├── .dialyzer_ignore_warnings ├── .formatter.exs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bench └── to_string.exs ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── release.exs └── test.exs ├── lib ├── money.ex └── money │ ├── application.ex │ ├── backend.ex │ ├── currency.ex │ ├── exception.ex │ ├── exchange_rates.ex │ ├── exchange_rates │ ├── cache │ │ ├── exchange_rates_cache_dets.ex │ │ ├── exchange_rates_cache_ets.ex │ │ └── exchange_rates_cache_etsdets.ex │ ├── callback_module.ex │ ├── exchange_rates_cache.ex │ ├── exchange_rates_retriever.ex │ ├── exchange_rates_supervisor.ex │ └── open_exchange_rates.ex │ ├── financial.ex │ ├── parser │ ├── combinators.ex │ └── parser.ex │ ├── protocol │ ├── gringotts.ex │ ├── inspect.ex │ ├── jason.ex │ ├── json.ex │ ├── phoenix_html_safe.ex │ └── string_chars.ex │ ├── sigil.ex │ ├── subscription.ex │ └── subscription │ ├── change.ex │ └── plan.ex ├── logo.png ├── mix.exs ├── mix.lock ├── mix └── test_cldr.ex └── test ├── application_test.exs ├── backend_test.exs ├── gringotts_test.exs ├── money_exchange_rates_test.exs ├── money_parse_test.exs ├── money_test.exs ├── money_token_test.exs ├── performance_test.exs ├── protocol_test.exs ├── split_test.exs ├── subscription_test.exs ├── support ├── exchange_rate_callback_module.ex ├── exchange_rate_helper.ex ├── exchange_rate_mock.ex ├── split_generator.ex └── test_cldr.ex └── test_helper.exs /.dialyzer_ignore_warnings: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["mix.exs", "{config,lib,test,mix}/**/*.{ex,exs}"], 3 | locals_without_parens: [docp: 1], 4 | line_length: 100 5 | ] 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | # Define workflow that runs when changes are pushed to the 4 | # `main` branch or pushed to a PR branch that targets the `main` 5 | # branch. Change the branch name if your project uses a 6 | # different name for the main branch like "master" or "production". 7 | on: 8 | push: 9 | branches: [ "main" ] # adapt branch for project 10 | pull_request: 11 | branches: [ "main" ] # adapt branch for project 12 | 13 | # Sets the ENV `MIX_ENV` to `test` for running tests 14 | env: 15 | MIX_ENV: test 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test: 22 | # Set up a Postgres DB service. By default, Phoenix applications 23 | # use Postgres. This creates a database for running tests. 24 | # Additional services can be defined here if required. 25 | # services: 26 | # db: 27 | # image: postgres:12 28 | # ports: ['5432:5432'] 29 | # env: 30 | # POSTGRES_PASSWORD: postgres 31 | # options: >- 32 | # --health-cmd pg_isready 33 | # --health-interval 10s 34 | # --health-timeout 5s 35 | # --health-retries 5 36 | 37 | runs-on: ubuntu-latest 38 | name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 39 | strategy: 40 | # Specify the OTP and Elixir versions to use when building 41 | # and running the workflow steps. 42 | matrix: 43 | include: 44 | - otp: "26.2" 45 | elixir: "1.17.2-otp-26" 46 | - otp: "26.2" 47 | elixir: "1.18.1-otp-26" 48 | - otp: "27.2" 49 | elixir: "1.17.2-otp-27" 50 | - otp: "27.2" 51 | elixir: "1.18.1-otp-27" 52 | lint: true 53 | steps: 54 | # Step: Setup Elixir + Erlang image as the base. 55 | - name: Set up Elixir 56 | uses: erlef/setup-beam@v1 57 | with: 58 | otp-version: ${{matrix.otp}} 59 | elixir-version: ${{matrix.elixir}} 60 | 61 | # Step: Check out the code. 62 | - name: Checkout code 63 | uses: actions/checkout@v3 64 | 65 | # Step: Define how to cache deps. Restores existing cache if present. 66 | - name: Cache deps 67 | id: cache-deps 68 | uses: actions/cache@v3 69 | env: 70 | cache-name: cache-elixir-deps 71 | with: 72 | path: deps 73 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} 74 | restore-keys: | 75 | ${{ runner.os }}-mix-${{ env.cache-name }}- 76 | 77 | # Step: Define how to cache the `_build` directory. After the first run, 78 | # this speeds up tests runs a lot. This includes not re-compiling our 79 | # project's downloaded deps every run. 80 | - name: Cache compiled build 81 | id: cache-build 82 | uses: actions/cache@v3 83 | env: 84 | cache-name: cache-compiled-build 85 | with: 86 | path: _build 87 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} 88 | restore-keys: | 89 | ${{ runner.os }}-mix-${{ env.cache-name }}- 90 | ${{ runner.os }}-mix- 91 | 92 | # Step: Download project dependencies. If unchanged, uses 93 | # the cached version. 94 | - name: Install dependencies 95 | run: mix deps.get 96 | 97 | # Step: Compile the project treating any warnings as errors. 98 | # Customize this step if a different behavior is desired. 99 | - name: Compiles without warnings 100 | run: mix compile --warnings-as-errors 101 | 102 | # Step: Check that the checked in code has already been formatted. 103 | # This step fails if something was found unformatted. 104 | # Customize this step as desired. 105 | # - name: Check Formatting 106 | # run: mix format --check-formatted 107 | 108 | # Step: Execute the tests. 109 | - name: Run tests 110 | run: mix test 111 | 112 | # Step: Execute dialyzer. 113 | - name: Run dialyzer 114 | run: mix dialyzer 115 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | # Priv dir is only used in development, not needed for the package 20 | /priv 21 | 22 | # Hex build files 23 | *.tar 24 | 25 | # Mac stuff 26 | .DS_Store 27 | 28 | # asdf 29 | .tool-versions -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ## License 2 | 3 | Copyright 2017-2021 Kip Cole 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in 6 | compliance with the License. You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software distributed under the License 11 | is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 12 | implied. See the License for the specific language governing permissions and limitations under the 13 | License. 14 | -------------------------------------------------------------------------------- /bench/to_string.exs: -------------------------------------------------------------------------------- 1 | money = Money.new(:USD, 100) 2 | 3 | options = [currency: money.currency] 4 | number = money.amount 5 | backend = Money.default_backend 6 | 7 | {:ok, options} = Cldr.Number.Format.Options.validate_options(0, backend, options) 8 | 9 | Benchee.run( 10 | %{ 11 | "to_string" => fn -> Money.to_string(money, options) end 12 | }, 13 | time: 10, 14 | memory_time: 2 15 | ) -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | import_config "#{Mix.env()}.exs" 6 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :ex_money, 4 | auto_start_exchange_rate_service: false, 5 | open_exchange_rates_app_id: {:system, "OPEN_EXCHANGE_RATES_APP_ID"}, 6 | exchange_rates_retrieve_every: 300_000, 7 | callback_module: Money.ExchangeRates.Callback, 8 | # preload_historic_rates: {~D[2017-01-01], ~D[2017-01-02]}, 9 | log_failure: :warning, 10 | log_info: :info, 11 | log_success: :info, 12 | exchange_rates_cache: Money.ExchangeRates.Cache.Dets, 13 | default_cldr_backend: Money.Cldr 14 | 15 | config :ex_cldr, 16 | default_backend: Money.Cldr 17 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kipcole9/money/6ca90ca70925c96c8f7c4056e887fe76d32e381d/config/prod.exs -------------------------------------------------------------------------------- /config/release.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :ex_money, Money.Repo, 4 | adapter: Ecto.Adapters.Postgres, 5 | username: "kip", 6 | database: "money_dev", 7 | hostname: "localhost", 8 | pool_size: 10 9 | 10 | config :ex_money, ecto_repos: [Money.Repo] 11 | 12 | config :ex_money, 13 | exchange_rates_retrieve_every: :never, 14 | open_exchange_rates_app_id: {:system, "OPEN_EXCHANGE_RATES_APP_ID"}, 15 | api_module: Money.ExchangeRates.Api.Test, 16 | log_failure: nil, 17 | log_info: nil, 18 | default_cldr_backend: Test.Cldr, 19 | json_library: Jason 20 | -------------------------------------------------------------------------------- /lib/money/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.Application do 2 | use Application 3 | alias Money.ExchangeRates 4 | require Logger 5 | 6 | @auto_start :auto_start_exchange_rate_service 7 | 8 | def start(_type, args) do 9 | children = [ 10 | Money.ExchangeRates.Supervisor 11 | ] 12 | 13 | opts = 14 | if args == [] do 15 | [strategy: :one_for_one, name: Money.Supervisor] 16 | else 17 | args 18 | end 19 | 20 | supervisor = Supervisor.start_link(children, opts) 21 | 22 | if start_exchange_rate_service?() do 23 | ExchangeRates.Supervisor.start_retriever() 24 | end 25 | 26 | supervisor 27 | end 28 | 29 | # Default is to not start the exchange rate service 30 | defp start_exchange_rate_service? do 31 | maybe_log_deprecation() 32 | 33 | start? = Money.get_env(@auto_start, true, :boolean) 34 | api_module = ExchangeRates.default_config().api_module 35 | api_module_present? = Code.ensure_loaded?(api_module) 36 | 37 | if !api_module_present? do 38 | Logger.error( 39 | "[ex_money] ExchangeRates api module #{api_module_name(api_module)} could not be loaded. " <> 40 | "Does it exist?" 41 | ) 42 | 43 | Logger.warning("ExchangeRates service will not be started.") 44 | end 45 | 46 | start? && api_module_present? 47 | end 48 | 49 | defp api_module_name(name) when is_atom(name) do 50 | name 51 | |> Atom.to_string() 52 | |> String.replace_leading("Elixir.", "") 53 | end 54 | 55 | @doc false 56 | def maybe_log_deprecation do 57 | case Application.fetch_env(:ex_money, :delay_before_first_retrieval) do 58 | {:ok, _} -> 59 | Logger.warning( 60 | "[ex_money] Configuration option :delay_before_first_retrieval is deprecated. " <> 61 | "Please remove it from your configuration." 62 | ) 63 | 64 | Application.delete_env(:ex_money, :delay_before_first_retrieval) 65 | 66 | :error -> 67 | nil 68 | end 69 | 70 | case Application.fetch_env(:ex_money, :exchange_rate_service) do 71 | {:ok, start?} -> 72 | Logger.warning( 73 | "[ex_money] Configuration option :exchange_rate_service is deprecated " <> 74 | "in favour of :auto_start_exchange_rate_service. Please " <> 75 | "update your configuration." 76 | ) 77 | 78 | Application.put_env(:ex_money, :auto_start_exchange_rate_service, start?) 79 | Application.delete_env(:ex_money, :exchange_rate_service) 80 | 81 | :error -> 82 | nil 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/money/currency.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.Currency do 2 | @moduledoc """ 3 | Functions to return lists of known, historic and 4 | legal tender currencies. 5 | """ 6 | @data_dir [:code.priv_dir(:ex_cldr), "/cldr/locales"] |> :erlang.iolist_to_binary() 7 | @config %{data_dir: @data_dir, locales: ["en"], default_locale: "en"} 8 | 9 | @currencies Cldr.Locale.Loader.get_locale(:en, @config) 10 | |> Map.get(:currencies) 11 | 12 | @current_currencies @currencies 13 | |> Enum.filter(fn {_code, currency} -> !is_nil(currency.iso_digits) end) 14 | |> Enum.map(fn {code, _currency} -> code end) 15 | |> Enum.sort() 16 | 17 | @historic_currencies @currencies 18 | |> Enum.filter(fn {_code, currency} -> is_nil(currency.iso_digits) end) 19 | |> Enum.map(fn {code, _currency} -> code end) 20 | |> Enum.sort() 21 | 22 | @tender_currencies @currencies 23 | |> Enum.filter(fn {_code, currency} -> currency.tender end) 24 | |> Enum.map(fn {code, _currency} -> code end) 25 | |> Enum.sort() 26 | 27 | @doc """ 28 | Returns the list of currently active ISO 4217 currency codes. 29 | 30 | ## Example: 31 | 32 | iex> Money.Currency.known_current_currencies() 33 | [:AED, :AFN, :ALL, :AMD, :ANG, :AOA, :ARS, :AUD, :AWG, :AZN, :BAM, :BBD, :BDT, 34 | :BGN, :BHD, :BIF, :BMD, :BND, :BOB, :BOV, :BRL, :BSD, :BTN, :BWP, :BYN, :BZD, 35 | :CAD, :CDF, :CHE, :CHF, :CHW, :CLF, :CLP, :CNY, :COP, :COU, :CRC, :CUC, :CUP, 36 | :CVE, :CZK, :DJF, :DKK, :DOP, :DZD, :EGP, :ERN, :ETB, :EUR, :FJD, :FKP, :GBP, 37 | :GEL, :GHS, :GIP, :GMD, :GNF, :GTQ, :GYD, :HKD, :HNL, :HRK, :HTG, :HUF, :IDR, 38 | :ILS, :INR, :IQD, :IRR, :ISK, :JMD, :JOD, :JPY, :KES, :KGS, :KHR, :KMF, :KPW, 39 | :KRW, :KWD, :KYD, :KZT, :LAK, :LBP, :LKR, :LRD, :LSL, :LYD, :MAD, :MDL, :MGA, 40 | :MKD, :MMK, :MNT, :MOP, :MRU, :MUR, :MVR, :MWK, :MXN, :MXV, :MYR, :MZN, :NAD, 41 | :NGN, :NIO, :NOK, :NPR, :NZD, :OMR, :PAB, :PEN, :PGK, :PHP, :PKR, :PLN, :PYG, 42 | :QAR, :RON, :RSD, :RUB, :RWF, :SAR, :SBD, :SCR, :SDG, :SEK, :SGD, :SHP, :SLL, 43 | :SOS, :SRD, :SSP, :STN, :SVC, :SYP, :SZL, :THB, :TJS, :TMT, :TND, :TOP, :TRY, 44 | :TTD, :TWD, :TZS, :UAH, :UGX, :USD, :USN, :UYI, :UYU, :UYW, :UZS, :VES, :VND, 45 | :VUV, :WST, :XAF, :XAG, :XAU, :XBA, :XBB, :XBC, :XBD, :XCD, :XDR, :XOF, :XPD, 46 | :XPF, :XPT, :XSU, :XTS, :XUA, :XXX, :YER, :ZAR, :ZMW, :ZWL] 47 | 48 | """ 49 | def known_current_currencies do 50 | @current_currencies 51 | end 52 | 53 | @doc """ 54 | Returns the list of historic ISO 4217 currency codes. 55 | 56 | ## Example: 57 | 58 | iex> Money.Currency.known_historic_currencies() 59 | [:ADP, :AFA, :ALK, :AOK, :AON, :AOR, :ARA, :ARL, :ARM, :ARP, :ATS, :AZM, :BAD, 60 | :BAN, :BEC, :BEF, :BEL, :BGL, :BGM, :BGO, :BOL, :BOP, :BRB, :BRC, :BRE, :BRN, 61 | :BRR, :BRZ, :BUK, :BYB, :BYR, :CLE, :CNH, :CNX, :CSD, :CSK, :CYP, :DDM, :DEM, 62 | :ECS, :ECV, :EEK, :ESA, :ESB, :ESP, :FIM, :FRF, :GEK, :GHC, :GNS, :GQE, :GRD, 63 | :GWE, :GWP, :HRD, :IEP, :ILP, :ILR, :ISJ, :ITL, :KRH, :KRO, :LTL, :LTT, :LUC, 64 | :LUF, :LUL, :LVL, :LVR, :MAF, :MCF, :MDC, :MGF, :MKN, :MLF, :MRO, :MTL, :MTP, 65 | :MVP, :MXP, :MZE, :MZM, :NIC, :NLG, :PEI, :PES, :PLZ, :PTE, :RHD, :ROL, :RUR, 66 | :SDD, :SDP, :SIT, :SKK, :SLE, :SRG, :STD, :SUR, :TJR, :TMM, :TPE, :TRL, :UAK, 67 | :UGS, :USS, :UYP, :VEB, :VED, :VEF, :VNN, :XCG, :XEU, :XFO, :XFU, :XRE, :YDD, 68 | :YUD, :YUM, :YUN, :YUR, :ZAL, :ZMK, :ZRN, :ZRZ, :ZWD, :ZWG, :ZWR] 69 | 70 | """ 71 | def known_historic_currencies do 72 | @historic_currencies 73 | end 74 | 75 | @doc """ 76 | Returns the list of legal tender ISO 4217 currency codes. 77 | 78 | ## Example: 79 | 80 | iex> Money.Currency.known_tender_currencies() 81 | [:ADP, :AED, :AFA, :AFN, :ALK, :ALL, :AMD, :ANG, :AOA, :AOK, :AON, :AOR, :ARA, 82 | :ARL, :ARM, :ARP, :ARS, :ATS, :AUD, :AWG, :AZM, :AZN, :BAD, :BAM, :BAN, :BBD, 83 | :BDT, :BEC, :BEF, :BEL, :BGL, :BGM, :BGN, :BGO, :BHD, :BIF, :BMD, :BND, :BOB, 84 | :BOL, :BOP, :BOV, :BRB, :BRC, :BRE, :BRL, :BRN, :BRR, :BRZ, :BSD, :BTN, :BUK, 85 | :BWP, :BYB, :BYN, :BYR, :BZD, :CAD, :CDF, :CHE, :CHF, :CHW, :CLE, :CLF, :CLP, 86 | :CNH, :CNX, :CNY, :COP, :COU, :CRC, :CSD, :CSK, :CUC, :CUP, :CVE, :CYP, :CZK, 87 | :DDM, :DEM, :DJF, :DKK, :DOP, :DZD, :ECS, :ECV, :EEK, :EGP, :ERN, :ESA, :ESB, 88 | :ESP, :ETB, :EUR, :FIM, :FJD, :FKP, :FRF, :GBP, :GEK, :GEL, :GHC, :GHS, :GIP, 89 | :GMD, :GNF, :GNS, :GQE, :GRD, :GTQ, :GWE, :GWP, :GYD, :HKD, :HNL, :HRD, :HRK, 90 | :HTG, :HUF, :IDR, :IEP, :ILP, :ILR, :ILS, :INR, :IQD, :IRR, :ISJ, :ISK, :ITL, 91 | :JMD, :JOD, :JPY, :KES, :KGS, :KHR, :KMF, :KPW, :KRH, :KRO, :KRW, :KWD, :KYD, 92 | :KZT, :LAK, :LBP, :LKR, :LRD, :LSL, :LTL, :LTT, :LUC, :LUF, :LUL, :LVL, :LVR, 93 | :LYD, :MAD, :MAF, :MCF, :MDC, :MDL, :MGA, :MGF, :MKD, :MKN, :MLF, :MMK, :MNT, 94 | :MOP, :MRO, :MRU, :MTL, :MTP, :MUR, :MVP, :MVR, :MWK, :MXN, :MXP, :MXV, :MYR, 95 | :MZE, :MZM, :MZN, :NAD, :NGN, :NIC, :NIO, :NLG, :NOK, :NPR, :NZD, :OMR, :PAB, 96 | :PEI, :PEN, :PES, :PGK, :PHP, :PKR, :PLN, :PLZ, :PTE, :PYG, :QAR, :RHD, :ROL, 97 | :RON, :RSD, :RUB, :RUR, :RWF, :SAR, :SBD, :SCR, :SDD, :SDG, :SDP, :SEK, :SGD, 98 | :SHP, :SIT, :SKK, :SLE, :SLL, :SOS, :SRD, :SRG, :SSP, :STD, :STN, :SUR, :SVC, 99 | :SYP, :SZL, :THB, :TJR, :TJS, :TMM, :TMT, :TND, :TOP, :TPE, :TRL, :TRY, :TTD, 100 | :TWD, :TZS, :UAH, :UAK, :UGS, :UGX, :USD, :USN, :USS, :UYI, :UYP, :UYU, :UYW, 101 | :UZS, :VEB, :VED, :VEF, :VES, :VND, :VNN, :VUV, :WST, :XAF, :XAG, :XAU, :XBA, 102 | :XBB, :XBC, :XBD, :XCD, :XCG, :XDR, :XEU, :XFO, :XFU, :XOF, :XPD, :XPF, :XPT, 103 | :XRE, :XSU, :XTS, :XUA, :XXX, :YDD, :YER, :YUD, :YUM, :YUN, :YUR, :ZAL, :ZAR, 104 | :ZMK, :ZMW, :ZRN, :ZRZ, :ZWD, :ZWG, :ZWL, :ZWR] 105 | 106 | """ 107 | def known_tender_currencies do 108 | @tender_currencies 109 | end 110 | 111 | def currency_for_code(code) do 112 | Cldr.Currency.currency_for_code(code, Money.default_backend!()) 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/money/exception.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.UnknownCurrencyError do 2 | defexception [:message] 3 | 4 | def exception(message) do 5 | %__MODULE__{message: message} 6 | end 7 | end 8 | 9 | defmodule Money.ExchangeRateError do 10 | defexception [:message] 11 | 12 | def exception(message) do 13 | %__MODULE__{message: message} 14 | end 15 | end 16 | 17 | defmodule Money.InvalidAmountError do 18 | defexception [:message] 19 | 20 | def exception(message) do 21 | %__MODULE__{message: message} 22 | end 23 | end 24 | 25 | defmodule Money.InvalidDigitsError do 26 | defexception [:message] 27 | 28 | def exception(message) do 29 | %__MODULE__{message: message} 30 | end 31 | end 32 | 33 | defmodule Money.Invalid do 34 | defexception [:message] 35 | 36 | def exception(message) do 37 | %__MODULE__{message: message} 38 | end 39 | end 40 | 41 | defmodule Money.ParseError do 42 | defexception [:message] 43 | 44 | def exception(message) do 45 | %__MODULE__{message: message} 46 | end 47 | end 48 | 49 | defmodule Money.Subscription.NoCurrentPlan do 50 | defexception [:message] 51 | 52 | def exception(message) do 53 | %__MODULE__{message: message} 54 | end 55 | end 56 | 57 | defmodule Money.Subscription.PlanError do 58 | defexception [:message] 59 | 60 | def exception(message) do 61 | %__MODULE__{message: message} 62 | end 63 | end 64 | 65 | defmodule Money.Subscription.DateError do 66 | defexception [:message] 67 | 68 | def exception(message) do 69 | %__MODULE__{message: message} 70 | end 71 | end 72 | 73 | defmodule Money.Subscription.PlanPending do 74 | defexception [:message] 75 | 76 | def exception(message) do 77 | %__MODULE__{message: message} 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/money/exchange_rates.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.ExchangeRates do 2 | @moduledoc """ 3 | Implements a behaviour and functions to retrieve exchange rates 4 | from an exchange rate service. 5 | 6 | Configuration for the exchange rate service is defined 7 | in a `Money.ExchangeRates.Config` struct. A default 8 | configuration is returned by `Money.ExchangeRates.default_config/0`. 9 | 10 | The default configuration is: 11 | 12 | config :ex_money, 13 | auto_start_exchange_rate_service: false, 14 | exchange_rates_retrieve_every: 300_000, 15 | api_module: Money.ExchangeRates.OpenExchangeRates, 16 | callback_module: Money.ExchangeRates.Callback, 17 | preload_historic_rates: nil 18 | log_failure: :warn, 19 | log_info: :info, 20 | log_success: nil 21 | 22 | These keys are are defined as follows: 23 | 24 | * `:auto_start_exchange_rate_service` is a boolean that determines whether to 25 | automatically start the exchange rate retrieval service. 26 | The default it false. 27 | 28 | * `:exchange_rates_retrieve_every` defines how often the exchange 29 | rates are retrieved in milliseconds. The default is 5 minutes 30 | (300,000 milliseconds). 31 | 32 | * `:api_module` identifies the module that does the retrieval of 33 | exchange rates. This is any module that implements the 34 | `Money.ExchangeRates` behaviour. The default is 35 | `Money.ExchangeRates.OpenExchangeRates`. 36 | 37 | * `:callback_module` defines a module that follows the 38 | Money.ExchangeRates.Callback behaviour whereby the function 39 | `rates_retrieved/2` is invoked after every successful retrieval 40 | of exchange rates. The default is `Money.ExchangeRates.Callback`. 41 | 42 | * `:preload_historic_rates` defines a date or a date range, 43 | that will be requested when the exchange rate service starts up. 44 | The date or date range should be specified as either a `Date.t` 45 | or a `Date.Range.t` or a tuple of `{Date.t, Date.t}` representing 46 | the `from` and `to` dates for the rates to be retrieved. The 47 | default is `nil` meaning no historic rates are preloaded. 48 | 49 | * `:log_failure` defines the log level at which api retrieval 50 | errors are logged. The default is `:warning`. 51 | 52 | * `:log_success` defines the log level at which successful api 53 | retrieval notifications are logged. The default is `nil` which 54 | means no logging. 55 | 56 | * `:log_info` defines the log level at which service startup messages 57 | are logged. The default is `:info`. 58 | 59 | * `:retriever_options` is available for exchange rate retriever 60 | module developers as a place to add retriever-specific configuration 61 | information. This information should be added in the `init/1` 62 | callback in the retriever module. See `Money.ExchangeRates.OpenExchangeRates.init/1` 63 | for an example. 64 | 65 | Keys can also be configured to retrieve values from environment 66 | variables. This lookup is done at runtime to facilitate deployment 67 | strategies. If the value of a configuration key is 68 | `{:system, "some_string"}` then "some_string" is interpreted as 69 | an environment variable name which is passed to System.get_env/2. 70 | 71 | An example configuration might be: 72 | 73 | config :ex_money, 74 | auto_start_exchange_rate_service: {:system, "RATE_SERVICE"}, 75 | exchange_rates_retrieve_every: {:system, "RETRIEVE_EVERY"}, 76 | 77 | ## Open Exchange Rates 78 | 79 | If you plan to use the provided Open Exchange Rates module 80 | to retrieve exchange rates then you should also provide the additional 81 | configuration key for `app_id`: 82 | 83 | config :ex_money, 84 | open_exchange_rates_app_id: "your_app_id" 85 | 86 | or configure it via environment variable: 87 | 88 | config :ex_money, 89 | open_exchange_rates_app_id: {:system, "OPEN_EXCHANGE_RATES_APP_ID"} 90 | 91 | The default exchange rate retrieval module is provided in 92 | `Money.ExchangeRates.OpenExchangeRates` which can be used 93 | as a example to implement your own retrieval module for 94 | other services. 95 | 96 | ## Managing the configuration at runtime 97 | 98 | During exchange rate service startup, the function `init/1` is called 99 | on the configuration exchange rate retrieval module. This module is 100 | expected to return an updated configuration allowing a developer to 101 | customise how the configuration is to be managed. See the implementation 102 | at `Money.ExchangeRates.OpenExchangeRates.init/1` for an example. 103 | 104 | """ 105 | 106 | @type t :: %{Money.currency_code() => Decimal.t()} 107 | 108 | @doc """ 109 | Invoked to return the latest exchange rates from the configured 110 | exchange rate retrieval service. 111 | 112 | * `config` is an `%Money.ExchangeRataes.Config{}` struct 113 | 114 | Returns `{:ok, map_of_rates}` or `{:error, reason}` 115 | 116 | """ 117 | @callback get_latest_rates(config :: Money.ExchangeRates.Config.t()) :: 118 | {:ok, map()} | {:error, binary} 119 | 120 | @doc """ 121 | Invoked to return the historic exchange rates from the configured 122 | exchange rate retrieval service. 123 | 124 | * `config` is an `%Money.ExchangeRataes.Config{}` struct 125 | 126 | Returns `{:ok, map_of_historic_rates}` or `{:error, reason}` 127 | 128 | """ 129 | @callback get_historic_rates(Date.t(), config :: Money.ExchangeRates.Config.t()) :: 130 | {:ok, map()} | {:error, binary} 131 | 132 | @doc """ 133 | Decode the body returned from the API request and 134 | return a map of rates. THe map of rates must have 135 | an upcased atom key representing an ISO 4217 currency 136 | code and the value must be a Decimal number. 137 | """ 138 | @callback decode_rates(any) :: map() 139 | 140 | @doc """ 141 | Given the default configuration, returns an updated configuration at runtime 142 | during exchange rates service startup. 143 | 144 | This callback is optional. If the callback is not defined, the default 145 | configuration returned by `Money.ExchangeRates.default_config/0` is used. 146 | 147 | * `config` is the configuration returned by `Money.ExchangeRates.default_config/0` 148 | 149 | The callback is expected to return a `%Money.ExchangeRates.Config.t()` struct 150 | which may have been updated. The configuration key `:retriever_options` is 151 | available for any service-specific configuration. 152 | 153 | """ 154 | @callback init(config :: Money.ExchangeRates.Config.t()) :: Money.ExchangeRates.Config.t() 155 | @optional_callbacks init: 1 156 | 157 | require Logger 158 | import Money.ExchangeRates.Cache 159 | alias Money.ExchangeRates.Retriever 160 | 161 | @default_retrieval_interval :never 162 | @default_callback_module Money.ExchangeRates.Callback 163 | @default_api_module Money.ExchangeRates.OpenExchangeRates 164 | @default_cache_module Money.ExchangeRates.Cache.Ets 165 | 166 | @doc """ 167 | Returns the configuration for `ex_money` including the 168 | configuration merged from the configured exchange rates 169 | retriever module. 170 | 171 | """ 172 | def config do 173 | api_module = default_config().api_module 174 | 175 | if function_exported?(api_module, :init, 1) do 176 | api_module.init(default_config()) 177 | else 178 | default_config() 179 | end 180 | end 181 | 182 | # Defines the configuration for the exchange rates mechanism. 183 | defmodule Config do 184 | @type t :: %__MODULE__{ 185 | retrieve_every: non_neg_integer | nil, 186 | api_module: module() | nil, 187 | callback_module: module() | nil, 188 | log_levels: map(), 189 | preload_historic_rates: Date.t() | Date.Range.t() | {Date.t(), Date.t()} | nil, 190 | retriever_options: map() | nil, 191 | cache_module: module() | nil, 192 | verify_peer: boolean() 193 | } 194 | 195 | defstruct retrieve_every: nil, 196 | api_module: nil, 197 | callback_module: nil, 198 | log_levels: %{}, 199 | preload_historic_rates: nil, 200 | retriever_options: nil, 201 | cache_module: nil, 202 | verify_peer: true 203 | end 204 | 205 | @doc """ 206 | Returns the default configuration for the exchange rates retriever. 207 | 208 | """ 209 | def default_config do 210 | %Config{ 211 | api_module: Money.get_env(:api_module, @default_api_module, :module), 212 | callback_module: Money.get_env(:callback_module, @default_callback_module, :module), 213 | preload_historic_rates: Money.get_env(:preload_historic_rates, nil), 214 | cache_module: Money.get_env(:exchange_rates_cache_module, @default_cache_module, :module), 215 | retrieve_every: 216 | Money.get_env(:exchange_rates_retrieve_every, @default_retrieval_interval, :maybe_integer), 217 | log_levels: %{ 218 | success: Money.get_env(:log_success, nil), 219 | failure: Money.get_env(:log_failure, :warning), 220 | info: Money.get_env(:log_info, :info) 221 | }, 222 | verify_peer: Money.get_env(:verify_peer, true, :boolean) 223 | } 224 | end 225 | 226 | @doc """ 227 | Return the latest exchange rates. 228 | 229 | Returns: 230 | 231 | * `{:ok, rates}` if exchange rates are successfully retrieved. `rates` is a map of 232 | exchange rates. 233 | 234 | * `{:error, reason}` if no exchange rates can be returned. 235 | 236 | This function looks up the latest exchange rates in a an ETS table 237 | called `:exchange_rates`. The actual retrieval of rates is requested 238 | through `Money.ExchangeRates.Retriever.latest_rates/0`. 239 | 240 | """ 241 | @spec latest_rates() :: {:ok, map()} | {:error, {Exception.t(), binary}} 242 | def latest_rates do 243 | try do 244 | case cache().latest_rates() do 245 | {:ok, rates} -> {:ok, rates} 246 | {:error, _} -> Retriever.latest_rates() 247 | end 248 | catch 249 | :exit, {:noproc, {GenServer, :call, [Money.ExchangeRates.Retriever, :config, _timeout]}} -> 250 | {:error, no_retriever_running_error()} 251 | end 252 | end 253 | 254 | @doc """ 255 | Return historic exchange rates. 256 | 257 | * `date` is a date returned by `Date.new/3` or any struct with the 258 | elements `:year`, `:month` and `:day`. 259 | 260 | Returns: 261 | 262 | * `{:ok, rates}` if exchange rates are successfully retrieved. `rates` is a map of 263 | exchange rates. 264 | 265 | * `{:error, reason}` if no exchange rates can be returned. 266 | 267 | **Note;** all dates are expected to be in the Calendar.ISO calendar 268 | 269 | This function looks up the historic exchange rates in a an ETS table 270 | called `:exchange_rates`. The actual retrieval of rates is requested 271 | through `Money.ExchangeRates.Retriever.historic_rates/1`. 272 | 273 | """ 274 | @spec historic_rates(Date.t()) :: {:ok, map()} | {:error, {Exception.t(), binary}} 275 | def historic_rates(date) do 276 | try do 277 | case cache().historic_rates(date) do 278 | {:ok, rates} -> {:ok, rates} 279 | {:error, _} -> Retriever.historic_rates(date) 280 | end 281 | catch 282 | :exit, {:noproc, {GenServer, :call, [Money.ExchangeRates.Retriever, :config, _timeout]}} -> 283 | {:error, no_retriever_running_error()} 284 | end 285 | end 286 | 287 | @doc """ 288 | Returns `true` if the latest exchange rates are available 289 | and false otherwise. 290 | """ 291 | @spec latest_rates_available?() :: boolean 292 | def latest_rates_available? do 293 | try do 294 | case cache().latest_rates() do 295 | {:ok, _rates} -> true 296 | _ -> false 297 | end 298 | catch 299 | :exit, {:noproc, {GenServer, :call, [Money.ExchangeRates.Retriever, :config, _timeout]}} -> 300 | false 301 | end 302 | end 303 | 304 | @doc """ 305 | Return the timestamp of the last successful retrieval of exchange rates or 306 | `{:error, reason}` if no timestamp is known. 307 | 308 | ## Example 309 | 310 | Money.ExchangeRates.last_updated 311 | #> {:ok, 312 | %DateTime{calendar: Calendar.ISO, day: 20, hour: 12, microsecond: {731942, 6}, 313 | minute: 36, month: 11, second: 6, std_offset: 0, time_zone: "Etc/UTC", 314 | utc_offset: 0, year: 2016, zone_abbr: "UTC"}} 315 | 316 | """ 317 | @spec last_updated() :: {:ok, DateTime.t()} | {:error, {Exception.t(), binary}} 318 | def last_updated do 319 | try do 320 | cache().last_updated() 321 | catch 322 | :exit, {:noproc, {GenServer, :call, [Money.ExchangeRates.Retriever, :config, _timeout]}} -> 323 | {:error, no_retriever_running_error()} 324 | end 325 | end 326 | 327 | defp no_retriever_running_error do 328 | {Money.ExchangeRateError, "Exchange Rates retrieval process is not running"} 329 | end 330 | end 331 | -------------------------------------------------------------------------------- /lib/money/exchange_rates/cache/exchange_rates_cache_dets.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.ExchangeRates.Cache.Dets do 2 | @moduledoc """ 3 | Money.ExchangeRates.Cache implementation for 4 | :dets 5 | """ 6 | 7 | @behaviour Money.ExchangeRates.Cache 8 | 9 | @ets_table :exchange_rates 10 | @dets_path Path.join(:code.priv_dir(:ex_money), ".exchange_rates") 11 | |> String.to_charlist() 12 | 13 | require Logger 14 | require Money.ExchangeRates.Cache.EtsDets 15 | Money.ExchangeRates.Cache.EtsDets.define_common_functions() 16 | 17 | def init do 18 | {:ok, name} = :dets.open_file(@ets_table, file: @dets_path) 19 | name 20 | end 21 | 22 | def terminate do 23 | :dets.close(@ets_table) 24 | end 25 | 26 | def get(key) do 27 | case :dets.lookup(@ets_table, key) do 28 | [{^key, value}] -> value 29 | [] -> nil 30 | end 31 | end 32 | 33 | def put(key, value) do 34 | :dets.insert(@ets_table, {key, value}) 35 | value 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/money/exchange_rates/cache/exchange_rates_cache_ets.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.ExchangeRates.Cache.Ets do 2 | @moduledoc """ 3 | Money.ExchangeRates.Cache implementation for 4 | :ets and :dets 5 | """ 6 | 7 | @behaviour Money.ExchangeRates.Cache 8 | 9 | @ets_table :exchange_rates 10 | 11 | require Logger 12 | require Money.ExchangeRates.Cache.EtsDets 13 | Money.ExchangeRates.Cache.EtsDets.define_common_functions() 14 | 15 | def init do 16 | if :ets.info(@ets_table) == :undefined do 17 | :ets.new(@ets_table, [ 18 | :named_table, 19 | :public, 20 | read_concurrency: true 21 | ]) 22 | else 23 | @ets_table 24 | end 25 | end 26 | 27 | def terminate do 28 | :ok 29 | end 30 | 31 | def get(key) do 32 | case :ets.lookup(@ets_table, key) do 33 | [{^key, value}] -> value 34 | [] -> nil 35 | end 36 | end 37 | 38 | def put(key, value) do 39 | :ets.insert(@ets_table, {key, value}) 40 | value 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/money/exchange_rates/cache/exchange_rates_cache_etsdets.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.ExchangeRates.Cache.EtsDets do 2 | defmacro define_common_functions do 3 | quote do 4 | def latest_rates do 5 | case get(:latest_rates) do 6 | nil -> 7 | {:error, {Money.ExchangeRateError, "No exchange rates were found"}} 8 | 9 | rates -> 10 | {:ok, rates} 11 | end 12 | end 13 | 14 | def historic_rates(%Date{calendar: Calendar.ISO} = date) do 15 | case get(date) do 16 | nil -> 17 | {:error, 18 | {Money.ExchangeRateError, "No exchange rates for #{Date.to_string(date)} were found"}} 19 | 20 | rates -> 21 | {:ok, rates} 22 | end 23 | end 24 | 25 | def historic_rates(%{year: year, month: month, day: day}) do 26 | {:ok, date} = Date.new(year, month, day) 27 | historic_rates(date) 28 | end 29 | 30 | def last_updated do 31 | case get(:last_updated) do 32 | nil -> 33 | Logger.error("Argument error getting last updated timestamp from ETS table") 34 | {:error, {Money.ExchangeRateError, "Last updated date is not known"}} 35 | 36 | last_updated -> 37 | {:ok, last_updated} 38 | end 39 | end 40 | 41 | def store_latest_rates(rates, retrieved_at) do 42 | put(:latest_rates, rates) 43 | put(:last_updated, retrieved_at) 44 | end 45 | 46 | def store_historic_rates(rates, date) do 47 | put(date, rates) 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/money/exchange_rates/callback_module.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.ExchangeRates.Callback do 2 | @moduledoc """ 3 | Default exchange rates retrieval callback module. 4 | 5 | When exchange rates are successfully retrieved, the function 6 | `latest_rates_retrieved/2` or `historic_rates_retrieved/2` is 7 | called to perform any desired serialization or proocessing. 8 | """ 9 | 10 | @doc """ 11 | Defines the behaviour to retrieve the latest exchange rates from an external 12 | data source. 13 | """ 14 | @callback latest_rates_retrieved(%{}, DateTime.t()) :: :ok 15 | 16 | @doc """ 17 | Defines the behaviour to retrieve historic exchange rates from an external 18 | data source. 19 | """ 20 | @callback historic_rates_retrieved(%{}, Date.t()) :: :ok 21 | 22 | @doc """ 23 | Callback function invoked when the latest exchange rates are retrieved. 24 | """ 25 | @spec latest_rates_retrieved(%{}, DateTime.t()) :: :ok 26 | def latest_rates_retrieved(_rates, _retrieved_at) do 27 | :ok 28 | end 29 | 30 | @doc """ 31 | Callback function invoked when historic exchange rates are retrieved. 32 | """ 33 | @spec historic_rates_retrieved(%{}, Date.t()) :: :ok 34 | def historic_rates_retrieved(_rates, _date) do 35 | :ok 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/money/exchange_rates/exchange_rates_cache.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.ExchangeRates.Cache do 2 | @moduledoc """ 3 | Defines a cache behaviour and default inplementation 4 | of a cache for exchange rates 5 | """ 6 | 7 | @doc """ 8 | Initialize the cache when the exchange rates 9 | retriever is started 10 | """ 11 | @callback init() :: any() 12 | 13 | @doc """ 14 | Terminate the cache when the retriver process 15 | stops normally 16 | """ 17 | @callback terminate() :: any() 18 | 19 | @doc """ 20 | Retrieve the latest exchange rates from the 21 | cache. 22 | """ 23 | @callback latest_rates() :: {:ok, map()} | {:error, {Exception.t(), String.t()}} 24 | 25 | @doc """ 26 | Returns the exchange rates for a given 27 | date. 28 | """ 29 | @callback historic_rates(Date.t()) :: {:ok, map()} | {:error, {Exception.t(), String.t()}} 30 | 31 | @doc """ 32 | Store the latest exchange rates in the cache. 33 | """ 34 | @callback store_latest_rates(map(), DateTime.t()) :: :ok 35 | 36 | @doc """ 37 | Store the historic exchange rates for a given 38 | date in the cache. 39 | """ 40 | @callback store_historic_rates(map(), Date.t()) :: :ok 41 | 42 | def latest_rates do 43 | cache().latest_rates 44 | end 45 | 46 | def historic_rates(date) do 47 | cache().historic_rates(date) 48 | end 49 | 50 | def cache do 51 | Money.ExchangeRates.Retriever.config().cache_module 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/money/exchange_rates/exchange_rates_retriever.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.ExchangeRates.Retriever do 2 | @moduledoc """ 3 | Implements a `GenServer` to retrieve exchange rates from 4 | a configured retrieveal module on a periodic or on demand basis. 5 | 6 | By default exchange rates are retrieved from [Open Exchange Rates](http://openexchangerates.org). 7 | 8 | The default period of execution is 5 minutes (300_000 milliseconds). The 9 | period of retrieval is configured in `config.exs` or the appropriate 10 | environment configuration. For example: 11 | 12 | config :ex_money, 13 | retrieve_every: 300_000 14 | 15 | """ 16 | 17 | use GenServer 18 | require Logger 19 | 20 | @etag_cache :etag_cache 21 | 22 | @doc """ 23 | Starts the exchange rates retrieval service 24 | """ 25 | def start(config \\ Money.ExchangeRates.config()) do 26 | Money.ExchangeRates.Supervisor.start_retriever(config) 27 | end 28 | 29 | @doc """ 30 | Stop the exchange rates retrieval service. 31 | 32 | The service can be restarted with `restart/0`. 33 | """ 34 | def stop do 35 | Money.ExchangeRates.Supervisor.stop_retriever() 36 | end 37 | 38 | @doc """ 39 | Restart the exchange rates retrieval service 40 | """ 41 | def restart do 42 | Money.ExchangeRates.Supervisor.restart_retriever() 43 | end 44 | 45 | @doc """ 46 | Delete the exchange rates retrieval service 47 | 48 | The service can be started again with `start/1` 49 | """ 50 | def delete do 51 | Money.ExchangeRates.Supervisor.delete_retriever() 52 | end 53 | 54 | @doc false 55 | def start_link(name, config \\ Money.ExchangeRates.config()) do 56 | GenServer.start_link(__MODULE__, config, name: name) 57 | end 58 | 59 | @doc """ 60 | Forces retrieval of the latest exchange rates 61 | 62 | Sends a message ot the exchange rate retrieval worker to request 63 | current rates be retrieved and stored. 64 | 65 | Returns: 66 | 67 | * `{:ok, rates}` if exchange rates request is successfully sent. 68 | 69 | * `{:error, reason}` if the request cannot be send. 70 | 71 | This function does not return exchange rates, for that see 72 | `Money.ExchangeRates.latest_rates/0` or 73 | `Money.ExchangeRates.historic_rates/1`. 74 | 75 | """ 76 | def latest_rates() do 77 | case Process.whereis(__MODULE__) do 78 | nil -> {:error, exchange_rate_service_error()} 79 | _pid -> GenServer.call(__MODULE__, :latest_rates) 80 | end 81 | end 82 | 83 | @doc """ 84 | Forces retrieval of historic exchange rates for a single date 85 | 86 | * `date` is a date returned by `Date.new/3` or any struct with the 87 | elements `:year`, `:month` and `:day` or 88 | 89 | * a `Date.Range.t` created by `Date.range/2` that specifies a 90 | range of dates to retrieve 91 | 92 | Returns: 93 | 94 | * `{:ok, rates}` if exchange rates request is successfully sent. 95 | 96 | * `{:error, reason}` if the request cannot be send. 97 | 98 | Sends a message ot the exchange rate retrieval worker to request 99 | historic rates for a specified date or range be retrieved and 100 | stored. 101 | 102 | This function does not return exchange rates, for that see 103 | `Money.ExchangeRates.latest_rates/0` or 104 | `Money.ExchangeRates.historic_rates/1`. 105 | 106 | """ 107 | def historic_rates(%Date{calendar: Calendar.ISO} = date) do 108 | case Process.whereis(__MODULE__) do 109 | nil -> {:error, exchange_rate_service_error()} 110 | _pid -> GenServer.call(__MODULE__, {:historic_rates, date}) 111 | end 112 | end 113 | 114 | def historic_rates(%{year: year, month: month, day: day}) do 115 | case Date.new(year, month, day) do 116 | {:ok, date} -> historic_rates(date) 117 | error -> error 118 | end 119 | end 120 | 121 | def historic_rates(%Date.Range{first: from, last: to}) do 122 | historic_rates(from, to) 123 | end 124 | 125 | @doc """ 126 | Forces retrieval of historic exchange rates for a range of dates 127 | 128 | * `from` is a date returned by `Date.new/3` or any struct with the 129 | elements `:year`, `:month` and `:day`. 130 | 131 | * `to` is a date returned by `Date.new/3` or any struct with the 132 | elements `:year`, `:month` and `:day`. 133 | 134 | Returns: 135 | 136 | * `{:ok, rates}` if exchange rates request is successfully sent. 137 | 138 | * `{:error, reason}` if the request cannot be send. 139 | 140 | Sends a message to the exchange rate retrieval process for each 141 | date in the range `from`..`to` to request historic rates be 142 | retrieved. 143 | 144 | """ 145 | def historic_rates(%Date{calendar: Calendar.ISO} = from, %Date{calendar: Calendar.ISO} = to) do 146 | case Process.whereis(__MODULE__) do 147 | nil -> 148 | {:error, exchange_rate_service_error()} 149 | 150 | _pid -> 151 | for date <- Date.range(from, to) do 152 | historic_rates(date) 153 | end 154 | end 155 | end 156 | 157 | def historic_rates(%{year: y1, month: m1, day: d1}, %{year: y2, month: m2, day: d2}) do 158 | with {:ok, from} <- Date.new(y1, m1, d1), 159 | {:ok, to} <- Date.new(y2, m2, d2) do 160 | historic_rates(from, to) 161 | end 162 | end 163 | 164 | @doc """ 165 | Updated the configuration for the Exchange Rate 166 | Service 167 | 168 | """ 169 | def reconfigure(%Money.ExchangeRates.Config{} = config) do 170 | GenServer.call(__MODULE__, {:reconfigure, config}) 171 | end 172 | 173 | @doc """ 174 | Return the current configuration of the Exchange Rates 175 | Retrieval service 176 | 177 | """ 178 | def config do 179 | GenServer.call(__MODULE__, :config) 180 | end 181 | 182 | @doc """ 183 | Retrieve exchange rates from an external HTTP 184 | service. 185 | 186 | This function is primarily intended for use by 187 | an exchange rates api module. 188 | """ 189 | def retrieve_rates(url, config) when is_binary(url) do 190 | url 191 | |> String.to_charlist() 192 | |> retrieve_rates(config) 193 | end 194 | 195 | def retrieve_rates(url, config) when is_list(url) do 196 | url = List.to_string(url) 197 | headers = if_none_match_header(url) 198 | 199 | {url, headers} 200 | |> Cldr.Http.get_with_headers(verify_peer: Map.get(config, :verify_peer, true)) 201 | |> process_response(url, config) 202 | end 203 | 204 | defp process_response({:ok, headers, body}, url, config) do 205 | rates = config.api_module.decode_rates(body) 206 | cache_etag(headers, url) 207 | {:ok, rates} 208 | end 209 | 210 | defp process_response({:not_modified, headers}, url, _config) do 211 | cache_etag(headers, url) 212 | {:ok, :not_modified} 213 | end 214 | 215 | defp process_response({:error, reason}, _url, _config) do 216 | {:error, {Money.ExchangeRateError, "#{inspect(reason)}"}} 217 | end 218 | 219 | defp if_none_match_header(url) do 220 | case get_etag(url) do 221 | {etag, date} -> 222 | [ 223 | {String.to_charlist("If-None-Match"), etag}, 224 | {String.to_charlist("If-Modified-Since"), date} 225 | ] 226 | 227 | _ -> 228 | [] 229 | end 230 | end 231 | 232 | defp cache_etag(headers, url) do 233 | etag = :proplists.get_value(String.to_charlist("etag"), headers) 234 | date = :proplists.get_value(String.to_charlist("date"), headers) 235 | 236 | if etag?(etag, date) do 237 | :ets.insert(@etag_cache, {url, {etag, date}}) 238 | else 239 | :ets.delete(@etag_cache, url) 240 | end 241 | end 242 | 243 | defp get_etag(url) do 244 | case :ets.lookup(@etag_cache, url) do 245 | [{^url, cached_value}] -> cached_value 246 | [] -> nil 247 | end 248 | end 249 | 250 | defp etag?(etag, date) do 251 | etag != :undefined && date != :undefined 252 | end 253 | 254 | # 255 | # Server implementation 256 | # 257 | 258 | @doc false 259 | def init(config) do 260 | :erlang.process_flag(:trap_exit, true) 261 | config.cache_module.init() 262 | 263 | if is_integer(config.retrieve_every) do 264 | log(config, :info, log_init_message(config.retrieve_every)) 265 | schedule_work(0) 266 | end 267 | 268 | if config.preload_historic_rates do 269 | log(config, :info, "Preloading historic rates for #{inspect(config.preload_historic_rates)}") 270 | schedule_work(config.preload_historic_rates, config.cache_module) 271 | end 272 | 273 | if :ets.info(@etag_cache) == :undefined do 274 | :ets.new(@etag_cache, [:named_table, :public]) 275 | end 276 | 277 | {:ok, config} 278 | end 279 | 280 | @doc false 281 | def terminate(:normal, config) do 282 | config.cache_module.terminate() 283 | end 284 | 285 | @doc false 286 | def terminate(:shutdown, config) do 287 | config.cache_module.terminate() 288 | end 289 | 290 | @doc false 291 | def terminate(other, _config) do 292 | Logger.error("[ExchangeRates.Retriever] Terminate called with unhandled #{inspect(other)}") 293 | end 294 | 295 | @doc false 296 | def handle_call(:latest_rates, _from, config) do 297 | {:reply, retrieve_latest_rates(config), config} 298 | end 299 | 300 | @doc false 301 | def handle_call({:historic_rates, date}, _from, config) do 302 | {:reply, retrieve_historic_rates(date, config), config} 303 | end 304 | 305 | @doc false 306 | def handle_call({:reconfigure, new_configuration}, _from, config) do 307 | config.cache_module.terminate() 308 | {:ok, new_config} = init(new_configuration) 309 | {:reply, new_config, new_config} 310 | end 311 | 312 | @doc false 313 | def handle_call(:config, _from, config) do 314 | {:reply, config, config} 315 | end 316 | 317 | @doc false 318 | def handle_call(:stop, _from, config) do 319 | {:stop, :normal, :ok, config} 320 | end 321 | 322 | @doc false 323 | def handle_call({:stop, reason}, _from, config) do 324 | {:stop, reason, :ok, config} 325 | end 326 | 327 | @doc false 328 | def handle_info(:latest_rates, config) do 329 | retrieve_latest_rates(config) 330 | schedule_work(config.retrieve_every) 331 | {:noreply, config} 332 | end 333 | 334 | @doc false 335 | def handle_info({:historic_rates, %Date{calendar: Calendar.ISO} = date}, config) do 336 | retrieve_historic_rates(date, config) 337 | {:noreply, config} 338 | end 339 | 340 | @doc false 341 | def handle_info(:stop, config) do 342 | {:stop, :normal, config} 343 | end 344 | 345 | @doc false 346 | def handle_info({:stop, reason}, config) do 347 | {:stop, reason, config} 348 | end 349 | 350 | @doc false 351 | def handle_info(message, config) do 352 | Logger.error("Invalid message for ExchangeRates.Retriever: #{inspect(message)}") 353 | {:noreply, config} 354 | end 355 | 356 | defp retrieve_latest_rates(%{callback_module: callback_module} = config) do 357 | case config.api_module.get_latest_rates(config) do 358 | {:ok, :not_modified} -> 359 | log(config, :success, "Retrieved latest exchange rates successfully. Rates unchanged.") 360 | {:ok, config.cache_module.latest_rates()} 361 | 362 | {:ok, rates} -> 363 | retrieved_at = DateTime.utc_now() 364 | config.cache_module.store_latest_rates(rates, retrieved_at) 365 | apply(callback_module, :latest_rates_retrieved, [rates, retrieved_at]) 366 | log(config, :success, "Retrieved latest exchange rates successfully") 367 | {:ok, rates} 368 | 369 | {:error, reason} -> 370 | log(config, :failure, "Could not retrieve latest exchange rates: #{inspect(reason)}") 371 | {:error, reason} 372 | end 373 | end 374 | 375 | defp retrieve_historic_rates(date, %{callback_module: callback_module} = config) do 376 | case config.api_module.get_historic_rates(date, config) do 377 | {:ok, :not_modified} -> 378 | log(config, :success, "Historic exchange rates for #{Date.to_string(date)} are unchanged") 379 | {:ok, config.cache_module.historic_rates(date)} 380 | 381 | {:ok, rates} -> 382 | config.cache_module.store_historic_rates(rates, date) 383 | apply(callback_module, :historic_rates_retrieved, [rates, date]) 384 | 385 | log( 386 | config, 387 | :success, 388 | "Retrieved historic exchange rates for #{Date.to_string(date)} successfully" 389 | ) 390 | 391 | {:ok, rates} 392 | 393 | {:error, reason} -> 394 | log( 395 | config, 396 | :failure, 397 | "Could not retrieve historic exchange rates " <> 398 | "for #{Date.to_string(date)}: #{inspect(reason)}" 399 | ) 400 | 401 | {:error, reason} 402 | end 403 | end 404 | 405 | defp schedule_work(delay_ms) when is_integer(delay_ms) do 406 | Process.send_after(self(), :latest_rates, delay_ms) 407 | end 408 | 409 | defp schedule_work(%Date.Range{} = date_range, cache_module) do 410 | for date <- date_range do 411 | schedule_work(date, cache_module) 412 | end 413 | end 414 | 415 | # Don't retrieve historic rates if they are 416 | # already cached. Note that this is only 417 | # called at retriever initialization, not 418 | # through the public api. 419 | # 420 | # This depends on: 421 | # 1. The cache is persistent, like Cache.Dets 422 | # 2. The assumption that historic rates don't change 423 | # A persistent cache will reduce the number of 424 | # external API calls and it means the cache 425 | # will survive restarts both intentional and 426 | # unintentional 427 | defp schedule_work(%Date{calendar: Calendar.ISO} = date, cache_module) do 428 | case cache_module.historic_rates(date) do 429 | {:ok, _rates} -> 430 | :ok 431 | 432 | {:error, _} -> 433 | Process.send(self(), {:historic_rates, date}, []) 434 | end 435 | end 436 | 437 | defp schedule_work({%Date{} = from, %Date{} = to}, cache_module) do 438 | schedule_work(Date.range(from, to), cache_module) 439 | end 440 | 441 | defp schedule_work(date_string, cache_module) when is_binary(date_string) do 442 | parts = String.split(date_string, "..") 443 | 444 | case parts do 445 | [date] -> schedule_work(Date.from_iso8601(date), cache_module) 446 | [from, to] -> schedule_work({Date.from_iso8601(from), Date.from_iso8601(to)}, cache_module) 447 | end 448 | end 449 | 450 | # Any non-numeric value, or non-date value means 451 | # we don't schedule work - ie there is no periodic 452 | # retrieval 453 | defp schedule_work(_, _cache_module) do 454 | :ok 455 | end 456 | 457 | @doc false 458 | def log(%{log_levels: log_levels}, key, message) do 459 | case Map.get(log_levels, key) do 460 | nil -> 461 | nil 462 | 463 | log_level -> 464 | Logger.log(log_level, message) 465 | end 466 | end 467 | 468 | defp log_init_message(every) do 469 | {every, plural_every} = seconds(every) 470 | "Exchange Rates will be retrieved now and then every #{every} #{plural_every}." 471 | end 472 | 473 | defp seconds(milliseconds) do 474 | seconds = div(milliseconds, 1000) 475 | plural = if seconds == 1, do: "second", else: "seconds" 476 | 477 | {:ok, formatted_seconds} = 478 | Cldr.Number.to_string(seconds, backend: Money.default_backend!()) 479 | 480 | {formatted_seconds, plural} 481 | end 482 | 483 | defp exchange_rate_service_error do 484 | {Money.ExchangeRateError, "Exchange rate service does not appear to be running"} 485 | end 486 | end 487 | -------------------------------------------------------------------------------- /lib/money/exchange_rates/exchange_rates_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.ExchangeRates.Supervisor do 2 | @moduledoc """ 3 | Functions to manage the starting, stopping, 4 | deleting and restarting of the Exchange 5 | Rates Retriever. 6 | """ 7 | 8 | use Supervisor 9 | alias Money.ExchangeRates 10 | 11 | @child_name ExchangeRates.Retriever 12 | 13 | @doc """ 14 | Starts the Exchange Rates supervisor and 15 | optionally starts the exchange rates 16 | retrieval service as well. 17 | 18 | ## Options 19 | 20 | * `:restart` is a boolean value indicating 21 | if the supervisor is to be restarted. This is 22 | typically used to move the supervisor from its 23 | default position under the `ex_money` supervision 24 | tree to a different supervision tree. The default 25 | is `false` 26 | 27 | * `:start_retriever` is a boolean indicating 28 | if the exchange rates retriever is to be started 29 | when the supervisor is started. The default is 30 | defined by the configuration key 31 | `:auto_start_exchange_rate_service` 32 | """ 33 | def start_link do 34 | Supervisor.start_link(__MODULE__, :ok, name: ExchangeRates.Supervisor) 35 | end 36 | 37 | def start_link(options) do 38 | options = Keyword.merge(default_options(), options) 39 | if options[:restart], do: stop() 40 | supervisor = start_link() 41 | if options[:start_retriever], do: start_retriever!() 42 | supervisor 43 | end 44 | 45 | defp default_options do 46 | [ 47 | restart: false, 48 | start_retriever: Money.get_env(:auto_start_exchange_rates_service, false, :boolean) 49 | ] 50 | end 51 | 52 | @doc """ 53 | Stop the Money.ExchangeRates.Supervisor. 54 | 55 | Unless `ex_money` is configured in `mix.exs` as 56 | `rumtime: false`, the Money.ExchangeRates.Supervisor 57 | is always started when `ex_money` starts even if the 58 | config key `:auto_start_exchange_rates_service` is 59 | set to `false`. 60 | 61 | 62 | In some instances an application may require the 63 | `Money.ExchangeRates.Supervisor` to be started under 64 | a different supervision tree. In this case it is 65 | required to call this function first before a new 66 | configuration is started. 67 | 68 | One use case is when the Exchange Rates service is 69 | configured with either an API module, a Callback module 70 | or a Cache module which uses Ecto and therefore its 71 | a requirement that Ecto is started first. 72 | 73 | See the README section on "Using Ecto or other applications 74 | from within the callback module" for an eanple of how 75 | to configure the supervisor in this case. 76 | """ 77 | def stop(supervisor \\ default_supervisor()) do 78 | Supervisor.terminate_child(supervisor, __MODULE__) 79 | end 80 | 81 | @doc """ 82 | Returns the name of the default supervisor 83 | which is `Money.Supervisor` 84 | 85 | """ 86 | def default_supervisor do 87 | {_, options} = 88 | Application.spec(:ex_money) 89 | |> Keyword.get(:mod) 90 | 91 | Keyword.get(options, :name) 92 | end 93 | 94 | @doc false 95 | def init(:ok) do 96 | Supervisor.init([], strategy: :one_for_one) 97 | end 98 | 99 | @doc """ 100 | Returns a boolean indicating of the 101 | retriever process is configured and 102 | running 103 | """ 104 | def retriever_running? do 105 | !!Process.whereis(@child_name) 106 | end 107 | 108 | @doc """ 109 | Returns the status of the exchange rates 110 | retriever. The returned value is one of: 111 | 112 | * `:running` if the service is running. In this 113 | state the valid action is `Money.ExchangeRates.Service.stop/0`. 114 | 115 | * `:stopped` if it is stopped. In this state 116 | the valid actions are `Money.ExchangeRates.Supervisor.restart_retriever/0` 117 | or `Money.ExchangeRates.Supervisor.delete_retriever/0`. 118 | 119 | * `:not_started` if it is not configured 120 | in the supervisor and is not running. In 121 | this state the only valid action is 122 | `Money.ExchangeRates.Supervisor.start_retriever/1`. 123 | 124 | """ 125 | def retriever_status do 126 | cond do 127 | !!Process.whereis(@child_name) -> :running 128 | configured?(@child_name) -> :stopped 129 | true -> :not_started 130 | end 131 | end 132 | 133 | defp configured?(child) do 134 | Money.ExchangeRates.Supervisor 135 | |> Supervisor.which_children() 136 | |> Enum.any?(fn {name, _pid, _type, _args} -> name == child end) 137 | end 138 | 139 | @doc """ 140 | Starts the exchange rates retriever. 141 | 142 | ## Arguments 143 | 144 | * `config` is a `t:Money.ExchangeRates.Config.t/0` 145 | struct returned by `Money.ExchangeRates.config/0` 146 | and adjusted as required. The default is 147 | `Money.ExchangeRates.config/0`. 148 | 149 | """ 150 | def start_retriever(config \\ ExchangeRates.config()) do 151 | _ = Money.default_backend!() 152 | Supervisor.start_child(__MODULE__, retriever_spec(config)) 153 | end 154 | 155 | @doc """ 156 | Stop the exchange rates retriever. 157 | """ 158 | def stop_retriever do 159 | Supervisor.terminate_child(__MODULE__, @child_name) 160 | end 161 | 162 | @doc """ 163 | Restarts a stopped retriever. 164 | 165 | See also `Money.ExchangeRates.Retriever.stop/0` 166 | """ 167 | def restart_retriever do 168 | Supervisor.restart_child(__MODULE__, @child_name) 169 | end 170 | 171 | @doc """ 172 | Deleted the retriever child specification from 173 | the exchange rates supervisor. 174 | 175 | This is primarily of use if you want to change 176 | the configuration of the retriever after it is 177 | stopped and before it is restarted. 178 | 179 | In this situation the sequence of calls would be: 180 | 181 | ``` 182 | iex> Money.ExchangeRates.Retriever.stop 183 | iex> Money.ExchangeRates.Retriever.delete 184 | iex> Money.ExchangeRates.Retriever.start(config) 185 | ``` 186 | 187 | """ 188 | def delete_retriever do 189 | Supervisor.delete_child(__MODULE__, @child_name) 190 | end 191 | 192 | defp retriever_spec(config) do 193 | %{id: @child_name, start: {@child_name, :start_link, [@child_name, config]}} 194 | end 195 | 196 | defp start_retriever! do 197 | case ExchangeRates.Retriever.start() do 198 | {:ok, _pid} -> :ok 199 | {:error, reason} -> raise "Unhandled error starting retriever; #{inspect(reason)}" 200 | end 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /lib/money/exchange_rates/open_exchange_rates.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.ExchangeRates.OpenExchangeRates do 2 | @moduledoc """ 3 | Implements the `Money.ExchangeRates` for the Open Exchange 4 | Rates service. 5 | 6 | ## Required configuration: 7 | 8 | The configuration key `:open_exchange_rates_app_id` should be 9 | set to your `app_id`. for example: 10 | 11 | config :ex_money, 12 | open_exchange_rates_app_id: "your_app_id" 13 | 14 | or configure it via environment variable: 15 | 16 | config :ex_money, 17 | open_exchange_rates_app_id: {:system, "OPEN_EXCHANGE_RATES_APP_ID"} 18 | 19 | It is also possible to configure an alternative base url for this 20 | service in case it changes in the future. For example: 21 | 22 | config :ex_money, 23 | open_exchange_rates_app_id: "your_app_id" 24 | open_exchange_rates_url: "https://openexchangerates.org/alternative_api" 25 | 26 | """ 27 | 28 | alias Money.ExchangeRates.Retriever 29 | 30 | @behaviour Money.ExchangeRates 31 | 32 | @open_exchange_rate_url "https://openexchangerates.org/api" 33 | 34 | @doc """ 35 | Update the retriever configuration to include the requirements 36 | for Open Exchange Rates. This function is invoked when the 37 | exchange rate service starts up, just after the ets table 38 | :exchange_rates is created. 39 | 40 | * `default_config` is the configuration returned by `Money.ExchangeRates.default_config/0` 41 | 42 | Returns the configuration either unchanged or updated with 43 | additional configuration specific to this exchange 44 | rates retrieval module. 45 | """ 46 | def init(default_config) do 47 | url = Money.get_env(:open_exchange_rates_url, @open_exchange_rate_url) 48 | app_id = Money.get_env(:open_exchange_rates_app_id, nil) 49 | Map.put(default_config, :retriever_options, %{url: url, app_id: app_id}) 50 | end 51 | 52 | def decode_rates(body) when is_list(body) do 53 | body 54 | |> List.to_string() 55 | |> decode_rates() 56 | end 57 | 58 | def decode_rates(body) when is_binary(body) do 59 | %{"base" => _base, "rates" => rates} = Money.json_library().decode!(body) 60 | 61 | rates 62 | |> Cldr.Map.atomize_keys() 63 | |> Enum.map(fn 64 | {k, v} when is_float(v) -> {k, Decimal.from_float(v)} 65 | {k, v} when is_integer(v) -> {k, Decimal.new(v)} 66 | end) 67 | |> Enum.into(%{}) 68 | end 69 | 70 | @doc """ 71 | Retrieves the latest exchange rates from Open Exchange Rates site. 72 | 73 | * `config` is the retrieval configuration. When invoked from the 74 | exchange rates services this will be the config returned from 75 | `Money.ExchangeRates.config/0` 76 | 77 | Returns: 78 | 79 | * `{:ok, rates}` if the rates can be retrieved 80 | 81 | * `{:error, reason}` if rates cannot be retrieved 82 | 83 | Typically this function is called by the exchange rates retrieval 84 | service although it can be called outside that context as 85 | required. 86 | 87 | """ 88 | @spec get_latest_rates(Money.ExchangeRates.Config.t()) :: {:ok, map()} | {:error, String.t()} 89 | def get_latest_rates(config) do 90 | url = config.retriever_options.url 91 | app_id = config.retriever_options.app_id 92 | retrieve_latest_rates(url, app_id, config) 93 | end 94 | 95 | defp retrieve_latest_rates(_url, nil, _config) do 96 | {:error, app_id_not_configured()} 97 | end 98 | 99 | @latest_rates "/latest.json" 100 | defp retrieve_latest_rates(url, app_id, config) do 101 | Retriever.retrieve_rates(url <> @latest_rates <> "?app_id=" <> app_id, config) 102 | end 103 | 104 | @doc """ 105 | Retrieves the historic exchange rates from Open Exchange Rates site. 106 | 107 | * `date` is a date returned by `Date.new/3` or any struct with the 108 | elements `:year`, `:month` and `:day`. 109 | 110 | * `config` is the retrieval configuration. When invoked from the 111 | exchange rates services this will be the config returned from 112 | `Money.ExchangeRates.config/0` 113 | 114 | Returns: 115 | 116 | * `{:ok, rates}` if the rates can be retrieved 117 | 118 | * `{:error, reason}` if rates cannot be retrieved 119 | 120 | Typically this function is called by the exchange rates retrieval 121 | service although it can be called outside that context as 122 | required. 123 | """ 124 | def get_historic_rates(date, config) do 125 | url = config.retriever_options.url 126 | app_id = config.retriever_options.app_id 127 | retrieve_historic_rates(date, url, app_id, config) 128 | end 129 | 130 | defp retrieve_historic_rates(_date, _url, nil, _config) do 131 | {:error, app_id_not_configured()} 132 | end 133 | 134 | @historic_rates "/historical/" 135 | defp retrieve_historic_rates(%Date{calendar: Calendar.ISO} = date, url, app_id, config) do 136 | date_string = Date.to_string(date) 137 | 138 | Retriever.retrieve_rates( 139 | url <> @historic_rates <> "#{date_string}.json" <> "?app_id=" <> app_id, 140 | config 141 | ) 142 | end 143 | 144 | defp retrieve_historic_rates(%{year: year, month: month, day: day}, url, app_id, config) do 145 | case Date.new(year, month, day) do 146 | {:ok, date} -> retrieve_historic_rates(date, url, app_id, config) 147 | error -> error 148 | end 149 | end 150 | 151 | defp app_id_not_configured do 152 | "Open Exchange Rates app_id is not configured. Rates are not retrieved." 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/money/financial.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.Financial do 2 | @moduledoc """ 3 | A set of financial functions, primarily related to discounted cash flows. 4 | 5 | Some of the algorithms are from [finance formulas](http://www.financeformulas.net) 6 | """ 7 | alias Cldr.Math 8 | 9 | @doc """ 10 | Calculates the future value for a present value, an interest rate 11 | and a number of periods. 12 | 13 | * `present_value` is a %Money{} representation of the present value 14 | 15 | * `interest_rate` is a float representation of an interest rate. For 16 | example, 12% would be represented as `0.12` 17 | 18 | * `periods` in an integer number of periods 19 | 20 | ## Examples 21 | 22 | iex> Money.Financial.future_value Money.new(:USD, 10000), 0.08, 1 23 | Money.new(:USD, "10800.00") 24 | 25 | iex> Money.Financial.future_value Money.new(:USD, 10000), 0.04, 2 26 | Money.new(:USD, "10816.0000") 27 | 28 | iex> Money.Financial.future_value Money.new(:USD, 10000), 0.02, 4 29 | Money.new(:USD, "10824.32160000") 30 | 31 | """ 32 | @spec future_value(Money.t(), number, number) :: Money.t() 33 | @one Decimal.new(1) 34 | 35 | def future_value(%Money{amount: amount} = money, interest_rate, periods) 36 | when is_number(interest_rate) and is_number(periods) do 37 | fv = 38 | interest_rate 39 | |> Decimal.from_float() 40 | |> Decimal.add(@one) 41 | |> Math.power(periods) 42 | |> Decimal.mult(amount) 43 | 44 | %{money | amount: fv} 45 | end 46 | 47 | @doc """ 48 | Calculates the future value for a list of cash flows and an interest rate. 49 | 50 | * `flows` is a list of tuples representing a cash flow. Each flow is 51 | represented as a tuple of the form `{period, %Money{}}` 52 | 53 | * `interest_rate` is a float representation of an interest rate. For 54 | example, 12% would be represented as `0.12` 55 | 56 | ## Example 57 | 58 | iex> Money.Financial.future_value([{4, Money.new(:USD, 10000)}, {5, Money.new(:USD, 10000)}, {6, Money.new(:USD, 10000)}], 0.13) 59 | Money.new(:USD, "34068.99999999999999999999999") 60 | 61 | iex> Money.Financial.future_value [{0, Money.new(:USD, 5000)},{1, Money.new(:USD, 2000)}], 0.12 62 | Money.new(:USD, "7600.000000000000000000000000") 63 | 64 | """ 65 | @spec future_value(list({number, Money.t()}), number) :: Money.t() 66 | 67 | def future_value(flows, interest_rate) 68 | 69 | def future_value([{period, %Money{}} | _other_flows] = flows, interest_rate) 70 | when is_integer(period) and is_number(interest_rate) do 71 | {max_period, _} = Enum.max(flows) 72 | 73 | present_value(flows, interest_rate) 74 | |> future_value(interest_rate, max_period) 75 | end 76 | 77 | @doc """ 78 | Calculates the present value for future value, an interest rate 79 | and a number of periods 80 | 81 | * `future_value` is a %Money{} representation of the future value 82 | 83 | * `interest_rate` is a float representation of an interest rate. For 84 | example, 12% would be represented as `0.12` 85 | 86 | * `periods` in an integer number of periods 87 | 88 | ## Examples 89 | 90 | iex> Money.Financial.present_value Money.new(:USD, 100), 0.08, 2 91 | Money.new(:USD, "85.73388203017832647462277092") 92 | 93 | iex> Money.Financial.present_value Money.new(:USD, 1000), 0.10, 20 94 | Money.new(:USD, "148.6436280241436864020760472") 95 | 96 | """ 97 | @spec present_value(Money.t(), number, number) :: Money.t() 98 | 99 | def present_value(%Money{amount: amount} = money, interest_rate, periods) 100 | when is_number(interest_rate) and is_number(periods) and periods >= 0 do 101 | pv_1 = 102 | interest_rate 103 | |> Decimal.from_float() 104 | |> Decimal.add(@one) 105 | |> Math.power(periods) 106 | 107 | %{money | amount: Decimal.div(amount, pv_1)} 108 | end 109 | 110 | @doc """ 111 | Calculates the present value for a list of cash flows and an interest rate. 112 | 113 | * `flows` is a list of tuples representing a cash flow. Each flow is 114 | represented as a tuple of the form `{period, %Money{}}` 115 | 116 | * `interest_rate` is a float representation of an interest rate. For 117 | example, 12% would be represented as `0.12` 118 | 119 | ## Example 120 | 121 | iex> Money.Financial.present_value([{4, Money.new(:USD, 10000)}, {5, Money.new(:USD, 10000)}, {6, Money.new(:USD, 10000)}], 0.13) 122 | Money.new(:USD, "16363.97191111964880256655144") 123 | 124 | iex> Money.Financial.present_value [{0, Money.new(:USD, -1000)},{1, Money.new(:USD, -4000)}], 0.1 125 | Money.new(:USD, "-4636.363636363636363636363636") 126 | 127 | """ 128 | @spec present_value(list({integer, Money.t()}), number) :: Money.t() 129 | def present_value(flows, interest_rate) 130 | 131 | def present_value([{period, %Money{}} | _other_flows] = flows, interest_rate) 132 | when is_integer(period) and is_number(interest_rate) do 133 | validate_same_currency!(flows) 134 | do_present_value(flows, interest_rate) 135 | end 136 | 137 | defp do_present_value({period, %Money{} = flow}, interest_rate) 138 | when is_integer(period) and is_number(interest_rate) do 139 | present_value(flow, interest_rate, period) 140 | end 141 | 142 | defp do_present_value([{period, %Money{}} = flow | []], interest_rate) 143 | when is_integer(period) and is_number(interest_rate) do 144 | do_present_value(flow, interest_rate) 145 | end 146 | 147 | defp do_present_value([{period, %Money{}} = flow | other_flows], interest_rate) 148 | when is_integer(period) and is_number(interest_rate) do 149 | do_present_value(flow, interest_rate) 150 | |> Money.add!(do_present_value(other_flows, interest_rate)) 151 | end 152 | 153 | @doc """ 154 | Calculates the net present value of an initial investment, a list of 155 | cash flows and an interest rate. 156 | 157 | * `flows` is a list of tuples representing a cash flow. Each flow is 158 | represented as a tuple of the form `{period, %Money{}}` 159 | 160 | * `interest_rate` is a float representation of an interest rate. For 161 | example, 12% would be represented as `0.12` 162 | 163 | * `investment` is a %Money{} struct representing the initial investment 164 | 165 | ## Example 166 | 167 | iex> flows = [{0, Money.new(:USD, 5000)},{1, Money.new(:USD, 2000)},{2, Money.new(:USD, 500)},{3, Money.new(:USD,10_000)}] 168 | iex> Money.Financial.net_present_value flows, 0.08, Money.new(:USD, 100) 169 | Money.new(:USD, "15118.84367220444038002337042") 170 | iex> Money.Financial.net_present_value flows, 0.08 171 | Money.new(:USD, "15218.84367220444038002337042") 172 | 173 | """ 174 | @spec net_present_value(list({integer, Money.t()}), number) :: Money.t() 175 | 176 | def net_present_value([{period, %Money{currency: currency}} | _] = flows, interest_rate) 177 | when is_integer(period) and is_number(interest_rate) do 178 | net_present_value(flows, interest_rate, Money.zero(currency)) 179 | end 180 | 181 | @spec net_present_value(list({integer, Money.t()}), number, Money.t()) :: Money.t() 182 | def net_present_value([{period, %Money{}} | _] = flows, interest_rate, %Money{} = investment) 183 | when is_integer(period) and is_number(interest_rate) do 184 | validate_same_currency!(investment, flows) 185 | 186 | present_value(flows, interest_rate) 187 | |> Money.sub!(investment) 188 | end 189 | 190 | @doc """ 191 | Calculates the net present value of an initial investment, a recurring 192 | payment, an interest rate and a number of periods 193 | 194 | * `investment` is a %Money{} struct representing the initial investment 195 | 196 | * `future_value` is a %Money{} representation of the future value 197 | 198 | * `interest_rate` is a float representation of an interest rate. For 199 | example, 12% would be represented as `0.12` 200 | 201 | * `periods` in an integer number of a period 202 | 203 | ## Example 204 | 205 | iex> Money.Financial.net_present_value Money.new(:USD, 10000), 0.13, 2 206 | Money.new(:USD, "7831.466833737959119743127888") 207 | 208 | iex> Money.Financial.net_present_value Money.new(:USD, 10000), 0.13, 2, Money.new(:USD, 100) 209 | Money.new(:USD, "7731.466833737959119743127888") 210 | 211 | """ 212 | @spec net_present_value(Money.t(), float, number) :: Money.t() 213 | def net_present_value(%Money{currency: currency} = future_value, interest_rate, periods) do 214 | net_present_value(future_value, interest_rate, periods, Money.new(currency, 0)) 215 | end 216 | 217 | @spec net_present_value(Money.t(), float, number, Money.t()) :: Money.t() 218 | def net_present_value(%Money{} = future_value, interest_rate, periods, %Money{} = investment) do 219 | present_value(future_value, interest_rate, periods) 220 | |> Money.sub!(investment) 221 | end 222 | 223 | @doc """ 224 | Calculates the interal rate of return for a given list of cash flows. 225 | 226 | * `flows` is a list of tuples representing a cash flow. Each flow is 227 | represented as a tuple of the form `{period, %Money{}}` 228 | 229 | """ 230 | @spec internal_rate_of_return(list({integer, Money.t()})) :: float() 231 | def internal_rate_of_return([{_period, %Money{}} | _other_flows] = flows) do 232 | # estimate_m = sum_of_inflows(flows) 233 | # |> Kernel./(abs(Math.to_float(amount))) 234 | # |> :math.pow(2 / (number_of_flows(flows) + 1)) 235 | # |> Kernel.-(1) 236 | 237 | # estimate_n = :math.pow(1 + estimate_m, ) 238 | 239 | estimate_n = 0.2 240 | estimate_m = 0.1 241 | 242 | do_internal_rate_of_return(flows, estimate_m, estimate_n) 243 | end 244 | 245 | @irr_precision 0.000001 246 | defp do_internal_rate_of_return(flows, estimate_m, estimate_n) do 247 | npv_n = net_present_value(flows, estimate_n).amount |> Math.to_float() 248 | npv_m = net_present_value(flows, estimate_m).amount |> Math.to_float() 249 | 250 | if abs(npv_n - npv_m) > @irr_precision do 251 | estimate_o = estimate_n - (estimate_n - estimate_m) / (npv_n - npv_m) * npv_n 252 | do_internal_rate_of_return(flows, estimate_n, estimate_o) 253 | else 254 | estimate_n 255 | end 256 | end 257 | 258 | @doc """ 259 | Calculates the effective interest rate for a given present value, 260 | a future value and a number of periods. 261 | 262 | * `present_value` is a %Money{} representation of the present value 263 | 264 | * `future_value` is a %Money{} representation of the future value 265 | 266 | * `periods` is an integer number of a period 267 | 268 | ## Examples 269 | 270 | iex> Money.Financial.interest_rate Money.new(:USD, 10000), Money.new(:USD, 10816), 2 271 | Decimal.new("0.04") 272 | 273 | iex> Money.Financial.interest_rate Money.new(:USD, 10000), Money.new(:USD, "10824.3216"), 4 274 | Decimal.new("0.02") 275 | 276 | """ 277 | @spec interest_rate(Money.t(), Money.t(), number) :: Decimal.t() 278 | def interest_rate( 279 | %Money{currency: pv_currency, amount: pv_amount} = _present_value, 280 | %Money{currency: fv_currency, amount: fv_amount} = _future_value, 281 | periods 282 | ) 283 | when pv_currency == fv_currency and is_integer(periods) and periods > 0 do 284 | fv_amount 285 | |> Decimal.div(pv_amount) 286 | |> Math.root(periods) 287 | |> Decimal.sub(@one) 288 | end 289 | 290 | @doc """ 291 | Calculates the number of periods between a present value and 292 | a future value with a given interest rate. 293 | 294 | * `present_value` is a %Money{} representation of the present value 295 | 296 | * `future_value` is a %Money{} representation of the future value 297 | 298 | * `interest_rate` is a float representation of an interest rate. For 299 | example, 12% would be represented as `0.12` 300 | 301 | ## Example 302 | 303 | iex> Money.Financial.periods Money.new(:USD, 1500), Money.new(:USD, 2000), 0.005 304 | Decimal.new("57.68013595323872502502238648") 305 | 306 | """ 307 | @spec periods(Money.t(), Money.t(), float) :: Decimal.t() 308 | def periods( 309 | %Money{currency: pv_currency, amount: pv_amount} = _present_value, 310 | %Money{currency: fv_currency, amount: fv_amount} = _future_value, 311 | interest_rate 312 | ) 313 | when pv_currency == fv_currency and is_float(interest_rate) and interest_rate > 0 do 314 | Decimal.div( 315 | Math.log(Decimal.div(fv_amount, pv_amount)), 316 | Math.log(Decimal.add(@one, Decimal.from_float(interest_rate))) 317 | ) 318 | end 319 | 320 | @doc """ 321 | Calculates the payment for a given loan or annuity given a 322 | present value, an interest rate and a number of periods. 323 | 324 | * `present_value` is a %Money{} representation of the present value 325 | 326 | * `interest_rate` is a float representation of an interest rate. For 327 | example, 12% would be represented as `0.12` 328 | 329 | * `periods` is an integer number of periods 330 | 331 | ## Example 332 | 333 | iex> Money.Financial.payment Money.new(:USD, 100), 0.12, 20 334 | Money.new(:USD, "13.38787800396606622792492299") 335 | 336 | """ 337 | @spec payment(Money.t(), float, number) :: Money.t() 338 | def payment( 339 | %Money{amount: pv_amount} = present_value, 340 | interest_rate, 341 | periods 342 | ) 343 | when is_float(interest_rate) and interest_rate > 0 and is_number(periods) and periods > 0 do 344 | interest_rate = Decimal.from_float(interest_rate) 345 | p1 = Decimal.mult(pv_amount, interest_rate) 346 | p2 = Decimal.sub(@one, Decimal.add(@one, interest_rate) |> Math.power(-periods)) 347 | %{present_value | amount: Decimal.div(p1, p2)} 348 | end 349 | 350 | defp validate_same_currency!(%Money{} = flow, flows) do 351 | validate_same_currency!([{0, flow} | flows]) 352 | end 353 | 354 | defp validate_same_currency!(flows) do 355 | number_of_currencies = 356 | flows 357 | |> Enum.map(fn {_period, %Money{currency: currency}} -> currency end) 358 | |> Enum.uniq() 359 | |> Enum.count() 360 | 361 | if number_of_currencies > 1 do 362 | raise ArgumentError, 363 | message: 364 | "More than one currency found in cash flows; " <> 365 | "implicit currency conversion is not supported. Cash flows: " <> inspect(flows) 366 | end 367 | end 368 | end 369 | -------------------------------------------------------------------------------- /lib/money/parser/combinators.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.Combinators do 2 | @moduledoc false 3 | 4 | import NimbleParsec 5 | 6 | # Whitespace as defined by Unicode set :Zs plus tab 7 | # non-breaking-space is considered a separator, not whitespace 8 | @whitespace [?\s, ?\t, 0x1680, 0x2000, 0x202F, 0x205F, 0x3000] 9 | def whitespace do 10 | repeat(empty(), utf8_char(@whitespace)) 11 | |> label("whitespace") 12 | end 13 | 14 | @separators [?., ?,, ?،, ?٫, ?、, ?︐, ?︑, ?﹐, ?﹑, ?,, ?、, ? , 0x00A0] 15 | def separators do 16 | utf8_char(@separators) 17 | |> label("separators") 18 | end 19 | 20 | @decimal_places [?., ?․, ?。, ?︒, ?﹒, ?., ?。] 21 | def decimal_place do 22 | utf8_char(@decimal_places) 23 | |> label("decimal place character") 24 | end 25 | 26 | @left_parens [?(] 27 | def left_paren do 28 | utf8_char(@left_parens) 29 | |> label("left parenthesis") 30 | end 31 | 32 | @right_parens [?)] 33 | def right_paren do 34 | utf8_char(@right_parens) 35 | |> label("right parenthesis") 36 | end 37 | 38 | @digits [?0..?9] 39 | def digits do 40 | repeat(empty(), ascii_char([?0..?9])) 41 | |> label("digits") 42 | end 43 | 44 | @minus [?-] 45 | def minus do 46 | ascii_char(@minus) 47 | end 48 | 49 | def positive_number do 50 | digits() 51 | |> repeat(separators() |> concat(digits())) 52 | |> optional(decimal_place() |> concat(digits())) 53 | |> label("positive number") 54 | end 55 | 56 | def negative_number do 57 | choice(empty(), [ 58 | ignore(minus()) 59 | |> concat(positive_number()), 60 | positive_number() 61 | |> ignore(minus()) 62 | ]) 63 | |> reduce({List, :to_string, []}) 64 | |> map(:add_minus_sign) 65 | |> label("negative number") 66 | end 67 | 68 | def number do 69 | choice(empty(), [negative_number(), positive_number()]) 70 | |> reduce({List, :to_string, []}) 71 | |> unwrap_and_tag(:amount) 72 | |> label("number") 73 | end 74 | 75 | @rtl [0x200F] 76 | def rtl do 77 | ignore(utf8_char(@rtl)) 78 | end 79 | 80 | @invalid_chars @digits ++ @left_parens ++ @minus ++ @rtl 81 | @currency Enum.map(@invalid_chars, fn s -> {:not, s} end) 82 | 83 | def currency do 84 | utf8_char(@currency) 85 | |> times(min: 1) 86 | |> reduce({List, :to_string, []}) 87 | |> unwrap_and_tag(:currency) 88 | |> label("currency code, symbol or name") 89 | end 90 | 91 | def money_with_currency do 92 | choice(empty(), [ 93 | optional(rtl()) 94 | |> concat(number()) 95 | |> ignore(optional(whitespace())) 96 | |> optional(currency()) 97 | |> optional(rtl()) 98 | |> eos(), 99 | optional(rtl()) 100 | |> optional(currency()) 101 | |> ignore(optional(whitespace())) 102 | |> concat(number()) 103 | |> optional(rtl()) 104 | |> eos() 105 | ]) 106 | |> label("money with currency") 107 | end 108 | 109 | def accounting_format do 110 | choice(empty(), [ 111 | ignore(left_paren()) 112 | |> ignore(optional(whitespace())) 113 | |> concat(number()) 114 | |> ignore(optional(whitespace())) 115 | |> optional(currency()) 116 | |> ignore(optional(whitespace())) 117 | |> ignore(right_paren()), 118 | ignore(left_paren()) 119 | |> ignore(optional(whitespace())) 120 | |> optional(currency()) 121 | |> ignore(optional(whitespace())) 122 | |> concat(number()) 123 | |> ignore(optional(whitespace())) 124 | |> ignore(right_paren()) 125 | ]) 126 | |> map(:change_sign) 127 | |> label("money with currency in accounting format") 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/money/parser/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.Parser do 2 | @moduledoc false 3 | 4 | # parsec:Money.Parser 5 | import NimbleParsec 6 | import Money.Combinators 7 | 8 | defparsec(:money_parser, choice([money_with_currency(), accounting_format()])) 9 | # parsec:Money.Parser 10 | 11 | def change_sign({:amount, amount}) do 12 | revised_number = 13 | case amount do 14 | <<"-", number::binary>> -> number 15 | number -> "-" <> number 16 | end 17 | 18 | {:amount, revised_number} 19 | end 20 | 21 | def change_sign(other), do: other 22 | 23 | def add_minus_sign(arg) do 24 | "-" <> arg 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/money/protocol/gringotts.ex: -------------------------------------------------------------------------------- 1 | if Cldr.Config.ensure_compiled?(Gringotts.Money) && 2 | !Money.exclude_protocol_implementation(Gringotts.Money) do 3 | defimpl Gringotts.Money, for: Money do 4 | def currency(%Money{currency: currency}) do 5 | Atom.to_string(currency) 6 | end 7 | 8 | def value(%Money{amount: amount}) do 9 | amount 10 | end 11 | 12 | def to_integer(%Money{} = money) do 13 | {_currency, integer, exponent, _remainder} = Money.to_integer_exp(money) 14 | {currency(money), integer, exponent} 15 | end 16 | 17 | def to_string(%Money{} = money) do 18 | rounded_string = 19 | money 20 | |> Money.round() 21 | |> Map.get(:amount) 22 | |> Module.concat(Money.default_backend!(), Number).to_string! 23 | 24 | {currency(money), rounded_string} 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/money/protocol/inspect.ex: -------------------------------------------------------------------------------- 1 | defimpl Inspect, for: Money do 2 | import Money, only: [is_digital_token: 1] 3 | 4 | def inspect(%Money{currency: token_id} = money, opts) when is_digital_token(token_id) do 5 | {:ok, short_name} = DigitalToken.short_name(token_id) 6 | 7 | money 8 | |> Map.put(:currency, short_name) 9 | |> do_inspect(opts) 10 | end 11 | 12 | def inspect(money, opts) do 13 | do_inspect(money, opts) 14 | end 15 | 16 | def do_inspect(%Money{format_options: []} = money, _opts) do 17 | "Money.new(#{inspect(money.currency)}, #{inspect(Decimal.to_string(money.amount))})" 18 | end 19 | 20 | def do_inspect(money, _opts) do 21 | format_options = 22 | money.format_options 23 | |> inspect() 24 | |> String.trim_leading("[") 25 | |> String.trim_trailing("]") 26 | 27 | "Money.new(#{inspect(money.currency)}, #{inspect(Decimal.to_string(money.amount))}, #{format_options})" 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/money/protocol/jason.ex: -------------------------------------------------------------------------------- 1 | if Cldr.Config.ensure_compiled?(Jason) && 2 | !Money.exclude_protocol_implementation(Jason.Encoder) do 3 | defimpl Jason.Encoder, for: Money do 4 | def encode(struct, opts) do 5 | struct 6 | |> Map.take([:currency, :amount]) 7 | |> Jason.Encode.map(opts) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/money/protocol/json.ex: -------------------------------------------------------------------------------- 1 | if Cldr.Config.ensure_compiled?(JSON) && 2 | !Money.exclude_protocol_implementation(JSON.Encoder) do 3 | defimpl JSON.Encoder, for: Money do 4 | def encode(struct, encoder) do 5 | struct 6 | |> Map.take([:currency, :amount]) 7 | |> encoder.(encoder) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/money/protocol/phoenix_html_safe.ex: -------------------------------------------------------------------------------- 1 | if Cldr.Config.ensure_compiled?(Phoenix.HTML.Safe) && 2 | !Money.exclude_protocol_implementation(Phoenix.HTML.Safe) do 3 | defimpl Phoenix.HTML.Safe, for: Money do 4 | def to_iodata(money) do 5 | Phoenix.HTML.Safe.to_iodata(Money.to_string!(money)) 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/money/protocol/string_chars.ex: -------------------------------------------------------------------------------- 1 | if !Money.exclude_protocol_implementation(String.Chars) do 2 | defimpl String.Chars, for: Money do 3 | def to_string(v) do 4 | Money.to_string!(v) 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/money/sigil.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.Sigil do 2 | @doc ~S""" 3 | Implements the sigil `~M` for Money 4 | 5 | The lower case `~m` variant does not exist as interpolation and excape 6 | characters are not useful for Money sigils. 7 | 8 | ## Example 9 | 10 | iex> import Money.Sigil 11 | iex> ~M[1000]usd 12 | Money.new(:USD, "1000") 13 | iex> ~M[1000.34]usd 14 | Money.new(:USD, "1000.34") 15 | 16 | """ 17 | @spec sigil_M(binary, list(char)) :: Money.t() | {:error, {module(), String.t()}} 18 | def sigil_M(amount, [_, _, _] = currency) do 19 | Money.new(to_decimal(amount), atomize(currency)) 20 | end 21 | 22 | defp to_decimal(string) do 23 | string 24 | |> String.replace("_", "") 25 | |> Decimal.new() 26 | end 27 | 28 | defp atomize(currency) do 29 | currency 30 | |> List.to_string() 31 | |> validate_currency! 32 | end 33 | 34 | def validate_currency!(currency) do 35 | case Money.validate_currency(currency) do 36 | {:ok, currency} -> currency 37 | {:error, {_exception, reason}} -> raise Money.UnknownCurrencyError, reason 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/money/subscription.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.Subscription do 2 | @moduledoc """ 3 | Provides functions to create, upgrade and downgrade subscriptions 4 | from one plan to another. 5 | 6 | Since moving from one plan to another may require 7 | prorating the payment stream at the point of transition, 8 | this module is introduced to provide a single point of 9 | calculation of the proration in order to give clear focus 10 | to the issues of calculating the carry-over amount or 11 | the carry-over period at the point of plan change. 12 | 13 | ### Defining a subscription 14 | 15 | A subscription records this current state and history of 16 | all plans assigned over time to a subscriber. The definition 17 | is deliberately minimal to simplify integration into applications 18 | that have a specific implementation of a subscription. 19 | 20 | A new subscription is created with `Money.Subscription.new/3` 21 | which has the following attributes: 22 | 23 | * `plan` which defines the initial plan for the subscription. 24 | This option is required. 25 | 26 | * `effective_date` which determines the effective date of 27 | the inital plan. This option is required. 28 | 29 | * `options` which include `:created_at` and `:id` with which 30 | a subscription may be annotated 31 | 32 | ### Changing a subscription plan 33 | 34 | Changing a subscription plan requires the following 35 | information be provided: 36 | 37 | * A Subscription or the definition of the current plan 38 | 39 | * The definition of the new plan 40 | 41 | * The strategy for changing the plan which is either: 42 | 43 | * to have the effective date of the new plan be after 44 | the current interval of the current plan 45 | 46 | * To change the plan immediately in which case there will 47 | be a credit on the current plan which needs to be applied 48 | to the new plan. 49 | 50 | See `Money.Subscription.change_plan/3` 51 | 52 | ### When the new plan is effective at the end of the current billing period 53 | 54 | The first strategy simply finishes the current billing period before 55 | the new plan is introduced and therefore no proration is required. 56 | This is the default strategy. 57 | 58 | ### When the new plan is effective immediately 59 | 60 | If the new plan is to be effective immediately then any credit 61 | balance remaining on the old plan needs to be applied to the 62 | new plan. There are two options of applying the credit: 63 | 64 | 1. Reduce the billing amount of the first period of the new plan 65 | be the amount of the credit left on the old plan. This means 66 | that the billing amount for the first period of the new plan 67 | will be different (less) than the billing amount for subsequent 68 | periods on the new plan. 69 | 70 | 2. Extend the first period of the new plan by the interval amount 71 | that can be funded by the credit amount left on the old plan. In 72 | the situation where the credit amount does not fully fund an integral 73 | interval the additional interval can be truncated or rounded up to the next 74 | integral period. 75 | 76 | ### Plan definition 77 | 78 | This module, and `Money` in general, does not provide a full 79 | billing or subscription solution - its focus is to support a reliable 80 | means of calcuating the accounting outcome of a plan change only. 81 | Therefore the plan definition required by `Money.Subscription` can be 82 | any `Map.t` that includes the following fields: 83 | 84 | * `interval` which defines the time interval for a plan. The value 85 | can be one of `day`, `week`, `month` or `year`. 86 | 87 | * `interval_count` which defines the number of `interval`s for the 88 | current plan interval. This must be a positive integer. 89 | 90 | * `price` which is a `Money.t` representing the price of the plan 91 | to be paid each interval count. 92 | 93 | ### Billing in advance 94 | 95 | This module calculates all subscription changes on the basis 96 | that billing is done in advance. This primarily affects the 97 | calculation of plan credit when a plan changes. The assumption 98 | is that the period from the start of the current interval to 99 | the point of change has been consumed and therefore the credit 100 | is based upon that period of the plan that has not yet been 101 | consumed. 102 | 103 | If the calculation was based upon "payment in arrears" then 104 | the credit would actually be a debit since the part of the 105 | current period consumed has not yet been paid. 106 | 107 | """ 108 | 109 | alias Money.Subscription 110 | alias Money.Subscription.{Change, Plan} 111 | 112 | @typedoc "An id that uniquely identifies a subscription" 113 | @type id :: term() 114 | 115 | @typedoc "A Money.Subscription type" 116 | @type t :: %__MODULE__{id: id(), plans: list({Change.t(), Plan.t()}), created_at: DateTime.t()} 117 | 118 | @doc """ 119 | A `struct` defining a subscription 120 | 121 | * `:id` any term that uniquely identifies this subscription 122 | 123 | * `:plans` is a list of `{change, plan}` tuples that record the history 124 | of plans assigned to this subscription 125 | 126 | * `:created_at` records the `DateTime.t` when the subscription was created 127 | 128 | """ 129 | defstruct id: nil, 130 | plans: [], 131 | created_at: nil 132 | 133 | @doc """ 134 | Creates a new subscription. 135 | 136 | ## Arguments 137 | 138 | * `plan` is any `Money.Subscription.Plan.t` the defines the initial plan 139 | 140 | * `effective_date` is a `Date.t` that represents the effective 141 | date of the initial plan. This defines the start of the first interval 142 | 143 | * `options` is a keyword list of options 144 | 145 | ## Options 146 | 147 | * `:id` is any term that an application can use to uniquely identify 148 | this subscription. It is not used in any function in this module. 149 | 150 | * `:created_at` is a `DateTime.t` that records the timestamp when 151 | the subscription was created. The default is `DateTime.utc_now/0` 152 | 153 | ## Returns 154 | 155 | * `{:ok, Money.Subscription.t}` or 156 | 157 | * `{:error, {exception, message}}` 158 | 159 | """ 160 | 161 | # @doc since: "2.3.0" 162 | 163 | @spec new(plan :: Plan.t(), effective_date :: Date.t(), Keyword.t()) :: 164 | {:ok, Subscription.t()} | {:error, {module(), String.t()}} 165 | 166 | def new(plan, effective_date, options \\ []) 167 | 168 | def new( 169 | %{price: _price, interval: _interval} = plan, 170 | %{year: _year, month: _month, day: _day, calendar: _calendar} = effective_date, 171 | options 172 | ) do 173 | options = 174 | default_subscription_options() 175 | |> Keyword.merge(options) 176 | 177 | next_interval_starts = next_interval_starts(plan, effective_date, options) 178 | first_billing_amount = plan.price 179 | 180 | changes = %Change{ 181 | first_interval_starts: effective_date, 182 | next_interval_starts: next_interval_starts, 183 | first_billing_amount: first_billing_amount, 184 | credit_amount_applied: Money.zero(first_billing_amount), 185 | credit_amount: Money.zero(first_billing_amount), 186 | carry_forward: Money.zero(first_billing_amount) 187 | } 188 | 189 | subscription = 190 | struct(__MODULE__, options) 191 | |> Map.put(:plans, [{changes, plan}]) 192 | 193 | {:ok, subscription} 194 | end 195 | 196 | def new(%{price: _price, interval: _interval}, effective_date, _options) do 197 | {:error, {Subscription.DateError, "The effective date #{inspect(effective_date)} is invalid."}} 198 | end 199 | 200 | def new(plan, %{year: _, month: _, day: _, calendar: _}, _options) do 201 | {:error, {Subscription.PlanError, "The plan #{inspect(plan)} is invalid."}} 202 | end 203 | 204 | @doc """ 205 | Creates a new subscription or raises an exception. 206 | 207 | ## Arguments 208 | 209 | * `plan` is any `Money.Subscription.Plan.t` the defines the initial plan 210 | 211 | * `effective_date` is a `Date.t` that represents the effective 212 | date of the initial plan. This defines the start of the first interval 213 | 214 | * `:options` is a keyword list of options 215 | 216 | ## Options 217 | 218 | * `:id` is any term that an application can use to uniquely identify 219 | this subscription. It is not used in any function in this module. 220 | 221 | * `:created_at` is a `DateTime.t` that records the timestamp when 222 | the subscription was created. The default is `DateTime.utc_now/0` 223 | 224 | ## Returns 225 | 226 | * A `Money.Subscription.t` or 227 | 228 | * raises an exception 229 | 230 | """ 231 | @spec new!(plan :: Plan.t(), effective_date :: Date.t(), Keyword.t()) :: 232 | Subscription.t() | no_return() 233 | 234 | def new!(plan, effective_date, options \\ []) do 235 | case new(plan, effective_date, options) do 236 | {:ok, subscription} -> subscription 237 | {:error, {exception, message}} -> raise exception, message 238 | end 239 | end 240 | 241 | defp default_subscription_options do 242 | [ 243 | created_at: DateTime.utc_now() 244 | ] 245 | end 246 | 247 | @doc """ 248 | Retrieve the plan that is currently in affect. 249 | 250 | The plan in affect is not necessarily the first 251 | plan in the list. We may have upgraded plans to 252 | be in affect at some later time. 253 | 254 | ## Arguments 255 | 256 | * `subscription` is a `Money.Subscription.t` or any 257 | map that provides the field `:plans` 258 | 259 | ## Returns 260 | 261 | * The `Money.Subscription.Plan.t` that is the plan currently in affect or 262 | `nil` 263 | 264 | """ 265 | # @doc since: "2.3.0" 266 | @spec current_plan(Subscription.t() | map, Keyword.t()) :: 267 | Plan.t() | {Change.t(), Plan.t()} | nil 268 | 269 | def current_plan(subscription, options \\ []) 270 | 271 | def current_plan(%{plans: []}, _options) do 272 | nil 273 | end 274 | 275 | def current_plan(%{plans: [h | t]}, options) do 276 | if current_plan?(h, options) do 277 | h 278 | else 279 | current_plan(%{plans: t}, options) 280 | end 281 | end 282 | 283 | # Because we walk the list from most recent to oldest, the first 284 | # plan that has a start date less than or equal to the current 285 | # date is the one we want 286 | @spec current_plan?({Change.t(), Plan.t()}, Keyword.t()) :: boolean 287 | 288 | defp current_plan?({%Change{first_interval_starts: start_date}, _}, options) do 289 | today = Keyword.get(options, :today, Date.utc_today()) 290 | Date.compare(start_date, today) in [:lt, :eq] 291 | end 292 | 293 | @doc """ 294 | Returns a `boolean` indicating if there is a pending plan. 295 | 296 | A pending plan is one where the subscription has changed 297 | plans but the plan is not yet in effect. There can only 298 | be one pending plan. 299 | 300 | ## Arguments 301 | 302 | * `:subscription` is any `Money.Subscription.t` 303 | 304 | * `:options` is a keyword list of options 305 | 306 | ## Options 307 | 308 | * `:today` is a `Date.t` that represents the effective 309 | date used to determine is there is a pending plan. 310 | The default is `Date.utc_today/1`. 311 | 312 | ## Returns 313 | 314 | * Either `true` or `false` 315 | 316 | """ 317 | 318 | # @doc since: "2.3.0" 319 | @spec plan_pending?(Subscription.t(), Keyword.t()) :: boolean() 320 | def plan_pending?(%{plans: [{changes, _plan} | _t]}, options \\ []) do 321 | today = options[:today] || Date.utc_today() 322 | Date.compare(changes.first_interval_starts, today) == :gt 323 | end 324 | 325 | @doc """ 326 | Cancel a subscription's pending plan. 327 | 328 | A pending plan arise when a a `Subscription.change_plan/3` has 329 | been executed but the effective date is in the future. Only 330 | one plan may be pending at any one time so that if 331 | `Subscription.change_plan/3` is attemtped a second time an 332 | error tuple will be returned. 333 | 334 | `Subscription.cancel_pending_plan/2` 335 | can be used to roll back the pending plan change. 336 | 337 | ## Arguments 338 | 339 | * `:subscription` is any `Money.Subscription.t` 340 | 341 | * `:options` is a `Keyword.t` 342 | 343 | ## Options 344 | 345 | * `:today` is a `Date.t` that represents today. 346 | The default is `Date.utc_today` 347 | 348 | ## Returns 349 | 350 | * An updated `Money.Subscription.t` which may or may not 351 | have had a pending plan. If it did have a pending plan 352 | that plan is deleted. If there was no pending plan then 353 | the subscription is returned unchanged. 354 | 355 | """ 356 | # @doc since: "2.3.0" 357 | @spec cancel_pending_plan(Subscription.t(), Keyword.t()) :: Subscription.t() 358 | def cancel_pending_plan(%{plans: [_plan | other_plans]} = subscription, options \\ []) do 359 | if plan_pending?(subscription, options) do 360 | %{subscription | plans: other_plans} 361 | else 362 | subscription 363 | end 364 | end 365 | 366 | @doc """ 367 | Returns the start date of the current plan. 368 | 369 | ## Arguments 370 | 371 | * `subscription` is a `Money.Subscription.t` or any 372 | map that provides the field `:plans` 373 | 374 | ## Returns 375 | 376 | * The start `Date.t` of the current plan 377 | 378 | """ 379 | # @doc since: "2.3.0" 380 | @spec current_plan_start_date(Subscription.t()) :: Date.t() | nil 381 | @dialyzer {:nowarn_function, current_plan_start_date: 1} 382 | 383 | def current_plan_start_date(%{plans: _plans} = subscription) do 384 | case current_plan(subscription) do 385 | {changes, _plan} -> changes.first_interval_starts 386 | nil -> nil 387 | end 388 | end 389 | 390 | @doc """ 391 | Returns the first date of the current interval of a plan. 392 | 393 | ## Arguments 394 | 395 | * `:subscription_or_changeset` is any`Money.Subscription.t` or 396 | a `{Change.t, Plan.t}` tuple 397 | 398 | * `:options` is a keyword list of options 399 | 400 | ## Options 401 | 402 | * `:today` is a `Date.t` that represents today. 403 | The default is `Date.utc_today` 404 | 405 | ## Returns 406 | 407 | * The `Date.t` that is the first date of the current interval 408 | 409 | """ 410 | # @doc since: "2.3.0" 411 | @spec current_interval_start_date(Subscription.t() | {Change.t(), Plan.t()} | map(), Keyword.t()) :: 412 | Date.t() 413 | 414 | @dialyzer {:nowarn_function, current_interval_start_date: 2} 415 | 416 | def current_interval_start_date(subscription_or_changeset, options \\ []) 417 | 418 | def current_interval_start_date(%{plans: _plans} = subscription, options) do 419 | case current_plan(subscription, options) do 420 | {changes, plan} -> 421 | current_interval_start_date({changes, plan}, options) 422 | 423 | _ -> 424 | {:error, 425 | {Money.Subscription.NoCurrentPlan, "There is no current plan for the subscription"}} 426 | end 427 | end 428 | 429 | def current_interval_start_date({%Change{first_interval_starts: start_date}, plan}, options) do 430 | next_interval_starts = next_interval_starts(plan, start_date) 431 | options = Keyword.put_new(options, :today, Date.utc_today()) 432 | 433 | case compare_range(options[:today], start_date, next_interval_starts) do 434 | :between -> 435 | start_date 436 | 437 | :less -> 438 | current_interval_start_date( 439 | {%Change{first_interval_starts: next_interval_starts}, plan}, 440 | options 441 | ) 442 | 443 | :greater -> 444 | {:error, 445 | {Money.Subscription.NoCurrentPlan, "The plan is not current for #{inspect(start_date)}"}} 446 | end 447 | end 448 | 449 | defp compare_range(date, current, next) do 450 | cond do 451 | Date.compare(date, current) in [:gt, :eq] and Date.compare(date, next) == :lt -> 452 | :between 453 | 454 | Date.compare(current, date) == :lt -> 455 | :less 456 | 457 | Date.compare(next, date) == :gt -> 458 | :greater 459 | end 460 | end 461 | 462 | @doc """ 463 | Returns the latest plan for a subscription. 464 | 465 | The latest plan may not be in affect since 466 | its start date may be in the future. 467 | 468 | ## Arguments 469 | 470 | * `subscription` is a `Money.Subscription.t` or any 471 | map that provides the field `:plans` 472 | 473 | ## Returns 474 | 475 | * The `Money.Subscription.Plan.t` that is the most recent 476 | plan - whether or not it is the currently active plan. 477 | 478 | """ 479 | # @doc since: "2.3.0" 480 | @spec latest_plan(Subscription.t() | map()) :: {Change.t(), Plan.t()} 481 | def latest_plan(%{plans: [h | _t]}) do 482 | h 483 | end 484 | 485 | @doc """ 486 | Change plan from the current plan to a new plan. 487 | 488 | ## Arguments 489 | 490 | * `subscription_or_plan` is either a `Money.Subscription.t` or `Money.Subscription.Plan.t` 491 | or a map with the same fields 492 | 493 | * `new_plan` is a `Money.Subscription.Plan.t` or a map with at least the fields 494 | `interval`, `interval_count` and `price` 495 | 496 | * `current_interval_started` is a `Date.t` or other map with the fields `year`, `month`, 497 | `day` and `calendar` 498 | 499 | * `options` is a keyword list of options the define how the change is to be made 500 | 501 | ## Options 502 | 503 | * `:effective` defines when the new plan comes into effect. The values are `:immediately`, 504 | a `Date.t` or `:next_period`. The default is `:next_period`. Note that the date 505 | applied in the case of `:immediately` is the date returned by `Date.utc_today`. 506 | 507 | * `:prorate` which determines how to prorate the current plan into the new plan. The 508 | options are `:price` which will reduce the price of the first period of the new plan 509 | by the credit amount left on the old plan (this is the default). Or `:period` in which 510 | case the first period of the new plan is extended by the `interval` amount of the new 511 | plan that the credit on the old plan will fund. 512 | 513 | * `:round` determines whether when prorating the `:period` it is truncated or rounded up 514 | to the next nearest full `interval_count`. Valid values are `:down`, `:half_up`, 515 | `:half_even`, `:ceiling`, `:floor`, `:half_down`, `:up`. The default is `:up`. 516 | 517 | * `:first_interval_started` determines the anchor day for monthly billing. For 518 | example if a monthly plan starts on January 31st then the next period will start 519 | on February 28th (or 29th). The period following that should, however, be March 31st. 520 | If `subscription_or_plan` is a `Money.Subscription.t` then the `:first_interval_started` 521 | is automatically populated from the subscription. If `:first_interval_started` is 522 | `nil` then the date defined by `:effective` is used. 523 | 524 | ## Returns 525 | 526 | A `Money.Subscription.Change.t` with the following elements: 527 | 528 | * `:first_interval_starts` which is the start date of the first interval for the new 529 | plan 530 | 531 | * `:first_billing_amount` is the amount to be billed, net of any credit, at 532 | the `:first_interval_starts` 533 | 534 | * `:next_interval_starts` is the start date of the next interval after the ` 535 | first interval `including any `credit_days_applied` 536 | 537 | * `:credit_amount` is the amount of unconsumed credit of the current plan 538 | 539 | * `:credit_amount_applied` is the amount of credit applied to the new plan. If 540 | the `:prorate` option is `:price` (the default) then `:first_billing_amount` 541 | is the plan `:price` reduced by the `:credit_amount_applied`. If the `:prorate` 542 | option is `:period` then the `:first_billing_amount` is the plan `price` and 543 | the `:next_interval_date` is extended by the `:credit_days_applied` 544 | instead. 545 | 546 | * `:credit_days_applied` is the number of days credit applied to the first 547 | interval by adding days to the `:first_interval_starts` date. 548 | 549 | * `:credit_period_ends` is the date on which any applied credit is consumed or `nil` 550 | 551 | * `:carry_forward` is any amount of credit carried forward to a subsequent period. 552 | If non-zero, this amount is a negative `Money.t`. It is non-zero when the credit 553 | amount for the current plan is greater than the `:price` of the new plan. In 554 | this case the `:first_billing_amount` is zero. 555 | 556 | ## Returns 557 | 558 | * `{:ok, updated_subscription}` or 559 | 560 | * `{:error, {exception, message}}` 561 | 562 | ## Examples 563 | 564 | # Change at end of the current period so no proration 565 | iex> current = Money.Subscription.Plan.new!(Money.new(:USD, 10), :month, 1) 566 | iex> new = Money.Subscription.Plan.new!(Money.new(:USD, 10), :month, 3) 567 | iex> Money.Subscription.change_plan current, new, current_interval_started: ~D[2018-01-01] 568 | {:ok, %Money.Subscription.Change{ 569 | carry_forward: Money.zero(:USD), 570 | credit_amount: Money.zero(:USD), 571 | credit_amount_applied: Money.zero(:USD), 572 | credit_days_applied: 0, 573 | credit_period_ends: nil, 574 | next_interval_starts: ~D[2018-05-01], 575 | first_billing_amount: Money.new(:USD, 10), 576 | first_interval_starts: ~D[2018-02-01] 577 | }} 578 | 579 | # Change during the current plan generates a credit amount 580 | iex> current = Money.Subscription.Plan.new!(Money.new(:USD, 10), :month, 1) 581 | iex> new = Money.Subscription.Plan.new!(Money.new(:USD, 10), :month, 3) 582 | iex> Money.Subscription.change_plan current, new, current_interval_started: ~D[2018-01-01], effective: ~D[2018-01-15] 583 | {:ok, %Money.Subscription.Change{ 584 | carry_forward: Money.zero(:USD), 585 | credit_amount: Money.new(:USD, "5.49"), 586 | credit_amount_applied: Money.new(:USD, "5.49"), 587 | credit_days_applied: 0, 588 | credit_period_ends: nil, 589 | next_interval_starts: ~D[2018-04-15], 590 | first_billing_amount: Money.new(:USD, "4.51"), 591 | first_interval_starts: ~D[2018-01-15] 592 | }} 593 | 594 | # Change during the current plan generates a credit period 595 | iex> current = Money.Subscription.Plan.new!(Money.new(:USD, 10), :month, 1) 596 | iex> new = Money.Subscription.Plan.new!(Money.new(:USD, 10), :month, 3) 597 | iex> Money.Subscription.change_plan current, new, current_interval_started: ~D[2018-01-01], effective: ~D[2018-01-15], prorate: :period 598 | {:ok, %Money.Subscription.Change{ 599 | carry_forward: Money.zero(:USD), 600 | credit_amount: Money.new(:USD, "5.49"), 601 | credit_amount_applied: Money.zero(:USD), 602 | credit_days_applied: 50, 603 | credit_period_ends: ~D[2018-03-05], 604 | next_interval_starts: ~D[2018-06-04], 605 | first_billing_amount: Money.new(:USD, 10), 606 | first_interval_starts: ~D[2018-01-15] 607 | }} 608 | 609 | """ 610 | # @doc since: "2.3.0" 611 | @spec change_plan( 612 | subscription_or_plan :: Subscription.t() | Plan.t(), 613 | new_plan :: Plan.t(), 614 | options :: Keyword.t() 615 | ) :: {:ok, Change.t() | Subscription.t()} | {:error, {module(), String.t()}} 616 | 617 | @dialyzer {:nowarn_function, change_plan: 3} 618 | 619 | def change_plan(subscription_or_plan, new_plan, options \\ []) 620 | 621 | def change_plan( 622 | %{plans: [{changes, %{price: %Money{currency: currency}} = current_plan} | _] = plans} = 623 | subscription, 624 | %{price: %Money{currency: currency}} = new_plan, 625 | options 626 | ) do 627 | options = 628 | options 629 | |> Keyword.put(:first_interval_started, changes.first_interval_starts) 630 | |> Keyword.put(:current_interval_started, current_interval_start_date(subscription, options)) 631 | |> change_plan_options_from(default_options()) 632 | |> Keyword.new() 633 | 634 | if plan_pending?(subscription, options) do 635 | {:error, 636 | {Money.Subscription.PlanPending, "Can't change plan when a new plan is already pending"}} 637 | else 638 | {:ok, changes} = change_plan(current_plan, new_plan, options) 639 | updated_subscription = %{subscription | plans: [{changes, new_plan} | plans]} 640 | {:ok, updated_subscription} 641 | end 642 | end 643 | 644 | def change_plan( 645 | %{price: %Money{currency: currency}} = current_plan, 646 | %{price: %Money{currency: currency}} = new_plan, 647 | options 648 | ) do 649 | options = change_plan_options_from(options, default_options()) 650 | change_plan(current_plan, new_plan, options[:effective], options) 651 | end 652 | 653 | @doc """ 654 | Change plan from the current plan to a new plan. 655 | 656 | Retuns the plan or raises an exception on error. 657 | 658 | See `Money.Subscription.change_plan/3` for the description 659 | of arguments, options and return. 660 | 661 | """ 662 | # @doc since: "2.3.0" 663 | @spec change_plan!( 664 | subscription_or_plan :: t() | Plan.t(), 665 | new_plan :: Plan.t(), 666 | options :: Keyword.t() 667 | ) :: Change.t() | no_return() 668 | 669 | def change_plan!(subscription_or_plan, new_plan, options \\ []) do 670 | case change_plan(subscription_or_plan, new_plan, options) do 671 | {:ok, changeset} -> changeset 672 | {:error, {exception, message}} -> raise exception, message 673 | end 674 | end 675 | 676 | # Change the plan at the end of the current plan interval. This requires 677 | # no proration and is therefore the easiest to calculate. 678 | defp change_plan(current_plan, new_plan, :next_period, options) do 679 | price = Map.get(new_plan, :price) 680 | 681 | first_interval_starts = 682 | next_interval_starts(current_plan, options[:current_interval_started], options) 683 | 684 | zero = Money.zero(price.currency) 685 | 686 | {:ok, 687 | %Change{ 688 | first_billing_amount: price, 689 | first_interval_starts: first_interval_starts, 690 | next_interval_starts: next_interval_starts(new_plan, first_interval_starts, options), 691 | credit_amount_applied: zero, 692 | credit_amount: zero, 693 | credit_days_applied: 0, 694 | credit_period_ends: nil, 695 | carry_forward: zero 696 | }} 697 | end 698 | 699 | defp change_plan(current_plan, new_plan, :immediately, options) do 700 | change_plan(current_plan, new_plan, options[:today], options) 701 | end 702 | 703 | defp change_plan(current_plan, new_plan, effective_date, options) do 704 | credit = plan_credit(current_plan, effective_date, options) 705 | {:ok, prorate(new_plan, credit, effective_date, options[:prorate], options)} 706 | end 707 | 708 | # Reduce the price of the first interval of the new plan by the 709 | # credit amount on the current plan 710 | defp prorate(plan, credit_amount, effective_date, :price, options) do 711 | prorate_price = 712 | Map.get(plan, :price) 713 | |> Money.sub!(credit_amount) 714 | |> Money.round(rounding_mode: options[:round]) 715 | 716 | zero = zero(plan) 717 | 718 | {first_billing_amount, carry_forward} = 719 | if Money.compare(prorate_price, zero) == :lt do 720 | {zero, prorate_price} 721 | else 722 | {prorate_price, zero} 723 | end 724 | 725 | %Change{ 726 | first_interval_starts: effective_date, 727 | first_billing_amount: first_billing_amount, 728 | next_interval_starts: next_interval_starts(plan, effective_date, options), 729 | credit_amount: credit_amount, 730 | credit_amount_applied: Money.add!(credit_amount, carry_forward), 731 | credit_days_applied: 0, 732 | credit_period_ends: nil, 733 | carry_forward: carry_forward 734 | } 735 | end 736 | 737 | # Extend the first interval of the new plan by the amount of credit 738 | # on the current plan 739 | defp prorate(plan, credit_amount, effective_date, :period, options) do 740 | {next_interval_starts, days_credit} = 741 | extend_period(plan, credit_amount, effective_date, options) 742 | 743 | first_billing_amount = Map.get(plan, :price) 744 | credit_period_ends = Date.add(effective_date, days_credit - 1) 745 | 746 | %Change{ 747 | first_interval_starts: effective_date, 748 | first_billing_amount: first_billing_amount, 749 | next_interval_starts: next_interval_starts, 750 | credit_amount: credit_amount, 751 | credit_amount_applied: zero(plan), 752 | credit_days_applied: days_credit, 753 | credit_period_ends: credit_period_ends, 754 | carry_forward: zero(plan) 755 | } 756 | end 757 | 758 | defp plan_credit(%{price: price} = plan, effective_date, options) do 759 | plan_days = plan_days(plan, effective_date, options) 760 | price_per_day = Decimal.div(price.amount, Decimal.new(plan_days)) 761 | 762 | days_remaining = 763 | days_remaining(plan, options[:current_interval_started], effective_date, options) 764 | 765 | price_per_day 766 | |> Decimal.mult(Decimal.new(days_remaining)) 767 | |> Money.new(price.currency) 768 | |> Money.round(rounding_mode: options[:round]) 769 | end 770 | 771 | # Extend the interval by the amount that 772 | # credit will fund on the new plan in days. 773 | defp extend_period(plan, credit, effective_date, options) do 774 | price = Map.get(plan, :price) 775 | plan_days = plan_days(plan, effective_date, options) 776 | price_per_day = Decimal.div(price.amount, Decimal.new(plan_days)) 777 | 778 | credit_days_applied = 779 | credit.amount 780 | |> Decimal.div(price_per_day) 781 | |> Decimal.round(0, options[:round]) 782 | |> Decimal.to_integer() 783 | 784 | next_interval_starts = 785 | next_interval_starts(plan, effective_date, options) 786 | |> Date.add(credit_days_applied) 787 | 788 | {next_interval_starts, credit_days_applied} 789 | end 790 | 791 | @doc """ 792 | Returns number of days in a plan interval. 793 | 794 | ## Arguments 795 | 796 | * `plan` is any `Money.Subscription.Plan.t` 797 | 798 | * `current_interval_started` is any `Date.t` 799 | 800 | ## Returns 801 | 802 | The number of days in a plan interval. 803 | 804 | ## Examples 805 | 806 | iex> plan = Money.Subscription.Plan.new! Money.new!(:USD, 100), :month, 1 807 | iex> Money.Subscription.plan_days plan, ~D[2018-01-01] 808 | 31 809 | iex> Money.Subscription.plan_days plan, ~D[2018-02-01] 810 | 28 811 | iex> Money.Subscription.plan_days plan, ~D[2018-04-01] 812 | 30 813 | 814 | """ 815 | # @doc since: "2.3.0" 816 | @spec plan_days(Plan.t(), Date.t(), Keyword.t()) :: integer() 817 | def plan_days(plan, current_interval_started, options \\ []) do 818 | plan 819 | |> next_interval_starts(current_interval_started, options) 820 | |> Date.diff(current_interval_started) 821 | end 822 | 823 | @doc """ 824 | Returns number of days remaining in a plan interval. 825 | 826 | ## Arguments 827 | 828 | * `plan` is any `Money.Subscription.Plan.t` 829 | 830 | * `current_interval_started` is a `Date.t` 831 | 832 | * `effective_date` is a `Date.t` after the 833 | `current_interval_started` and before the end of 834 | the `plan_days` 835 | 836 | ## Returns 837 | 838 | The number of days remaining in a plan interval 839 | 840 | ## Examples 841 | 842 | iex> plan = Money.Subscription.Plan.new! Money.new!(:USD, 100), :month, 1 843 | iex> Money.Subscription.days_remaining plan, ~D[2018-01-01], ~D[2018-01-02] 844 | 30 845 | iex> Money.Subscription.days_remaining plan, ~D[2018-02-01], ~D[2018-02-02] 846 | 27 847 | 848 | """ 849 | # @doc since: "2.3.0" 850 | @spec days_remaining(Plan.t(), Date.t(), Date.t(), Keyword.t()) :: integer 851 | def days_remaining(plan, current_interval_started, effective_date, options \\ []) do 852 | plan 853 | |> next_interval_starts(current_interval_started, options) 854 | |> Date.diff(effective_date) 855 | end 856 | 857 | @doc """ 858 | Returns the next interval start date for a plan. 859 | 860 | ## Arguments 861 | 862 | * `plan` is any `Money.Subscription.Plan.t` 863 | 864 | * `:current_interval_started` is the `Date.t` that 865 | represents the start of the current interval 866 | 867 | ## Returns 868 | 869 | The next interval start date as a `Date.t`. 870 | 871 | ## Example 872 | 873 | iex> plan = Money.Subscription.Plan.new!(Money.new!(:USD, 100), :month) 874 | iex> Money.Subscription.next_interval_starts(plan, ~D[2018-03-01]) 875 | ~D[2018-04-01] 876 | 877 | iex> plan = Money.Subscription.Plan.new!(Money.new!(:USD, 100), :day, 30) 878 | iex> Money.Subscription.next_interval_starts(plan, ~D[2018-02-01]) 879 | ~D[2018-03-03] 880 | 881 | """ 882 | # @doc since: "2.3.0" 883 | @spec next_interval_starts(Plan.t(), Date.t(), Keyword.t()) :: Date.t() 884 | def next_interval_starts(plan, current_interval_started, options \\ []) 885 | 886 | def next_interval_starts( 887 | %{interval: :day, interval_count: count}, 888 | %{ 889 | year: _year, 890 | month: _month, 891 | day: _day, 892 | calendar: _calendar 893 | } = current_interval_started, 894 | _options 895 | ) do 896 | Date.add(current_interval_started, count) 897 | end 898 | 899 | def next_interval_starts( 900 | %{interval: :week, interval_count: count}, 901 | current_interval_started, 902 | options 903 | ) do 904 | next_interval_starts( 905 | %Plan{interval: :day, interval_count: count * 7}, 906 | current_interval_started, 907 | options 908 | ) 909 | end 910 | 911 | def next_interval_starts( 912 | %{interval: :month, interval_count: count} = plan, 913 | %{year: year, month: month, day: day, calendar: calendar} = current_interval_started, 914 | options 915 | ) do 916 | # options = if is_list(options), do: options, else: Enum.into(options, %{}) 917 | months_in_this_year = months_in_year(current_interval_started) 918 | 919 | {year, month} = 920 | if count + month <= months_in_this_year do 921 | {year, month + count} 922 | else 923 | months_left_this_year = months_in_this_year - month 924 | plan = %{plan | interval_count: count - months_left_this_year - 1} 925 | current_interval_started = %{current_interval_started | year: year + 1, month: 1, day: day} 926 | date = next_interval_starts(plan, current_interval_started, options) 927 | {Map.get(date, :year), Map.get(date, :month)} 928 | end 929 | 930 | day = 931 | year 932 | |> calendar.days_in_month(month) 933 | |> min(max(day, preferred_day(options))) 934 | 935 | {:ok, next_interval_starts} = Date.new(year, month, day, calendar) 936 | next_interval_starts 937 | end 938 | 939 | def next_interval_starts( 940 | %{interval: :year, interval_count: count}, 941 | %{year: year} = current_interval_started, 942 | _options 943 | ) do 944 | %{current_interval_started | year: year + count} 945 | end 946 | 947 | ## Helpers 948 | 949 | @default_months_in_year 12 950 | defp months_in_year(%{year: year, calendar: calendar}) do 951 | if function_exported?(calendar, :months_in_year, 1) do 952 | calendar.months_in_year(year) 953 | else 954 | @default_months_in_year 955 | end 956 | end 957 | 958 | defp change_plan_options_from(options, default_options) do 959 | options = 960 | default_options 961 | |> Keyword.merge(options) 962 | 963 | require_options!(options, [:effective, :current_interval_started]) 964 | Keyword.put_new(options, :first_interval_started, options[:current_interval_started]) 965 | end 966 | 967 | defp default_options do 968 | [effective: :next_period, prorate: :price, round: :up, today: Date.utc_today()] 969 | end 970 | 971 | defp zero(plan) do 972 | plan 973 | |> Map.get(:price) 974 | |> Map.get(:currency) 975 | |> Money.zero() 976 | end 977 | 978 | defp require_options!(options, [h | []]) do 979 | unless options[h] do 980 | raise_change_plan_options_error(h) 981 | end 982 | end 983 | 984 | defp require_options!(options, [h | t]) do 985 | if options[h] do 986 | require_options!(options, t) 987 | else 988 | raise_change_plan_options_error(h) 989 | end 990 | end 991 | 992 | @dialyzer {:nowarn_function, raise_change_plan_options_error: 1} 993 | defp raise_change_plan_options_error(opt) do 994 | raise ArgumentError, "change_plan requires the the option #{inspect(opt)}" 995 | end 996 | 997 | defp preferred_day(options) do 998 | case Keyword.get(options, :first_interval_started) do 999 | %{day: day} -> day 1000 | _ -> -1 1001 | end 1002 | end 1003 | end 1004 | -------------------------------------------------------------------------------- /lib/money/subscription/change.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.Subscription.Change do 2 | @moduledoc """ 3 | Defines the structure of a plan changeset. 4 | 5 | * `:first_interval_starts` which is the start date of the first interval for the new 6 | plan 7 | 8 | * `:first_billing_amount` is the amount to be billed, net of any credit, at 9 | the `:first_interval_starts` 10 | 11 | * `:next_interval_starts` is the start date of the next interval after the 12 | first interval including any `credit_days_applied` 13 | 14 | * `:credit_amount` is the amount of unconsumed credit of the current plan 15 | 16 | * `:credit_amount_applied` is the amount of credit applied to the new plan. If 17 | the `:prorate` option is `:price` (the default) the `:first_billing_amount` 18 | is the plan `:price` reduced by the `:credit_amount_applied`. If the `:prorate` 19 | option is `:period` then the `:first_billing_amount` is the plan `price` and 20 | the `:next_interval_date` is extended by the `:credit_days_applied` 21 | instead. 22 | 23 | * `:credit_days_applied` is the number of days credit applied to the first 24 | interval by adding days to the `:first_interval_starts` date. 25 | 26 | * `:credit_period_ends` is the date on which any applied credit is consumed or `nil` 27 | 28 | * `:carry_forward` is any amount of credit carried forward to a subsequent period. 29 | If non-zero this amount is a negative `Money.t`. It is non-zero when the credit 30 | amount for the current plan is greater than the price of the new plan. In 31 | this case the `:first_billing_amount` is zero. 32 | 33 | """ 34 | 35 | @typedoc "A plan change record struct." 36 | @type t :: %__MODULE__{ 37 | first_billing_amount: Money.t(), 38 | first_interval_starts: Date.t(), 39 | next_interval_starts: Date.t(), 40 | credit_amount_applied: Money.t(), 41 | credit_amount: Money.t(), 42 | credit_days_applied: non_neg_integer(), 43 | credit_period_ends: Date.t(), 44 | carry_forward: Money.t() 45 | } 46 | 47 | @doc """ 48 | A struct defining the changes between two plans. 49 | """ 50 | defstruct first_billing_amount: nil, 51 | first_interval_starts: nil, 52 | next_interval_starts: nil, 53 | credit_amount_applied: nil, 54 | credit_amount: nil, 55 | credit_days_applied: 0, 56 | credit_period_ends: nil, 57 | carry_forward: nil 58 | end 59 | -------------------------------------------------------------------------------- /lib/money/subscription/plan.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.Subscription.Plan do 2 | @moduledoc """ 3 | Defines a standard subscription plan data structure. 4 | """ 5 | 6 | @typedoc "A plan interval type." 7 | @type interval :: :day | :week | :month | :year 8 | 9 | @typedoc "A integer interval count for a plan." 10 | @type interval_count :: non_neg_integer 11 | 12 | @typedoc "A Subscription Plan" 13 | @type t :: %__MODULE__{ 14 | price: Money.t() | nil, 15 | interval: interval, 16 | interval_count: interval_count 17 | } 18 | 19 | @doc """ 20 | Defines the structure of a subscription plan. 21 | """ 22 | defstruct price: nil, 23 | interval: nil, 24 | interval_count: nil 25 | 26 | @interval [:day, :week, :month, :year] 27 | 28 | @doc """ 29 | Returns `{:ok, Money.Subscription.Plan.t}` or an `{:error, reason}` 30 | tuple. 31 | 32 | ## Arguments 33 | 34 | * `:price` is any `Money.t` 35 | 36 | * `:interval` is the period of the plan. The valid intervals are 37 | ` `:day`, `:week`, `:month` or ':year`. 38 | 39 | * `:interval_count` is an integer count of the number of `:interval`s 40 | of the plan. The default is `1` 41 | 42 | ## Returns 43 | 44 | A `Money.Subscription.Plan.t` 45 | 46 | ## Examples 47 | 48 | iex> Money.Subscription.Plan.new Money.new(:USD, 100), :month, 1 49 | {:ok, 50 | %Money.Subscription.Plan{ 51 | interval: :month, 52 | interval_count: 1, 53 | price: Money.new(:USD, 100) 54 | }} 55 | 56 | iex> Money.Subscription.Plan.new Money.new(:USD, 100), :month 57 | {:ok, 58 | %Money.Subscription.Plan{ 59 | interval: :month, 60 | interval_count: 1, 61 | price: Money.new(:USD, 100) 62 | }} 63 | 64 | iex> Money.Subscription.Plan.new Money.new(:USD, 100), :day, 30 65 | {:ok, 66 | %Money.Subscription.Plan{ 67 | interval: :day, 68 | interval_count: 30, 69 | price: Money.new(:USD, 100) 70 | }} 71 | 72 | iex> Money.Subscription.Plan.new 23, :day, 30 73 | {:error, {Money.Invalid, "Invalid subscription plan definition"}} 74 | 75 | """ 76 | @spec new(Money.t(), interval(), interval_count()) :: 77 | {:ok, t()} | {:error, {module(), String.t()}} 78 | 79 | def new(price, interval, interval_count \\ 1) 80 | 81 | def new(%Money{} = price, interval, interval_count) 82 | when interval in @interval and is_integer(interval_count) do 83 | {:ok, %__MODULE__{price: price, interval: interval, interval_count: interval_count}} 84 | end 85 | 86 | def new(_price, _interval, _interval_count) do 87 | {:error, {Money.Invalid, "Invalid subscription plan definition"}} 88 | end 89 | 90 | @doc """ 91 | Returns `{:ok, Money.Subscription.Plan.t}` or raises an 92 | exception. 93 | 94 | Takes the same arguments as `Money.Subscription.Plan.new/3`. 95 | 96 | ## Example 97 | 98 | iex> Money.Subscription.Plan.new! Money.new(:USD, 100), :day, 30 99 | %Money.Subscription.Plan{ 100 | interval: :day, 101 | interval_count: 30, 102 | price: Money.new(:USD, 100) 103 | } 104 | 105 | """ 106 | @spec new!(Money.t(), interval(), interval_count()) :: t() | no_return() 107 | def new!(price, interval, interval_count \\ 1) 108 | 109 | def new!(price, interval, interval_count) do 110 | case new(price, interval, interval_count) do 111 | {:ok, plan} -> plan 112 | {:error, {exception, reason}} -> raise exception, reason 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kipcole9/money/6ca90ca70925c96c8f7c4056e887fe76d32e381d/logo.png -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.Mixfile do 2 | use Mix.Project 3 | 4 | @version "5.21.0" 5 | 6 | def project do 7 | [ 8 | app: :ex_money, 9 | version: @version, 10 | elixir: "~> 1.12", 11 | name: "Money", 12 | source_url: "https://github.com/kipcole9/money", 13 | docs: docs(), 14 | build_embedded: Mix.env() == :prod, 15 | start_permanent: Mix.env() == :prod, 16 | deps: deps(), 17 | description: description(), 18 | package: package(), 19 | test_coverage: [tool: ExCoveralls], 20 | aliases: aliases(), 21 | elixirc_paths: elixirc_paths(Mix.env()), 22 | dialyzer: [ 23 | plt_add_apps: ~w(inets jason mix phoenix_html gringotts)a, 24 | flags: [ 25 | :error_handling, 26 | :unknown, 27 | :underspecs, 28 | :extra_return, 29 | :missing_return 30 | ] 31 | ], 32 | compilers: Mix.compilers() 33 | ] 34 | end 35 | 36 | defp description do 37 | """ 38 | Money functions for operations on and localization of a money data type with support 39 | for ISO 4217 currencies and ISO 24165 digial tokens (crypto currencies). 40 | """ 41 | end 42 | 43 | defp package do 44 | [ 45 | maintainers: ["Kip Cole"], 46 | licenses: ["Apache-2.0"], 47 | links: %{ 48 | "GitHub" => "https://github.com/kipcole9/money", 49 | "Readme" => "https://github.com/kipcole9/money/blob/v#{@version}/README.md", 50 | "Changelog" => "https://github.com/kipcole9/money/blob/v#{@version}/CHANGELOG.md" 51 | }, 52 | files: [ 53 | "lib", 54 | "config", 55 | "mix.exs", 56 | "README.md", 57 | "CHANGELOG.md", 58 | "LICENSE.md" 59 | ] 60 | ] 61 | end 62 | 63 | def application do 64 | [ 65 | mod: {Money.Application, [strategy: :one_for_one, name: Money.Supervisor]}, 66 | extra_applications: [:inets, :logger, :decimal] 67 | ] 68 | end 69 | 70 | def docs do 71 | [ 72 | source_ref: "v#{@version}", 73 | extras: ["README.md", "CHANGELOG.md", "LICENSE.md"], 74 | main: "readme", 75 | groups_for_modules: groups_for_modules(), 76 | logo: "logo.png", 77 | skip_undefined_reference_warnings_on: ["changelog", "CHANGELOG.md", "README.md"] 78 | ] 79 | end 80 | 81 | defp groups_for_modules do 82 | [ 83 | "Exchange Rates": ~r/^Money.ExchangeRates.?/, 84 | Subscriptions: ~r/^Money.Subscription.?/ 85 | ] 86 | end 87 | 88 | def aliases do 89 | [] 90 | end 91 | 92 | defp deps do 93 | [ 94 | {:ex_cldr_numbers, "~> 2.34"}, 95 | 96 | {:nimble_parsec, "~> 0.5 or ~> 1.0"}, 97 | {:decimal, "~> 1.6 or ~> 2.0"}, 98 | {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", optional: true}, 99 | {:phoenix_html, "~> 2.0 or ~> 3.0 or ~> 4.0", optional: true}, 100 | {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, 101 | {:jason, "~> 1.0", optional: true}, 102 | {:stream_data, "~> 1.0", only: [:dev, :test]}, 103 | {:benchee, "~> 1.0", optional: true, only: :dev}, 104 | {:exprof, "~> 0.2", only: :dev, runtime: false}, 105 | {:ex_doc, "~> 0.31", only: [:dev, :release]}, 106 | {:gringotts, "~> 1.1", optional: true} 107 | ] 108 | |> Enum.reject(&is_nil/1) 109 | end 110 | 111 | defp elixirc_paths(:test), do: ["lib", "test", "test/support"] 112 | defp elixirc_paths(:dev), do: ["lib", "mix"] 113 | defp elixirc_paths(_), do: ["lib"] 114 | end 115 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.3.1", "c786e6a76321121a44229dde3988fc772bca73ea75170a73fd5f4ddf1af95ccf", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "76224c58ea1d0391c8309a8ecbfe27d71062878f59bd41a390266bf4ac1cc56d"}, 3 | "certifi": {:hex, :certifi, "2.14.0", "ed3bef654e69cde5e6c022df8070a579a79e8ba2368a00acf3d75b82d9aceeed", [:rebar3], [], "hexpm", "ea59d87ef89da429b8e905264fdec3419f84f2215bb3d81e07a18aac919026c3"}, 4 | "cldr_utils": {:hex, :cldr_utils, "2.28.2", "f500667164a9043369071e4f9dcef31f88b8589b2e2c07a1eb9f9fa53cb1dce9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "c506eb1a170ba7cdca59b304ba02a56795ed119856662f6b1a420af80ec42551"}, 5 | "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, 6 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 7 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 8 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 9 | "digital_token": {:hex, :digital_token, "1.0.0", "454a4444061943f7349a51ef74b7fb1ebd19e6a94f43ef711f7dae88c09347df", [:mix], [{:cldr_utils, "~> 2.17", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "8ed6f5a8c2fa7b07147b9963db506a1b4c7475d9afca6492136535b064c9e9e6"}, 10 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 11 | "elixir_xml_to_map": {:hex, :elixir_xml_to_map, "3.1.0", "4d6260486a8cce59e4bf3575fe2dd2a24766546ceeef9f93fcec6f7c62a2827a", [:mix], [{:erlsom, "~> 1.4", [hex: :erlsom, repo: "hexpm", optional: false]}], "hexpm", "8fe5f2e75f90bab07ee2161120c2dc038ebcae8135554f5582990f1c8c21f911"}, 12 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 13 | "erlsom": {:hex, :erlsom, "1.5.2", "3e47c53a199136fb4d20d5479edb7c9229f31624534c062633951c8d14dcd276", [:rebar3], [], "hexpm", "4e765cc677fb30509f7b628ff2914e124cf4dcc0fac1c0a62ee4dcee24215b5d"}, 14 | "ex_cldr": {:hex, :ex_cldr, "2.42.0", "17ea930e88b8802b330e1c1e288cdbaba52cbfafcccf371ed34b299a47101ffb", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "07264a7225810ecae6bdd6715d8800c037a1248dc0063923cddc4ca3c4888df6"}, 15 | "ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.16.4", "d76770690699b6ba91f1fa253a299a905f9c22b45d91891b85f431b9dafa8b3b", [:mix], [{:ex_cldr, "~> 2.38", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "46a67d1387f14e836b1a24d831fa5f0904663b4f386420736f40a7d534e3cb9e"}, 16 | "ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.35.0", "66d3e6af936abcaad6a1ab89ecd6ccef107e3198152315a78b76b398782be367", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 0.3 or ~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.42", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.16", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "0146dbe6c9665fb725ae2345c8c6d033e1d9526a8ef848e187cc9042886729bf"}, 17 | "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, 18 | "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, 19 | "exprintf": {:hex, :exprintf, "0.2.1", "b7e895dfb00520cfb7fc1671303b63b37dc3897c59be7cbf1ae62f766a8a0314", [:mix], [], "hexpm", "20a0e8c880be90e56a77fcc82533c5d60c643915c7ce0cc8aa1e06ed6001da28"}, 20 | "exprof": {:hex, :exprof, "0.2.4", "13ddc0575a6d24b52e7c6809d2a46e9ad63a4dd179628698cdbb6c1f6e497c98", [:mix], [{:exprintf, "~> 0.2", [hex: :exprintf, repo: "hexpm", optional: false]}], "hexpm", "0884bcb66afc421c75d749156acbb99034cc7db6d3b116c32e36f32551106957"}, 21 | "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, 22 | "gringotts": {:hex, :gringotts, "1.1.1", "a368c62bd70267970c01e727ce7294371e6560997b8680d5e391c8332714bb69", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:elixir_xml_to_map, "~> 3.0", [hex: :elixir_xml_to_map, repo: "hexpm", optional: false]}, {:httpoison, "~> 1.8", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:timex, "~> 3.7", [hex: :timex, repo: "hexpm", optional: false]}, {:xml_builder, "~> 2.2", [hex: :xml_builder, repo: "hexpm", optional: false]}], "hexpm", "aff9dccfc5cc5e6c9bb61aa0e0a586c116409246b74717ffe741a27d9e4d5e97"}, 23 | "hackney": {:hex, :hackney, "1.23.0", "55cc09077112bcb4a69e54be46ed9bc55537763a96cd4a80a221663a7eafd767", [:rebar3], [{:certifi, "~> 2.14.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "6cd1c04cd15c81e5a493f167b226a15f0938a84fc8f0736ebe4ddcab65c0b44e"}, 24 | "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, 25 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 26 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 27 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 28 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 29 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 30 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 31 | "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, 32 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 33 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 34 | "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, 35 | "poison": {:hex, :poison, "6.0.0", "9bbe86722355e36ffb62c51a552719534257ba53f3271dacd20fbbd6621a583a", [:mix], [{:decimal, "~> 2.1", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "bb9064632b94775a3964642d6a78281c07b7be1319e0016e1643790704e739a2"}, 36 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 37 | "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, 38 | "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, 39 | "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, 40 | "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, 41 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 42 | "xml_builder": {:hex, :xml_builder, "2.3.0", "69d214c6ad41ae1300b36acff4367551cdfd9dc1b860affc16e103c6b1589053", [:mix], [], "hexpm", "972ec33346a225cd5acd14ab23d4e79042bd37cb904e07e24cd06992dde1a0ed"}, 43 | } 44 | -------------------------------------------------------------------------------- /mix/test_cldr.ex: -------------------------------------------------------------------------------- 1 | require Money.Backend 2 | require Money 3 | 4 | defmodule Money.Cldr do 5 | @moduledoc false 6 | 7 | use Cldr, 8 | locales: [ 9 | "en", 10 | "en-ZA", 11 | "bn", 12 | "de", 13 | "it", 14 | "es", 15 | "fr", 16 | "da", 17 | "zh-Hant-HK", 18 | "zh-Hans", 19 | "ja", 20 | "en-001", 21 | "th", 22 | "es-CO", 23 | "pt-CV", 24 | "ar-EG", 25 | "ar-MA" 26 | ], 27 | default_locale: "en", 28 | providers: [Cldr.Number, Money] 29 | end 30 | -------------------------------------------------------------------------------- /test/application_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.Application.Test do 2 | use ExUnit.Case 3 | 4 | test "That our default Application supervisor has the default options" do 5 | {_app, options} = Application.spec(:ex_money) |> Keyword.get(:mod) 6 | assert options == [strategy: :one_for_one, name: Money.Supervisor] 7 | end 8 | 9 | test "default supervisor name" do 10 | assert Money.ExchangeRates.Supervisor.default_supervisor() == Money.Supervisor 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/backend_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.BackendTest do 2 | use ExUnit.Case 3 | 4 | doctest Test.Cldr.Money 5 | doctest Money.Backend 6 | end 7 | -------------------------------------------------------------------------------- /test/gringotts_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.GringottsTest do 2 | use ExUnit.Case 3 | 4 | alias Gringotts.Money, as: MoneyProtocol 5 | 6 | describe "Gringotts.Money protocol implementation" do 7 | test "currency is an upcase String.t" do 8 | the_currency = MoneyProtocol.currency(Money.new(0, :USD)) 9 | assert match?(currency when is_binary(currency), the_currency) 10 | assert the_currency == String.upcase(the_currency) 11 | end 12 | 13 | test "to_integer" do 14 | assert match?({"EUR", 4200, -2}, MoneyProtocol.to_integer(Money.new(42, :EUR))) 15 | assert match?({"BHD", 42_000, -3}, MoneyProtocol.to_integer(Money.new(42, :BHD))) 16 | assert match?({"BHD", 42_007, -3}, MoneyProtocol.to_integer(Money.new("42.0066", :BHD))) 17 | end 18 | 19 | test "to_string" do 20 | assert match?({"EUR", "42"}, MoneyProtocol.to_string(Money.new("42.00", :EUR))) 21 | assert match?({"EUR", "42"}, MoneyProtocol.to_string(Money.new(42, :EUR))) 22 | assert match?({"EUR", "42.01"}, MoneyProtocol.to_string(Money.new("42.0064", :EUR))) 23 | assert match?({"BHD", "42.006"}, MoneyProtocol.to_string(Money.new("42.006", :BHD))) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/money_exchange_rates_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.ExchangeRates.Test do 2 | use ExUnit.Case 3 | import ExUnit.CaptureIO 4 | 5 | alias Money.ExchangeRates 6 | 7 | test "Get exchange rates from ExchangeRates.Retriever" do 8 | test_result = {:ok, %{USD: Decimal.new(1), AUD: Decimal.new("0.7"), EUR: Decimal.new("1.2")}} 9 | assert Money.ExchangeRates.latest_rates() == test_result 10 | end 11 | 12 | test "Get exchange rates from ExchangeRates" do 13 | test_result = {:ok, %{USD: Decimal.new(1), AUD: Decimal.new("0.7"), EUR: Decimal.new("1.2")}} 14 | assert Money.ExchangeRates.latest_rates() == test_result 15 | end 16 | 17 | test "Localize money" do 18 | assert {:ok, _} = Money.localize(Money.new(:AUD, 100), locale: "en", backend: Test.Cldr) 19 | end 20 | 21 | test "Convert from USD to AUD" do 22 | assert Money.compare(Money.to_currency!(Money.new(:USD, 100), :AUD), Money.new(:AUD, 70)) == :eq 23 | end 24 | 25 | test "Convert from USD to USD" do 26 | assert Money.compare(Money.to_currency!(Money.new(:USD, 100), :USD), Money.new(:USD, 100)) == 27 | :eq 28 | end 29 | 30 | test "Convert from USD to ZZZ should return an error" do 31 | capture_io(fn -> 32 | assert Money.to_currency(Money.new(:USD, 100), :ZZZ) == 33 | {:error, {Cldr.UnknownCurrencyError, "The currency :ZZZ is invalid"}} 34 | end) 35 | end 36 | 37 | test "Convert from USD to ZZZ should raise an exception" do 38 | capture_io(fn -> 39 | assert_raise Cldr.UnknownCurrencyError, ~r/The currency :ZZZ is invalid/, fn -> 40 | assert Money.to_currency!(Money.new(:USD, 100), :ZZZ) 41 | end 42 | end) 43 | end 44 | 45 | test "Convert from USD to AUD using historic rates" do 46 | capture_io(fn -> 47 | assert Money.to_currency!( 48 | Money.new(:USD, 100), 49 | :AUD, 50 | ExchangeRates.historic_rates(~D[2017-01-01]) 51 | ) 52 | |> Money.round() == Money.new(:AUD, Decimal.new("71.43")) 53 | end) 54 | end 55 | 56 | test "Convert from USD to AUD using historic rates that aren't available" do 57 | assert Money.to_currency( 58 | Money.new(:USD, 100), 59 | :AUD, 60 | ExchangeRates.historic_rates(~D[2017-02-01]) 61 | ) == {:error, {Money.ExchangeRateError, "No exchange rates for 2017-02-01 were found"}} 62 | end 63 | 64 | test "That an error is returned if there is no open exchange rates app_id configured" do 65 | Application.put_env(:ex_money, :open_exchange_rates_app_id, nil) 66 | config = Money.ExchangeRates.OpenExchangeRates.init(Money.ExchangeRates.default_config()) 67 | config = Map.put(config, :log_levels, %{failure: nil, info: nil, success: nil}) 68 | 69 | assert Money.ExchangeRates.OpenExchangeRates.get_latest_rates(config) == 70 | {:error, "Open Exchange Rates app_id is not configured. Rates are not retrieved."} 71 | end 72 | 73 | if System.get_env("OPEN_EXCHANGE_RATES_APP_ID") do 74 | test "That the Open Exchange Rates retriever returns a map" do 75 | Application.put_env( 76 | :ex_money, 77 | :open_exchange_rates_app_id, 78 | System.get_env("OPEN_EXCHANGE_RATES_APP_ID") 79 | ) 80 | 81 | config = Money.ExchangeRates.OpenExchangeRates.init(Money.ExchangeRates.default_config()) 82 | config = Map.put(config, :log_levels, %{failure: nil, info: nil, success: nil}) 83 | 84 | # Testing only, should not be used in production 85 | # config = Map.put(config, :verify_peer, false) 86 | 87 | case Money.ExchangeRates.OpenExchangeRates.get_latest_rates(config) do 88 | {:ok, rates} -> assert is_map(rates) 89 | {:error, :nxdomain} -> :no_network 90 | {:error, other} -> IO.warn(inspect(other)) 91 | end 92 | end 93 | end 94 | 95 | test "that api latest_rates callbacks are executed" do 96 | config = 97 | Money.ExchangeRates.default_config() 98 | |> Map.put(:callback_module, Money.ExchangeRates.CallbackTest) 99 | 100 | Money.ExchangeRates.Retriever.reconfigure(config) 101 | Money.ExchangeRates.Retriever.latest_rates() 102 | 103 | assert Application.get_env(:ex_money, :test) == "Latest Rates Retrieved" 104 | 105 | Money.ExchangeRates.default_config() 106 | |> Money.ExchangeRates.Retriever.reconfigure() 107 | end 108 | 109 | test "that api historic_rates callbacks are executed" do 110 | config = 111 | Money.ExchangeRates.default_config() 112 | |> Map.put(:callback_module, Money.ExchangeRates.CallbackTest) 113 | 114 | Money.ExchangeRates.Retriever.reconfigure(config) 115 | Money.ExchangeRates.Retriever.historic_rates(~D[2017-01-01]) 116 | 117 | assert Application.get_env(:ex_money, :test) == "Historic Rates Retrieved" 118 | 119 | Money.ExchangeRates.default_config() 120 | |> Money.ExchangeRates.Retriever.reconfigure() 121 | end 122 | 123 | test "that the last_udpated timestamp is returned in a success tuple" do 124 | # warm up cache 125 | Money.ExchangeRates.Retriever.latest_rates() 126 | 127 | assert {:ok, %DateTime{}} = Money.ExchangeRates.last_updated() 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /test/money_parse_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MoneyTest.Parse do 2 | use ExUnit.Case 3 | 4 | describe "Money.parse/2 " do 5 | test "parses with currency code in front" do 6 | assert Money.parse("USD 100") == Money.new(:USD, 100) 7 | assert Money.parse("USD100") == Money.new(:USD, 100) 8 | assert Money.parse("USD 100 ") == Money.new(:USD, 100) 9 | assert Money.parse("USD100 ") == Money.new(:USD, 100) 10 | assert Money.parse("USD 100.00") == Money.new(:USD, "100.00") 11 | end 12 | 13 | test "parses with a single digit amount" do 14 | assert Money.parse("USD 1") == Money.new(:USD, 1) 15 | assert Money.parse("USD1") == Money.new(:USD, 1) 16 | assert Money.parse("USD9") == Money.new(:USD, 9) 17 | end 18 | 19 | test "parses with currency code out back" do 20 | assert Money.parse("100 USD") == Money.new(:USD, 100) 21 | assert Money.parse("100USD") == Money.new(:USD, 100) 22 | assert Money.parse("100 USD ") == Money.new(:USD, 100) 23 | assert Money.parse("100USD ") == Money.new(:USD, 100) 24 | assert Money.parse("100.00USD") == Money.new(:USD, "100.00") 25 | end 26 | 27 | test "parses digital tokens" do 28 | assert Money.new("BTC", "100") == Money.parse("100 BTC") 29 | assert Money.new("BTC", "100") == Money.parse("100 Bitcoin") 30 | assert Money.new("BTC", "100") == Money.parse("BTC 100") 31 | assert Money.new("BTC", "100") == Money.parse("Bitcoin 100") 32 | end 33 | 34 | test "parsing with currency strings that are not codes" do 35 | assert Money.parse("australian dollar 12346.45") == Money.new(:AUD, "12346.45") 36 | assert Money.parse("12346.45 australian dollars") == Money.new(:AUD, "12346.45") 37 | assert Money.parse("12346.45 Australian Dollars") == Money.new(:AUD, "12346.45") 38 | assert Money.parse("12 346 dollar australien", locale: "fr") == Money.new(:AUD, 12346) 39 | end 40 | 41 | test "parses with locale specific separators" do 42 | assert Money.parse("100,00USD", locale: "de") == Money.new(:USD, "100.00") 43 | end 44 | 45 | test "parses euro (unicode symbol)" do 46 | assert Money.parse("99.99€") == Money.new(:EUR, "99.99") 47 | end 48 | 49 | test "currency filtering" do 50 | assert Money.parse("100 Mexican silver pesos") == Money.new(:MXP, 100) 51 | 52 | assert Money.parse("100 Mexican silver pesos", currency_filter: [:current]) == 53 | {:error, 54 | {Money.UnknownCurrencyError, 55 | "The currency \"Mexican silver pesos\" is unknown or not supported"}} 56 | end 57 | 58 | test "fuzzy matching of currencies" do 59 | assert Money.parse("100 eurosports", fuzzy: 0.8) == Money.new(:EUR, 100) 60 | 61 | assert Money.parse("100 eurosports", fuzzy: 0.9) == 62 | {:error, 63 | {Money.UnknownCurrencyError, 64 | "The currency \"eurosports\" is unknown or not supported"}} 65 | end 66 | 67 | test "parsing fails if no currency and no default currency" do 68 | assert Money.parse("100", default_currency: false) == 69 | {:error, 70 | {Money.Invalid, 71 | "A currency code, symbol or description must be specified but was not found in \"100\""}} 72 | end 73 | 74 | test "parse with locale determining currency" do 75 | assert Money.parse("100", locale: "en") == Money.new(:USD, 100) 76 | assert Money.parse("100", locale: "de") == Money.new(:EUR, 100) 77 | end 78 | 79 | test "parse with a default currency" do 80 | assert Money.parse("100", default_currency: :USD) == Money.new(:USD, 100) 81 | assert Money.parse("100", default_currency: "USD") == Money.new(:USD, 100) 82 | assert Money.parse("100", default_currency: "australian dollars") == Money.new(:AUD, 100) 83 | end 84 | 85 | test "with locale overrides" do 86 | # A locale that has a regional override. The regional override 87 | # takes precedence and hence the currency is USD 88 | assert Money.parse("100", locale: "zh-Hans-u-rg-uszzzz") == Money.new(:USD, 100) 89 | 90 | # A locale that has a regional override and a currency 91 | # override uses the currency override as precedent over 92 | # the regional override. In this case, EUR 93 | assert Money.parse("100", locale: "zh-Hans-u-rg-uszzzz-cu-eur") == Money.new(:EUR, 100) 94 | end 95 | 96 | test "parse with negative numbers" do 97 | assert Money.parse("-127,54 €", locale: "fr") == Money.new(:EUR, "-127.54") 98 | assert Money.parse("-127,54€", locale: "fr") == Money.new(:EUR, "-127.54") 99 | 100 | assert Money.parse("€ 127,54-", locale: "nl") == Money.new(:EUR, "-127.54") 101 | assert Money.parse("€127,54-", locale: "nl") == Money.new(:EUR, "-127.54") 102 | 103 | assert Money.parse("($127.54)", locale: "en") == Money.new(:USD, "-127.54") 104 | 105 | assert Money.parse("CHF -127.54", locale: "de-CH") == Money.new(:CHF, "-127.54") 106 | assert Money.parse("-127.54 CHF", locale: "de-CH") == Money.new(:CHF, "-127.54") 107 | 108 | assert Money.parse("kr-127,54", locale: "da") == Money.new(:DKK, "-127.54") 109 | assert Money.parse("kr -127,54", locale: "da") == Money.new(:DKK, "-127.54") 110 | end 111 | 112 | test "de locale" do 113 | assert Money.parse("1.127,54 €", locale: "de") == Money.new(:EUR, "1127.54") 114 | end 115 | 116 | test "Round trip parsing" do 117 | assert Money.parse("1 127,54 €", locale: "fr") == 118 | Money.new!(:EUR, "1127.54") 119 | |> Money.to_string!(locale: "fr") 120 | |> Money.parse(locale: "fr") 121 | end 122 | 123 | test "parsing strings that have `.` in them" do 124 | assert Money.parse("4.200,00 kr.", locale: "da") == Money.new(:DKK, "4200.00") 125 | end 126 | 127 | test "parse a string that has RTL markers" do 128 | assert Money.parse("\u200F1.234,56\u00A0د.م.\u200F", locale: "ar-MA") == 129 | Money.new(:MAD, "1234.56") 130 | end 131 | 132 | test "Parse a money string that uses a non-breaking-space for a separator" do 133 | assert Money.parse("US$30\u00A0000,00", locale: :en_ZA, separators: :standard) == 134 | Money.new(:USD, "30000.00") 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /test/money_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MoneyTest do 2 | use ExUnit.Case 3 | use ExUnitProperties 4 | 5 | import ExUnit.CaptureLog 6 | alias Money.Financial 7 | 8 | doctest Money 9 | doctest Money.ExchangeRates 10 | doctest Money.Currency 11 | doctest Money.ExchangeRates.Cache 12 | doctest Money.ExchangeRates.Cache.Ets 13 | doctest Money.ExchangeRates.Cache.Dets 14 | doctest Money.ExchangeRates.Retriever 15 | doctest Money.Financial 16 | doctest Money.Sigil 17 | 18 | test "create a new money struct with a binary currency code" do 19 | money = Money.new(1234, "USD") 20 | assert money.currency == :USD 21 | assert money.amount == Decimal.new(1234) 22 | end 23 | 24 | test "create a new money struct wth a binary currency code and binary amount" do 25 | money = Money.new("1234", "USD") 26 | assert money.currency == :USD 27 | assert money.amount == Decimal.new(1234) 28 | 29 | money = Money.new("USD", "1234") 30 | assert money.currency == :USD 31 | assert money.amount == Decimal.new(1234) 32 | end 33 | 34 | test "create a new money struct wth a invalid binary currency code and binary amount" do 35 | money = Money.new("1234", "ZZZ") 36 | assert money == {:error, {Money.UnknownCurrencyError, "The currency \"ZZZ\" is invalid"}} 37 | 38 | money = Money.new("ZZZ", "1234") 39 | assert money == {:error, {Money.UnknownCurrencyError, "The currency \"ZZZ\" is invalid"}} 40 | end 41 | 42 | test "create a new! money struct with a binary currency code" do 43 | money = Money.new!(1234, "USD") 44 | assert money.currency == :USD 45 | assert money.amount == Decimal.new(1234) 46 | end 47 | 48 | test "create a new money struct with an atom currency code" do 49 | money = Money.new(1234, :USD) 50 | assert money.currency == :USD 51 | assert money.amount == Decimal.new(1234) 52 | end 53 | 54 | test "create a new! money struct with an atom currency code" do 55 | money = Money.new!(1234, :USD) 56 | assert money.currency == :USD 57 | assert money.amount == Decimal.new(1234) 58 | end 59 | 60 | test "create a new money struct with a binary currency code with reversed params" do 61 | money = Money.new("USD", 1234) 62 | assert money.currency == :USD 63 | assert money.amount == Decimal.new(1234) 64 | end 65 | 66 | test "create a new money struct with a atom currency code with reversed params" do 67 | money = Money.new(:USD, 1234) 68 | assert money.currency == :USD 69 | assert money.amount == Decimal.new(1234) 70 | end 71 | 72 | test "create a new money struct with a lower case binary currency code with reversed params" do 73 | money = Money.new("usd", 1234) 74 | assert money.currency == :USD 75 | assert money.amount == Decimal.new(1234) 76 | end 77 | 78 | test "create a new currency with a locale to normalise an amount string" do 79 | money = Money.new(:USD, "1.234.567,99", locale: "de") 80 | assert money.currency == :USD 81 | assert money.amount == Decimal.new("1234567.99") 82 | 83 | money = Money.new(:USD, "1,234,567.99", locale: "en") 84 | assert money.currency == :USD 85 | assert money.amount == Decimal.new("1234567.99") 86 | end 87 | 88 | test "create a new money struct from a number and atom currency with bang method" do 89 | money = Money.new!(:USD, 1234) 90 | assert money.currency == :USD 91 | assert money.amount == Decimal.new(1234) 92 | end 93 | 94 | test "create a new money struct from a number and binary currency with bang method" do 95 | money = Money.new!("USD", 1234) 96 | assert money.currency == :USD 97 | assert money.amount == Decimal.new(1234) 98 | end 99 | 100 | test "create a new money struct from a decimal and binary currency with bang method" do 101 | money = Money.new!("USD", Decimal.new(1234)) 102 | assert money.currency == :USD 103 | assert money.amount == Decimal.new(1234) 104 | 105 | money = Money.new!(Decimal.new(1234), "USD") 106 | assert money.currency == :USD 107 | assert money.amount == Decimal.new(1234) 108 | end 109 | 110 | test "create a new money struct from a decimal and atom currency with bang method" do 111 | money = Money.new!(:USD, Decimal.new(1234)) 112 | assert money.currency == :USD 113 | assert money.amount == Decimal.new(1234) 114 | 115 | money = Money.new!(Decimal.new(1234), :USD) 116 | assert money.currency == :USD 117 | assert money.amount == Decimal.new(1234) 118 | end 119 | 120 | test "create a new money struct from an atom currency and a string amount" do 121 | money = Money.new!(:USD, "1234") 122 | assert money.currency == :USD 123 | assert money.amount == Decimal.new(1234) 124 | 125 | money = Money.new!("1234", :USD) 126 | assert money.currency == :USD 127 | assert money.amount == Decimal.new(1234) 128 | end 129 | 130 | test "that creating a money with a NaN is invalid" do 131 | assert Money.new(:USD, "NaN") == 132 | {:error, 133 | {Money.InvalidAmountError, "Invalid money amount. Found Decimal.new(\"NaN\")."}} 134 | 135 | assert Money.new(:USD, "-NaN") == 136 | {:error, 137 | {Money.InvalidAmountError, "Invalid money amount. Found Decimal.new(\"-NaN\")."}} 138 | end 139 | 140 | test "that creating a money with a Inf is invalid" do 141 | assert Money.new(:USD, "Inf") == 142 | {:error, 143 | {Money.InvalidAmountError, "Invalid money amount. Found Decimal.new(\"Infinity\")."}} 144 | 145 | assert Money.new(:USD, "-Inf") == 146 | {:error, 147 | {Money.InvalidAmountError, "Invalid money amount. Found Decimal.new(\"-Infinity\")."}} 148 | end 149 | 150 | test "that creating a money with a string amount that is invalid returns and error" do 151 | assert Money.new(:USD, "2134ff") == 152 | {:error, {Money.Invalid, "Unable to create money from :USD and \"2134ff\""}} 153 | end 154 | 155 | test "raise when creating a new money struct from invalid input" do 156 | assert_raise Money.UnknownCurrencyError, "The currency \"ABCDE\" is invalid", fn -> 157 | Money.new!("ABCDE", 100) 158 | end 159 | 160 | assert_raise Money.UnknownCurrencyError, "The currency \"ABCDE\" is invalid", fn -> 161 | Money.new!(Decimal.new(100), "ABCDE") 162 | end 163 | 164 | assert_raise Money.UnknownCurrencyError, "The currency \"ABCDE\" is invalid", fn -> 165 | Money.new!("ABCDE", Decimal.new(100)) 166 | end 167 | end 168 | 169 | test "create a new money struct with a decimal" do 170 | money = Money.new(:USD, Decimal.new(1234)) 171 | assert money.currency == :USD 172 | assert money.amount == Decimal.new(1234) 173 | 174 | money = Money.new("usd", Decimal.new(1234)) 175 | assert money.currency == :USD 176 | assert money.amount == Decimal.new(1234) 177 | 178 | money = Money.new(Decimal.new(1234), :USD) 179 | assert money.currency == :USD 180 | assert money.amount == Decimal.new(1234) 181 | 182 | money = Money.new(Decimal.new(1234), "usd") 183 | assert money.currency == :USD 184 | assert money.amount == Decimal.new(1234) 185 | end 186 | 187 | test "creating a money struct with an invalid atom currency code returns error tuple" do 188 | assert Money.new(:ZYZ, 100) == 189 | {:error, {Money.UnknownCurrencyError, "The currency :ZYZ is invalid"}} 190 | end 191 | 192 | test "creating a money struct with an invalid binary currency code returns error tuple" do 193 | assert Money.new("ABCD", 100) == 194 | {:error, {Money.UnknownCurrencyError, "The currency \"ABCD\" is invalid"}} 195 | end 196 | 197 | test "string output of money is correctly formatted" do 198 | money = Money.new(1234, :USD) 199 | assert Money.to_string(money) == {:ok, "$1,234.00"} 200 | end 201 | 202 | test "to_string works via protocol" do 203 | money = Money.new(1234, :USD) 204 | assert to_string(money) == "$1,234.00" 205 | end 206 | 207 | # Comment out until the next version that depends on ex_cldr_numbers 2.31.0 208 | # test "to_string! raises if there is an error" do 209 | # money = Money.new(1234, :USD) 210 | # 211 | # assert_raise Cldr.FormatCompileError, fn -> 212 | # Money.to_string!(money, format: "0#") 213 | # end 214 | # end 215 | 216 | test "abs value of a negative value returns positive value" do 217 | assert Money.abs(Money.new(:USD, -100)) == Money.new(:USD, 100) 218 | end 219 | 220 | test "abs value of a positive value returns positive value" do 221 | assert Money.abs(Money.new(:USD, 100)) == Money.new(:USD, 100) 222 | end 223 | 224 | test "adding two money structs with same currency" do 225 | assert Money.add!(Money.new(:USD, 100), Money.new(:USD, 100)) == Money.new(:USD, 200) 226 | end 227 | 228 | test "subtracting two money structs with same currency" do 229 | assert Money.sub!(Money.new(:USD, 100), Money.new(:USD, 40)) == Money.new(:USD, 60) 230 | end 231 | 232 | test "adding two money structs with different currency raises" do 233 | assert_raise ArgumentError, ~r/Cannot add monies/, fn -> 234 | Money.add!(Money.new(:USD, 100), Money.new(:AUD, 100)) 235 | end 236 | end 237 | 238 | test "subtracting two money structs with different currency raises" do 239 | assert_raise ArgumentError, ~r/Cannot subtract two monies/, fn -> 240 | Money.sub!(Money.new(:USD, 100), Money.new(:AUD, 100)) 241 | end 242 | end 243 | 244 | test "cash flows with different currencies raises" do 245 | flows = [{1, Money.new(:USD, 100)}, {2, Money.new(:AUD, 100)}] 246 | 247 | assert_raise ArgumentError, ~r/More than one currency found in cash flows/, fn -> 248 | Money.Financial.present_value(flows, 0.12) 249 | end 250 | 251 | assert_raise ArgumentError, ~r/More than one currency found in cash flows/, fn -> 252 | Money.Financial.future_value(flows, 0.12) 253 | end 254 | 255 | assert_raise ArgumentError, ~r/More than one currency found in cash flows/, fn -> 256 | Money.Financial.net_present_value(flows, 0.12, Money.new(:EUR, 100)) 257 | end 258 | end 259 | 260 | test "multiply a money by an integer" do 261 | assert Money.mult!(Money.new(:USD, 100), 2) == Money.new(:USD, 200) 262 | end 263 | 264 | test "multiply a money by an decimal" do 265 | assert Money.mult!(Money.new(:USD, 100), Decimal.new(2)) == Money.new(:USD, 200) 266 | end 267 | 268 | test "multiply a money by a float" do 269 | m1 = Money.mult!(Money.new(:USD, 100), 2.5) 270 | m2 = Money.new(:USD, 250) 271 | assert Money.equal?(m1, m2) == true 272 | end 273 | 274 | test "multiple a money by something that raises an exception" do 275 | assert_raise ArgumentError, ~r/Cannot multiply money by/, fn -> 276 | Money.mult!(Money.new(:USD, 100), :invalid) 277 | end 278 | end 279 | 280 | test "divide a money by an integer" do 281 | assert Money.div!(Money.new(:USD, 100), 2) == Money.new(:USD, 50) 282 | end 283 | 284 | test "divide a money by an decimal" do 285 | assert Money.div!(Money.new(:USD, 100), Decimal.new(2)) == Money.new(:USD, 50) 286 | end 287 | 288 | test "divide a money by a float" do 289 | m1 = Money.div!(Money.new(:USD, 100), 2.5) 290 | m2 = Money.new(:USD, 40) 291 | assert Money.equal?(m1, m2) == true 292 | end 293 | 294 | test "divide a money by something that raises an exception" do 295 | assert_raise ArgumentError, ~r/Cannot divide money by/, fn -> 296 | Money.div!(Money.new(:USD, 100), :invalid) 297 | end 298 | end 299 | 300 | test "Two %Money{} with different currencies are not equal" do 301 | m1 = Money.new(:USD, 250) 302 | m2 = Money.new(:JPY, 250) 303 | assert Money.equal?(m1, m2) == false 304 | end 305 | 306 | test "Split %Money{} into 4 equal parts" do 307 | m1 = Money.new(:USD, 100) 308 | {m2, m3} = Money.split(m1, 4) 309 | assert Money.compare(m2, Money.new(:USD, 25)) == :eq 310 | assert Money.compare(m3, Money.new(:USD, 0)) == :eq 311 | end 312 | 313 | test "Split %Money{} into 3 equal parts" do 314 | m1 = Money.new(:USD, 100) 315 | {m2, m3} = Money.split(m1, 3) 316 | assert Money.compare(m2, Money.new(:USD, Decimal.new("33.33"))) == :eq 317 | assert Money.compare(m3, Money.new(:USD, Decimal.new("0.01"))) == :eq 318 | end 319 | 320 | property "check that money split sums to the original value" do 321 | check all({money, splits} <- GenerateSplits.generate_money(), max_runs: 1_000) do 322 | {split_amount, remainder} = Money.split(money, splits) 323 | reassemble = Money.mult!(split_amount, splits) |> Money.add!(remainder) 324 | assert Money.compare(reassemble, money) == :eq 325 | end 326 | end 327 | 328 | test "Test successful money compare" do 329 | m1 = Money.new(:USD, 100) 330 | m2 = Money.new(:USD, 200) 331 | m3 = Money.new(:USD, 100) 332 | assert Money.compare(m1, m2) == :lt 333 | assert Money.compare(m2, m1) == :gt 334 | assert Money.compare(m1, m3) == :eq 335 | end 336 | 337 | test "Test money compare!" do 338 | m1 = Money.new(:USD, 100) 339 | m2 = Money.new(:USD, 200) 340 | m3 = Money.new(:USD, 100) 341 | assert Money.compare!(m1, m2) == :lt 342 | assert Money.compare!(m2, m1) == :gt 343 | assert Money.compare!(m1, m3) == :eq 344 | end 345 | 346 | test "cmp! raises an exception" do 347 | assert_raise ArgumentError, ~r/Cannot compare monies with different currencies/, fn -> 348 | Money.cmp!(Money.new(:USD, 100), Money.new(:AUD, 25)) 349 | end 350 | end 351 | 352 | test "Test successul money cmp" do 353 | m1 = Money.new(:USD, 100) 354 | m2 = Money.new(:USD, 200) 355 | m3 = Money.new(:USD, 100) 356 | assert Money.cmp(m1, m2) == -1 357 | assert Money.cmp(m2, m1) == 1 358 | assert Money.cmp(m1, m3) == 0 359 | end 360 | 361 | test "Test money cmp!" do 362 | m1 = Money.new(:USD, 100) 363 | m2 = Money.new(:USD, 200) 364 | m3 = Money.new(:USD, 100) 365 | assert Money.cmp!(m1, m2) == -1 366 | assert Money.cmp!(m2, m1) == 1 367 | assert Money.cmp!(m1, m3) == 0 368 | end 369 | 370 | test "compare! raises an exception" do 371 | assert_raise ArgumentError, ~r/Cannot compare monies with different currencies/, fn -> 372 | Money.compare!(Money.new(:USD, 100), Money.new(:AUD, 25)) 373 | end 374 | end 375 | 376 | test "Money is rounded according to currency definition for USD" do 377 | assert Money.round(Money.new(:USD, "123.456")) == Money.new(:USD, "123.46") 378 | end 379 | 380 | test "Money is rounded according to currency definition for JPY" do 381 | assert Money.round(Money.new(:JPY, "123.456")) == Money.new(:JPY, 123) 382 | end 383 | 384 | test "Money is rounded according to currency definition for CHF" do 385 | assert Money.round(Money.new(:CHF, "123.456")) == Money.new(:CHF, "123.46") 386 | end 387 | 388 | test "Money is rounded according to currency cash definition for CHF" do 389 | assert Money.round(Money.new(:CHF, "123.456"), currency_digits: :cash) == 390 | Money.new(:CHF, "123.45") 391 | 392 | assert Money.round(Money.new(:CHF, "123.41"), currency_digits: :cash) == 393 | Money.new(:CHF, "123.40") 394 | 395 | assert Money.round(Money.new(:CHF, "123.436"), currency_digits: :cash) == 396 | Money.new(:CHF, "123.45") 397 | end 398 | 399 | test "Extract decimal from money" do 400 | assert Money.to_decimal(Money.new(:USD, 1234)) == Decimal.new(1234) 401 | end 402 | 403 | test "Calculate irr with one outflow" do 404 | flows = [ 405 | {1, Money.new(:USD, -123_400)}, 406 | {2, Money.new(:USD, 36200)}, 407 | {3, Money.new(:USD, 54800)}, 408 | {4, Money.new(:USD, 48100)} 409 | ] 410 | 411 | assert Float.round(Financial.internal_rate_of_return(flows), 4) == 0.0596 412 | end 413 | 414 | test "Calculate irr with two outflows" do 415 | flows = [ 416 | {0, Money.new(:USD, -1000)}, 417 | {1, Money.new(:USD, -4000)}, 418 | {2, Money.new(:USD, 5000)}, 419 | {3, Money.new(:USD, 2000)} 420 | ] 421 | 422 | assert Float.round(Financial.internal_rate_of_return(flows), 4) == 0.2548 423 | end 424 | 425 | test "money conversion" do 426 | rates = %{USD: Decimal.new(1), AUD: Decimal.new(2)} 427 | assert Money.to_currency(Money.new(:USD, 100), :AUD, rates) == {:ok, Money.new(:AUD, 200)} 428 | end 429 | 430 | test "money conversion with binary to_currency that is the same as from currency" do 431 | rates = %{USD: Decimal.new("0.3"), AUD: Decimal.new(2)} 432 | assert Money.to_currency(Money.new(:USD, 100), "USD", rates) == {:ok, Money.new(:USD, 100)} 433 | end 434 | 435 | test "money conversion with digital_token" do 436 | rates = %{:USD => Decimal.new(50_000), "4H95J0R2X" => Decimal.new(1)} 437 | 438 | assert Money.to_currency(Money.new(:USD, 50_000), "4H95J0R2X", rates) == 439 | {:ok, Money.new("4H95J0R2X", "1.00000")} 440 | 441 | assert Money.to_currency(Money.new("4H95J0R2X", 1), :USD, rates) == 442 | {:ok, Money.new(:USD, 50_000)} 443 | end 444 | 445 | test "money to_string" do 446 | assert Money.to_string(Money.new(:USD, 100)) == {:ok, "$100.00"} 447 | end 448 | 449 | test "create money with a sigil" do 450 | import Money.Sigil 451 | m = ~M[100]USD 452 | assert m == Money.new!(:USD, 100) 453 | end 454 | 455 | test "raise when a sigil function has an invalid currency" do 456 | assert_raise Money.UnknownCurrencyError, ~r/The currency .* is invalid/, fn -> 457 | Money.Sigil.sigil_M("42", [?A, ?A, ?A]) 458 | end 459 | end 460 | 461 | test "raise when a sigil has an invalid currency" do 462 | import Money.Sigil 463 | 464 | assert_raise Money.UnknownCurrencyError, ~r/The currency .* is invalid/, fn -> 465 | ~M[42]ABD 466 | end 467 | end 468 | 469 | test "that we get a deprecation message if we use :exchange_rate_service keywork option" do 470 | Application.put_env(:ex_money, :exchange_rate_service, true) 471 | 472 | assert capture_log(fn -> 473 | Money.Application.maybe_log_deprecation() 474 | end) =~ "Configuration option :exchange_rate_service is deprecated" 475 | end 476 | 477 | test "that we get a deprecation message if we use :delay_before_first_retrieval keywork option" do 478 | Application.put_env(:ex_money, :delay_before_first_retrieval, 300) 479 | 480 | assert capture_log(fn -> 481 | Money.Application.maybe_log_deprecation() 482 | end) =~ "Configuration option :delay_before_first_retrieval is deprecated" 483 | end 484 | 485 | test "the integer and exponent for a number with more than the required decimal places" do 486 | m = Money.new(:USD, "200.012356") 487 | assert Money.to_integer_exp(m) == {:USD, 20001, -2, Money.new(:USD, "0.002356")} 488 | end 489 | 490 | test "the integer and exponent for a number with no decimal places" do 491 | m = Money.new(:USD, "200.00") 492 | assert Money.to_integer_exp(m) == {:USD, 20000, -2, Money.new(:USD, "0.00")} 493 | end 494 | 495 | test "the integer and exponent for a number with one less than the required decimal places" do 496 | m = Money.new(:USD, "200.1") 497 | assert Money.to_integer_exp(m) == {:USD, 20010, -2, Money.new(:USD, "0.0")} 498 | end 499 | 500 | test "the integer and exponent for a currency with no decimal places" do 501 | m = Money.new(:JPY, "200.1") 502 | assert Money.to_integer_exp(m) == {:JPY, 200, 0, Money.new(:JPY, "0.1")} 503 | end 504 | 505 | test "the integer and exponent for a currency with three decimal places" do 506 | m = Money.new(:JOD, "200.1") 507 | assert Money.to_integer_exp(m) == {:JOD, 200_100, -3, Money.new(:JOD, "0.0")} 508 | 509 | m = Money.new(:JOD, "200.1234") 510 | assert Money.to_integer_exp(m) == {:JOD, 200_123, -3, Money.new(:JOD, "0.0004")} 511 | 512 | m = Money.new(:JOD, 200) 513 | assert Money.to_integer_exp(m) == {:JOD, 200_000, -3, Money.new(:JOD, 0)} 514 | end 515 | 516 | test "that the Phoenix.HTML.Safe protocol returns the correct result" do 517 | assert Phoenix.HTML.Safe.to_iodata(Money.new(:USD, 100)) == "$100.00" 518 | end 519 | 520 | test "that we use iso digits as default for to_integer_exp" do 521 | assert Money.to_integer_exp(Money.new(:COP, 1234)) == {:COP, 123_400, -2, Money.new(:COP, 0)} 522 | end 523 | 524 | test "that we can use accounting digits for to_integer_exp" do 525 | assert Money.to_integer_exp(Money.new(:COP, 1234), currency_digits: :accounting) == 526 | {:COP, 123_400, -2, Money.new(:COP, 0)} 527 | end 528 | 529 | test "that to_integer_exp handles negative amounts" do 530 | assert Money.to_integer_exp(Money.new(:USD, -1234)) == {:USD, -123_400, -2, Money.new(:USD, 0)} 531 | end 532 | 533 | test "json encoding for Jason" do 534 | assert Jason.encode( 535 | Money.new("0.0020", :USD) == {:ok, "{\"currency\":\"USD\",\"amount\":\"0.0020\"}"} 536 | ) 537 | end 538 | 539 | test "json encoding for Poison" do 540 | assert Poison.encode( 541 | Money.new("0.0020", :USD) == {:ok, "{\"currency\":\"USD\",\"amount\":\"0.0020\"}"} 542 | ) 543 | end 544 | 545 | test "an exception is raised if no default backend" do 546 | backend = Application.get_env(:ex_money, :default_cldr_backend) 547 | Application.put_env(:ex_money, :default_cldr_backend, nil) 548 | 549 | assert_raise Cldr.NoDefaultBackendError, fn -> 550 | Cldr.default_backend!() 551 | end 552 | 553 | Application.put_env(:ex_money, :default_cldr_backend, backend) 554 | end 555 | 556 | test "that cldr default backend is used if there is no money default backend" do 557 | money_backend = Application.get_env(:ex_money, :default_cldr_backend) 558 | cldr_backend = Application.get_env(:ex_cldr, :default_backend) 559 | Application.put_env(:ex_money, :default_cldr_backend, nil) 560 | Application.put_env(:ex_cldr, :default_backend, Test.Cldr) 561 | 562 | assert Money.default_backend!() == Test.Cldr 563 | 564 | Application.put_env(:ex_money, :default_cldr_backend, money_backend) 565 | Application.put_env(:ex_cldr, :default_backend, cldr_backend) 566 | end 567 | 568 | test "that format options propagate through operations" do 569 | format_options = [fractional_digits: 4] 570 | money = Money.new!(:USD, 100) 571 | money_with_options = Money.new!(:USD, 100, format_options) 572 | 573 | assert Money.add!(money, money_with_options).format_options == format_options 574 | assert Money.sub!(money, money_with_options).format_options == format_options 575 | assert Money.mult!(money_with_options, 3).format_options == format_options 576 | assert Money.div!(money_with_options, 3).format_options == format_options 577 | end 578 | 579 | test "check if Money is positive" do 580 | assert Money.positive?(Money.new(:USD, 1)) 581 | 582 | refute Money.positive?(Money.new(:USD, 0)) 583 | refute Money.positive?(Money.new(:USD, -1)) 584 | end 585 | 586 | test "check if Money is negative" do 587 | assert Money.negative?(Money.new(:USD, -1)) 588 | 589 | refute Money.negative?(Money.new(:USD, 0)) 590 | refute Money.negative?(Money.new(:USD, 1)) 591 | end 592 | 593 | test "check if Money is zero" do 594 | assert Money.zero?(Money.new(:USD, 0)) 595 | 596 | refute Money.zero?(Money.new(:USD, 1)) 597 | refute Money.zero?(Money.new(:USD, -1)) 598 | end 599 | 600 | test "to_string/2 on a Money that doesn't have a :format_options key" do 601 | money = %{__struct__: Money, amount: Decimal.new(3), currency: :USD} 602 | assert Money.to_string(money) == {:ok, "$3.00"} 603 | end 604 | 605 | test "from_integer/3" do 606 | assert Money.from_integer(100, :USD, fractional_digits: 1) == Money.new(:USD, "10.0") 607 | assert Money.from_integer(100, :USD, fractional_digits: 0) == Money.new(:USD, "100") 608 | assert Money.from_integer(100, :USD, fractional_digits: :iso) == Money.new(:USD, "1.00") 609 | assert Money.from_integer(100, :USD, fractional_digits: :cash) == Money.new(:USD, "1.00") 610 | assert Money.from_integer(100, :USD, fractional_digits: :accounting) == Money.new(:USD, "1.00") 611 | 612 | assert Money.from_integer(100, :USD, fractional_digits: :rubbish) == 613 | {:error, 614 | {Money.InvalidDigitsError, 615 | "Unknown or invalid :currency_digits option, found: :rubbish"}} 616 | end 617 | 618 | if Version.compare(System.version(), "1.18.0") in [:gt, :eq] do 619 | test "new/3 with separators specified" do 620 | assert Money.new(:USD, "30,000", locale: :en_ZA, separators: :us) == Money.new(:USD, "30000") 621 | 622 | assert Money.parse("US$30\u00A0000,00", locale: :en_ZA, separators: :us) == 623 | {:error, {Money.Invalid, "Unable to create money from :USD and \"30\\u00A0000,00\""}} 624 | end 625 | end 626 | end 627 | -------------------------------------------------------------------------------- /test/money_token_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.DigitalToken.Test do 2 | use ExUnit.Case, async: true 3 | 4 | test "Creating digital token money" do 5 | assert %Money{} = Money.new("BTC", "100") 6 | assert %Money{} = Money.new("ETH", 100) 7 | assert %Money{} = Money.new("Terra", 100) 8 | assert %Money{} = Money.new("4H95J0R2X", "100.234235") 9 | end 10 | 11 | test "Formatting digital token" do 12 | assert {:ok, "₿ 100.234235"} = Money.to_string(Money.new("BTC", "100.234235")) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/performance_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MoneyTest.Performance do 2 | use ExUnit.Case 3 | 4 | # This is fixed to a single machine spec which is not good. 5 | # A proper approach is required. 6 | 7 | # @acceptable_limit 76_000 8 | # @acceptable_tolerance 0.02 9 | # @iterations 1000 10 | # @acceptable_range trunc(@acceptable_limit * (1 - @acceptable_tolerance)).. 11 | # trunc(@acceptable_limit * (1 + @acceptable_tolerance)) 12 | # 13 | # test "that performance on rounding hasn't degraded" do 14 | # m = Money.new(:USD, "2.04") 15 | # {millseconds, :ok} = :timer.tc(fn -> 16 | # Enum.each(1..@iterations, fn _x -> 17 | # Money.round(m) 18 | # end) 19 | # end) 20 | # assert millseconds in @acceptable_range 21 | # end 22 | end 23 | -------------------------------------------------------------------------------- /test/protocol_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.Protocol.Test do 2 | use ExUnit.Case 3 | 4 | test "Money with format options with String.Chars protocol" do 5 | assert to_string(Money.new!(:USD, 100, fractional_digits: 4)) == "$100.0000" 6 | end 7 | 8 | test "Money with format options with Cldr.Chars protocol" do 9 | assert Cldr.to_string(Money.new!(:USD, 100, fractional_digits: 4)) == "$100.0000" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/split_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.SplitTest do 2 | use ExUnit.Case 3 | use ExUnitProperties 4 | 5 | property "check Money.split/3 always generates a non-negative remainder" do 6 | check all( 7 | amount <- StreamData.float(min: 0.01, max: 9999.99), 8 | parts <- StreamData.integer(2..10), 9 | rounding <- StreamData.integer(0..7), 10 | max_runs: 1_000) do 11 | money = Money.from_float(:USD, Float.round(amount, rounding)) 12 | {split, remainder} = Money.split(money, parts) 13 | assert Money.compare(remainder, Money.zero(:USD)) in [:gt, :eq] 14 | assert Money.compare(Money.add!(Money.mult!(split, parts), remainder), money) == :eq 15 | end 16 | end 17 | 18 | end -------------------------------------------------------------------------------- /test/subscription_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MoneySubscriptionTest do 2 | use ExUnit.Case 3 | alias Money.Subscription.Plan 4 | alias Money.Subscription.Change 5 | alias Money.Subscription 6 | 7 | doctest Money.Subscription 8 | doctest Money.Subscription.Plan 9 | 10 | test "plan change at end of period has no credit and correct billing dates" do 11 | p1 = %{interval: :day, interval_count: 30, price: Money.new(:USD, 100)} 12 | p2 = %{interval: :day, interval_count: 30, price: Money.new(:USD, 200)} 13 | 14 | {:ok, changeset} = 15 | Money.Subscription.change_plan(p1, p2, current_interval_started: ~D[2018-03-01]) 16 | 17 | assert changeset.first_billing_amount == Money.new(:USD, 200) 18 | assert changeset.first_interval_starts == ~D[2018-03-31] 19 | assert changeset.next_interval_starts == ~D[2018-04-30] 20 | end 21 | 22 | test "plan change on January 31th should preserve 31th even past february" do 23 | p1 = %{interval: :month, interval_count: 1, price: Money.new(:USD, 100)} 24 | p2 = %{interval: :month, interval_count: 1, price: Money.new(:USD, 200)} 25 | 26 | {:ok, changeset} = 27 | Money.Subscription.change_plan( 28 | p1, 29 | p2, 30 | current_interval_started: ~D[2018-01-30], 31 | first_interval_started: ~D[2017-12-31] 32 | ) 33 | 34 | assert changeset.first_billing_amount == Money.new(:USD, 200) 35 | assert changeset.first_interval_starts == ~D[2018-02-28] 36 | assert changeset.next_interval_starts == ~D[2018-03-31] 37 | end 38 | 39 | test "plan change at 50% of period has no credit and correct billing dates" do 40 | p1 = %{interval: :day, interval_count: 30, price: Money.new(:USD, 100)} 41 | p2 = %{interval: :day, interval_count: 30, price: Money.new(:USD, 200)} 42 | 43 | {:ok, changeset} = 44 | Money.Subscription.change_plan( 45 | p1, 46 | p2, 47 | current_interval_started: ~D[2018-03-01], 48 | effective: ~D[2018-03-16] 49 | ) 50 | 51 | assert changeset.first_billing_amount == Money.new(:USD, "150.00") 52 | assert changeset.first_interval_starts == ~D[2018-03-16] 53 | assert changeset.next_interval_starts == ~D[2018-04-15] 54 | assert changeset.credit_amount_applied == Money.new(:USD, "50.00") 55 | 56 | assert Money.compare!( 57 | Money.add!(changeset.credit_amount_applied, changeset.first_billing_amount), 58 | p2.price 59 | ) == :eq 60 | end 61 | 62 | test "should get days to add to subscription upgrade" do 63 | today = ~D[2018-02-14] 64 | 65 | old = %{ 66 | price: Money.new(:CHF, Decimal.new(130)), 67 | interval: :month, 68 | interval_count: 3 69 | } 70 | 71 | new = %{ 72 | price: Money.new(:CHF, Decimal.new(180)), 73 | interval: :month, 74 | interval_count: 6 75 | } 76 | 77 | assert {:ok, 78 | %Change{ 79 | carry_forward: Money.zero(:CHF), 80 | credit_amount: Money.new(:CHF, "67.20"), 81 | credit_amount_applied: Money.zero(:CHF), 82 | credit_days_applied: 68, 83 | credit_period_ends: ~D[2018-04-22], 84 | next_interval_starts: ~D[2018-10-21], 85 | first_billing_amount: new.price, 86 | first_interval_starts: today 87 | }} == 88 | Money.Subscription.change_plan( 89 | old, 90 | new, 91 | current_interval_started: ~D[2018-01-01], 92 | effective: today, 93 | prorate: :period 94 | ) 95 | end 96 | 97 | test "should get days to add to subscription upgrade when upgrading the same day" do 98 | today = ~D[2018-01-01] 99 | 100 | old = %{ 101 | price: Money.new(:CHF, Decimal.new(130)), 102 | interval: :month, 103 | interval_count: 3 104 | } 105 | 106 | new = %{ 107 | price: Money.new(:CHF, Decimal.new(180)), 108 | interval: :month, 109 | interval_count: 6 110 | } 111 | 112 | assert {:ok, 113 | %Change{ 114 | carry_forward: Money.zero(:CHF), 115 | credit_amount: Money.new(:CHF, Decimal.new("130.00")), 116 | credit_amount_applied: Money.zero(:CHF), 117 | credit_days_applied: 131, 118 | credit_period_ends: ~D[2018-05-11], 119 | next_interval_starts: ~D[2018-11-09], 120 | first_billing_amount: new.price, 121 | first_interval_starts: today 122 | }} == 123 | Money.Subscription.change_plan( 124 | old, 125 | new, 126 | current_interval_started: today, 127 | effective: today, 128 | prorate: :period 129 | ) 130 | end 131 | 132 | test "should get at least 1 day when subscription upgrade is from a very low subscription to a very high one" do 133 | today = ~D[2018-01-14] 134 | 135 | old = Money.Subscription.Plan.new!(Money.new(:CHF, Decimal.new("0.5")), :month) 136 | new = Money.Subscription.Plan.new!(Money.new(:CHF, Decimal.new(1000)), :month, 36) 137 | 138 | assert {:ok, 139 | %Change{ 140 | carry_forward: Money.zero(:CHF), 141 | credit_amount: Money.new(:CHF, "0.30"), 142 | credit_amount_applied: Money.zero(:CHF), 143 | credit_days_applied: 1, 144 | credit_period_ends: ~D[2018-01-14], 145 | next_interval_starts: ~D[2021-01-15], 146 | first_billing_amount: new.price, 147 | first_interval_starts: today 148 | }} == 149 | Money.Subscription.change_plan( 150 | old, 151 | new, 152 | current_interval_started: ~D[2018-01-01], 153 | effective: today, 154 | prorate: :period 155 | ) 156 | end 157 | 158 | test "that a carry forward is generated when credit is greater than price" do 159 | p1 = Plan.new!(Money.new(:USD, 1000), :day, 20) 160 | p2 = Plan.new!(Money.new(:USD, 10), :day, 10) 161 | 162 | changeset = 163 | Subscription.change_plan( 164 | p1, 165 | p2, 166 | current_interval_started: ~D[2018-01-01], 167 | effective: ~D[2018-01-05] 168 | ) 169 | 170 | assert changeset == 171 | {:ok, 172 | %Change{ 173 | carry_forward: Money.new(:USD, "-790.00"), 174 | credit_amount: Money.new(:USD, "800.00"), 175 | credit_amount_applied: Money.new(:USD, "10.00"), 176 | credit_days_applied: 0, 177 | credit_period_ends: nil, 178 | next_interval_starts: ~D[2018-01-15], 179 | first_billing_amount: Money.zero(:USD), 180 | first_interval_starts: ~D[2018-01-05] 181 | }} 182 | end 183 | 184 | test "that month rollover works at end of month when next month is shorter" do 185 | assert Money.Subscription.next_interval_starts( 186 | %{interval: :month, interval_count: 1}, 187 | ~D[2018-01-31] 188 | ) == ~D[2018-02-28] 189 | end 190 | 191 | @tag :sub 192 | test "That we can create a subscription" do 193 | assert {:ok, _s1} = Subscription.new(Plan.new!(Money.new(:USD, 200), :month, 3), ~D[2018-01-01]) 194 | end 195 | 196 | @tag :change 197 | test "We can change plan in a subscription" do 198 | p1 = Plan.new!(Money.new(:USD, 200), :month, 3) 199 | p2 = Plan.new!(Money.new(:USD, 200), :day, 90) 200 | today = ~D[2018-01-15] 201 | 202 | s1 = Subscription.new!(p1, ~D[2018-01-01]) 203 | c1 = Subscription.change_plan!(s1, p2, today: today) 204 | 205 | assert c1.plans == 206 | [ 207 | {%Money.Subscription.Change{ 208 | carry_forward: Money.zero(:USD), 209 | credit_amount: Money.zero(:USD), 210 | credit_amount_applied: Money.zero(:USD), 211 | credit_days_applied: 0, 212 | credit_period_ends: nil, 213 | first_billing_amount: Money.new(:USD, 200), 214 | first_interval_starts: ~D[2018-04-01], 215 | next_interval_starts: ~D[2018-06-30] 216 | }, 217 | %Money.Subscription.Plan{ 218 | interval: :day, 219 | interval_count: 90, 220 | price: Money.new(:USD, 200) 221 | }}, 222 | {%Money.Subscription.Change{ 223 | carry_forward: Money.zero(:USD), 224 | credit_amount: Money.zero(:USD), 225 | credit_amount_applied: Money.zero(:USD), 226 | credit_days_applied: 0, 227 | credit_period_ends: nil, 228 | first_billing_amount: Money.new(:USD, 200), 229 | first_interval_starts: ~D[2018-01-01], 230 | next_interval_starts: ~D[2018-04-01] 231 | }, 232 | %Money.Subscription.Plan{ 233 | interval: :month, 234 | interval_count: 3, 235 | price: Money.new(:USD, 200) 236 | }} 237 | ] 238 | 239 | # Confirm we can't add a second pending plan 240 | change_2 = Subscription.change_plan(c1, p1, today: today) 241 | 242 | assert {:error, 243 | {Subscription.PlanPending, "Can't change plan when a new plan is already pending"}} == 244 | change_2 245 | end 246 | 247 | test "We can detect a pending plan" do 248 | p1 = Plan.new!(Money.new(:USD, 200), :month, 3) 249 | p2 = Plan.new!(Money.new(:USD, 200), :day, 90) 250 | 251 | s1 = Subscription.new!(p1, ~D[2018-01-01]) 252 | c1 = Subscription.change_plan!(s1, p2) 253 | 254 | assert Subscription.plan_pending?(c1) == true 255 | end 256 | 257 | test "we can get current and latest plan" do 258 | p1 = Plan.new!(Money.new(:USD, 200), :month, 3) 259 | p2 = Plan.new!(Money.new(:USD, 200), :day, 90) 260 | 261 | s1 = Subscription.new!(p1, ~D[2018-01-01]) 262 | c1 = Subscription.change_plan!(s1, p2) 263 | 264 | {_changes, current} = Subscription.current_plan(c1) 265 | assert current == p1 266 | 267 | {_changes, latest} = Subscription.latest_plan(c1) 268 | assert latest == p2 269 | end 270 | 271 | test "current interval start date when the plan's starts earlier than today" do 272 | today = ~D[2018-01-10] 273 | start_date = ~D[2017-01-01] 274 | plan = Plan.new!(Money.new(:USD, 100), :month, 1) 275 | 276 | assert Subscription.current_interval_start_date( 277 | {%Change{first_interval_starts: start_date}, plan}, 278 | today: today 279 | ) == ~D[2018-01-01] 280 | end 281 | 282 | test "current interval start date when today is within the plan's first interval" do 283 | today = ~D[2018-01-10] 284 | start_date = ~D[2018-01-01] 285 | plan = Plan.new!(Money.new(:USD, 100), :month, 1) 286 | 287 | assert Subscription.current_interval_start_date( 288 | {%Change{first_interval_starts: start_date}, plan}, 289 | today: today 290 | ) == ~D[2018-01-01] 291 | end 292 | 293 | test "current interval start date when today is earlier than the plan's start date" do 294 | today = ~D[2018-01-10] 295 | start_date = ~D[2019-01-01] 296 | plan = Plan.new!(Money.new(:USD, 100), :month, 1) 297 | 298 | assert Subscription.current_interval_start_date( 299 | {%Change{first_interval_starts: start_date}, plan}, 300 | today: today 301 | ) == 302 | {:error, 303 | {Subscription.NoCurrentPlan, "The plan is not current for #{inspect(start_date)}"}} 304 | end 305 | end 306 | -------------------------------------------------------------------------------- /test/support/exchange_rate_callback_module.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.ExchangeRates.CallbackTest do 2 | @behaviour Money.ExchangeRates.Callback 3 | 4 | def init do 5 | :ok 6 | end 7 | 8 | def latest_rates_retrieved(_rates, _retrieved_at) do 9 | Application.put_env(:ex_money, :test, "Latest Rates Retrieved") 10 | :ok 11 | end 12 | 13 | def historic_rates_retrieved(_rates, _date) do 14 | Application.put_env(:ex_money, :test, "Historic Rates Retrieved") 15 | :ok 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/support/exchange_rate_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.ExchangeRatesTestHelper do 2 | alias Money.ExchangeRates 3 | 4 | def start_service do 5 | ExchangeRates.Retriever.start_link(ExchangeRates.Retriever, ExchangeRates.config()) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/support/exchange_rate_mock.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.ExchangeRates.Api.Test do 2 | @behaviour Money.ExchangeRates 3 | 4 | @app_id "app_id" 5 | @exr_url "https://openexchangerates.org/api" 6 | 7 | @latest_endpoint "/latest.json" 8 | @latest_url @exr_url <> @latest_endpoint <> "?app_id=" <> @app_id 9 | def init(config) do 10 | url = @latest_url 11 | app_id = @app_id 12 | 13 | Map.put(config, :retriever_options, %{url: url, app_id: app_id}) 14 | end 15 | 16 | def decode_rates(rates) do 17 | Money.ExchangeRates.OpenExchangeRates.decode_rates(rates) 18 | end 19 | 20 | def get_latest_rates(_config) do 21 | get_rates(@latest_url) 22 | end 23 | 24 | def get_historic_rates(~D[2017-01-01], _config) do 25 | {:ok, %{AUD: Decimal.new("0.5"), EUR: Decimal.new("1.1"), USD: Decimal.new("0.7")}} 26 | end 27 | 28 | def get_historic_rates(~D[2017-01-02], _config) do 29 | {:ok, %{AUD: Decimal.new("0.4"), EUR: Decimal.new("0.9"), USD: Decimal.new("0.6")}} 30 | end 31 | 32 | def get_historic_rates(~D[2017-02-01], _config) do 33 | {:error, {Money.ExchangeRateError, "No exchange rates for 2017-02-01 were found"}} 34 | end 35 | 36 | defp get_rates("invalid_url") do 37 | {:error, "bad url"} 38 | end 39 | 40 | defp get_rates("http:/something.com/unknown" = url) do 41 | {:error, "#{url} was not found"} 42 | end 43 | 44 | defp get_rates(@latest_url) do 45 | {:ok, %{AUD: Decimal.new("0.7"), EUR: Decimal.new("1.2"), USD: Decimal.new(1)}} 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/support/split_generator.ex: -------------------------------------------------------------------------------- 1 | defmodule GenerateSplits do 2 | require ExUnitProperties 3 | 4 | def generate_money do 5 | ExUnitProperties.gen all( 6 | value <- StreamData.float(min: 0.0, max: 999_999_999_999_999.9), 7 | split <- StreamData.integer(1..101) 8 | ) do 9 | {Money.new(:USD, Float.to_string(value)), split} 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/support/test_cldr.ex: -------------------------------------------------------------------------------- 1 | require Money.Backend 2 | require Money 3 | 4 | defmodule Test.Cldr do 5 | use Cldr, 6 | default_locale: "en", 7 | locales: [ 8 | "en", 9 | "en-ZA", 10 | "de", 11 | "da", 12 | "nl", 13 | "de-CH", 14 | "fr", 15 | "zh-Hant-HK", 16 | "zh-Hans", 17 | "ja", 18 | "es-CO", 19 | "bn", 20 | "ar-MA" 21 | ], 22 | providers: [Cldr.Number, Money], 23 | suppress_warnings: true 24 | end 25 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------