From 0c5c3faa1eef9259c66a8188774afe905941aac2 Mon Sep 17 00:00:00 2001 From: Lovro Bikic Date: Fri, 18 Feb 2022 00:15:30 +0100 Subject: [PATCH] Set up gem --- .github/workflows/ci.yml | 94 ++++ .gitignore | 15 + .rspec | 3 + .rubocop.yml | 28 ++ CHANGELOG.md | 5 + CODE_OF_CONDUCT.md | 84 ++++ Gemfile | 5 + Gemfile.lock | 73 +++ Gemfile_test | 12 + LICENSE | 21 + README.md | 399 ++++++++++++++++ Rakefile | 61 +++ bin/console | 12 + bin/prepare_money_rails_specs | 61 +++ bin/prepare_money_specs | 61 +++ bin/setup | 8 + lib/money_with_date.rb | 20 + .../active_record/class_methods.rb | 9 + .../active_record/monetizable.rb | 34 ++ lib/money_with_date/bank/variable_exchange.rb | 89 ++++ lib/money_with_date/class_methods.rb | 34 ++ lib/money_with_date/hooks.rb | 24 + lib/money_with_date/instance_methods.rb | 76 ++++ lib/money_with_date/railtie.rb | 11 + lib/money_with_date/rates_store/memory.rb | 44 ++ lib/money_with_date/version.rb | 11 + money_with_date.gemspec | 39 ++ .../active_record/class_methods_spec.rb | 26 ++ .../active_record/monetizable_spec.rb | 211 +++++++++ .../bank/variable_exchange_spec.rb | 426 ++++++++++++++++++ spec/money_with_date/class_methods_spec.rb | 85 ++++ spec/money_with_date/hooks_spec.rb | 31 ++ spec/money_with_date/instance_methods_spec.rb | 356 +++++++++++++++ .../rates_store/memory_spec.rb | 70 +++ spec/spec_helper.rb | 29 ++ 35 files changed, 2567 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 .rubocop.yml create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 Gemfile_test create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Rakefile create mode 100755 bin/console create mode 100755 bin/prepare_money_rails_specs create mode 100755 bin/prepare_money_specs create mode 100755 bin/setup create mode 100644 lib/money_with_date.rb create mode 100644 lib/money_with_date/active_record/class_methods.rb create mode 100644 lib/money_with_date/active_record/monetizable.rb create mode 100644 lib/money_with_date/bank/variable_exchange.rb create mode 100644 lib/money_with_date/class_methods.rb create mode 100644 lib/money_with_date/hooks.rb create mode 100644 lib/money_with_date/instance_methods.rb create mode 100644 lib/money_with_date/railtie.rb create mode 100644 lib/money_with_date/rates_store/memory.rb create mode 100644 lib/money_with_date/version.rb create mode 100644 money_with_date.gemspec create mode 100644 spec/money_with_date/active_record/class_methods_spec.rb create mode 100644 spec/money_with_date/active_record/monetizable_spec.rb create mode 100644 spec/money_with_date/bank/variable_exchange_spec.rb create mode 100644 spec/money_with_date/class_methods_spec.rb create mode 100644 spec/money_with_date/hooks_spec.rb create mode 100644 spec/money_with_date/instance_methods_spec.rb create mode 100644 spec/money_with_date/rates_store/memory_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..02d305d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,94 @@ +name: CI + +on: + push: + +env: + BUNDLE_GEMFILE: Gemfile_test + +jobs: + lint: + runs-on: ubuntu-latest + name: rake rubocop + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.0' + bundler-cache: true + - name: Run rubocop + run: bundle exec rake rubocop + test: + runs-on: ubuntu-latest + env: + MONEY_VERSION: ${{ matrix.money }} + MONEY_RAILS_VERSION: ${{ matrix.money_rails }} + RAILS_VERSION: ${{ matrix.rails }} + name: 'rake "spec:unit[${{ matrix.money }},${{ matrix.money_rails }},${{ matrix.rails }}]" (Ruby ${{ matrix.ruby }})' + strategy: + fail-fast: false + matrix: + ruby: ['2.6', '2.7', '3.0', '3.1'] + money: ['6.14.0', '6.14.1', '6.16.0'] + money_rails: ['1.15.0'] + rails: ['5.2.6.2', '6.0.4.6', '6.1.4.6', '7.0.2.2'] + exclude: + - ruby: '3.0' + rails: '5.2.6.2' + - ruby: '3.1' + rails: '5.2.6.2' + - ruby: '2.6' + rails: '7.0.2.2' + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run specs + run: bundle exec rake "spec:unit[${{ matrix.money }},${{ matrix.money_rails }},${{ matrix.rails }}]" + test_money: + runs-on: ubuntu-latest + env: + MONEY_VERSION: ${{ matrix.money }} + name: 'rake "spec:money[${{ matrix.money }}]" (Ruby ${{ matrix.ruby }})' + strategy: + fail-fast: false + matrix: + ruby: ['2.6', '2.7', '3.0'] + money: ['6.14.0', '6.14.1', '6.16.0'] + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run money specs + run: bundle exec rake "spec:money[${{ matrix.money }}]" + test_money_rails: + runs-on: ubuntu-latest + env: + MONEY_RAILS_VERSION: ${{ matrix.money_rails }} + name: 'rake "spec:money_rails[${{ matrix.money_rails }}]" (Ruby ${{ matrix.ruby }})' + strategy: + fail-fast: false + matrix: + ruby: ['2.6', '2.7', '3.0', '3.1'] + money_rails: ['1.15.0'] + mongodb: ['4.4'] + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Start MongoDB + uses: supercharge/mongodb-github-action@1.3.0 + with: + mongodb-version: ${{ matrix.mongodb }} + - name: Run money rails specs + run: bundle exec rake "spec:money_rails[${{ matrix.money_rails }}]" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ea0303 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ +/spec_money/ +/spec_money_rails/ +/vendor/ + +# rspec failure tracking +.rspec_status +Gemfile_test.lock diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c8a3e6c --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--format documentation +--force-color +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..0ba5eae --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,28 @@ +AllCops: + TargetRubyVersion: 2.6 + NewCops: enable + Exclude: + - 'spec_money/**/*' + - 'spec_money_rails/**/*' + - 'vendor/**/*' + +Style/StringLiterals: + Enabled: true + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + Enabled: true + EnforcedStyle: double_quotes + +Style/Documentation: + Enabled: false + +Style/ConditionalAssignment: + Enabled: false + +Metrics/BlockLength: + Exclude: + - 'spec/**/*' + +Layout/LineLength: + Max: 120 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6c4b220 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +## [Unreleased] + +## [0.1.0] - 2022-02-18 + +- Initial release diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..8b9a88d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,84 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at lovro.bikic@gmail.com. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, +available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..be173b2 --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..91e6ec2 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,73 @@ +PATH + remote: . + specs: + money_with_date (0.1.0) + money (>= 6.14.0, <= 6.16.0) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.2) + coderay (1.1.3) + concurrent-ruby (1.1.9) + diff-lcs (1.5.0) + i18n (1.10.0) + concurrent-ruby (~> 1.0) + method_source (1.0.0) + money (6.16.0) + i18n (>= 0.6.4, <= 2) + parallel (1.21.0) + parser (3.1.0.0) + ast (~> 2.4.1) + pry (0.14.1) + coderay (~> 1.1) + method_source (~> 1.0) + rainbow (3.1.1) + rake (13.0.6) + regexp_parser (2.2.1) + rexml (3.2.5) + rspec (3.11.0) + rspec-core (~> 3.11.0) + rspec-expectations (~> 3.11.0) + rspec-mocks (~> 3.11.0) + rspec-core (3.11.0) + rspec-support (~> 3.11.0) + rspec-expectations (3.11.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.11.0) + rspec-mocks (3.11.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.11.0) + rspec-support (3.11.0) + rubocop (1.25.1) + parallel (~> 1.10) + parser (>= 3.1.0.0) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml + rubocop-ast (>= 1.15.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 3.0) + rubocop-ast (1.15.2) + parser (>= 3.0.1.1) + rubocop-rake (0.6.0) + rubocop (~> 1.0) + rubocop-rspec (2.8.0) + rubocop (~> 1.19) + ruby-progressbar (1.11.0) + unicode-display_width (2.1.0) + +PLATFORMS + ruby + +DEPENDENCIES + money_with_date! + pry + rake + rspec + rubocop + rubocop-rake + rubocop-rspec + +BUNDLED WITH + 2.3.7 diff --git a/Gemfile_test b/Gemfile_test new file mode 100644 index 0000000..638e1fe --- /dev/null +++ b/Gemfile_test @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +gem "money", ENV["MONEY_VERSION"] +gem "money-rails", ENV["MONEY_RAILS_VERSION"] +gem "activerecord", ENV["RAILS_VERSION"] +gem "sqlite3" +gem "rubyzip" +gem "simplecov", require: false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e8184d2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Infinum + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5127441 --- /dev/null +++ b/README.md @@ -0,0 +1,399 @@ +# money_with_date + +A Ruby library which extends the popular [money](https://github.com/RubyMoney/money) and [money-rails](https://github.com/RubyMoney/money-rails) gems with support for dated Money objects. + +Dated Money objects are useful in situations where you have to exchange money between currencies based on historical exchange rates, and you'd like to keep date information on the Money object itself. + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'money_with_date' +``` + +And then execute: + + $ bundle install + +Or install it yourself: + + $ gem install money_with_date + +That's it! All your Money objects now have a date attribute. + +## Usage + +```ruby +Money.default_bank = MoneyWithDate::Bank::VariableExchange.new(MoneyWithDate::RatesStore::Memory.new) +# the gem provides subclasses of Money::Bank::VariableExchange and Money::RatesStore::Memory +# which can save historical exchange rates + +Money.add_rate "USD", "EUR", 0.9, "2020-01-01" +Money.add_rate "CHF", "EUR", 0.96, Date.today + +Money.new(100, "EUR", date: Date.today) + Money.new(100, "USD", date: "2020-01-01") +# => Money.new(190, "EUR", date: Date.today) +# the second Money object is exchanged to EUR with the exchange rate on 1/1/2020 + +Money.default_date = -> { Date.today } +# new Money objects use the default date if "date" parameter is omitted in Money.new + +transactions = [ + Money.from_amount(2, "CHF"), + Money.from_amount(1, "USD", date: "2020-01-01"), + Money.from_amount(2, "EUR") +] + +transactions.map { |money| money.exchange_to(:eur) }.sum +# => Money.from_amount(4,82, "EUR") +# 2 CHF == 1,92 EUR on today's date +# 1 USD = 0,9 EUR on 1/1/2020 +# 1,92 + 0,9 + 2 = 4,82 + +# In Rails, a date column can be used to control the date of the Money object: +class Product < ActiveRecord::Base + monetize :amount_cents, with_model_date: :published_on +end + +product = Product.new(amount_cents: 100, published_on: "2020-01-01") + +product.amount.date +# => "2020-01-01" +``` + +When you install and require the gem, all Money objects will automatically have a date attribute. + +The default date for a Money object is read from the configuration option `Money.default_date` (more about that [here](#default_date)). + +You can also override the default date when creating a Money object: +```ruby +money = Money.new(100, :usd, date: "2020-01-01") + +money.date # => "2020-01-01" +``` + +The type of the `date` param should either be a Date object or something which can be coerced into Date (e.g. Time object, a string which can be parsed as date). The following examples are all valid ways of setting a date: +```ruby +Money.new(100, :usd, date: Date.today) + +Money.new(100, :usd, date: Time.now) # converted to Date with the `#to_date` method + +Money.new(100, :usd, date: "2022-02-15") # converted to Date with `Date.parse` +``` + +`date` argument which cannot be coerced into a Date will result in an `ArgumentError`. + +It is also possible to copy Money objects and change their date with `Money#with_date`: +```ruby +money = Money.from_cents(100, :eur, date: "2022-02-15") + +new_money = money.with_date("2020-01-01") + +new_money.cents # => 100 +new_money.date # => "2020-01-01" +money.date # => "2022-02-15" (the original object is unchanged) +``` + +In addition to `Money.new`, the `date` param can also be passed to the following methods: +```ruby +Money.from_amount(10.00, :usd, date: Date.today) + +Money.from_cents(1000, :usd, date: Date.today) +``` + +### Currency Exchange + +This gem supports all existing bank implementations which do not support historical exchange rates. By default, when you require `money_with_date` in your project, existing currency exchange logic won't be affected. + +To enable historical currency exchanges, the bank and rates store objects you use must support an additional `date` param in order to associate an exchange rate with a specific date. + +This gem provides subclasses of money's default bank and rates store (variable exchange, in-memory store), which have been extended to support historical exchange rates: +```ruby +Money.default_bank = MoneyWithDate::Bank::VariableExchange.new(MoneyWithDate::RatesStore::Memory.new) +``` + +To save a historical exchange rate, use `Money.add_rate` and supply a date param: +```ruby +Money.add_rate "EUR", "USD", 1.1, Date.today +``` + +Currency exchange then works like this: +```ruby +Money.add_rate "EUR", "USD", 1.05, Date.today - 1 +Money.add_rate "EUR", "USD", 1.1, Date.today +Money.add_rate "EUR", "USD", 1.2, Date.today + 1 + +money = Money.new(100, "EUR", date: Date.today + 1) + +money.exchange_to("USD").cents == 120 # => true +``` + +To retrieve a historical exchange rate from a bank, invoke `Bank#get_rate` with a date param: +```ruby +Money.default_bank.get_rate("EUR", "USD", Date.today - 1) # => 1.05 +``` + +The same rules that apply to the date param when initializing a Money object also apply here: it can be a Date object or anything that can be coerced into a Date object (Time, String). + +### Exchange rate stores + +This gem provides an in-memory store for historical exchange rates: `MoneyWithDate::RatesStore::Memory`. This class is very similar to `Money::RatesStore::Memory`, only an additional `date` parameter has been added to methods where necessary. + +You can also implement your own store, but it has to follow this interface: +```ruby +# Add new exchange rate. +# @param [String] iso_from Currency ISO code. ex. 'USD' +# @param [String] iso_to Currency ISO code. ex. 'CAD' +# @param [Numeric] rate Exchange rate. ex. 0.0016 +# @param [Date] date Exchange rate date. ex. Date.today +# +# @return [Numeric] rate. +def add_rate(iso_from, iso_to, rate, date); end + +# Get rate. Must be idempotent. i.e. adding the same rate must not produce duplicates. +# @param [String] iso_from Currency ISO code. ex. 'USD' +# @param [String] iso_to Currency ISO code. ex. 'CAD' +# @param [Date] date Exchange rate date. ex. Date.today +# +# @return [Numeric] rate. +def get_rate(iso_from, iso_to, date); end + +# Iterate over rate tuples (iso_from, iso_to, rate) +# +# @yieldparam iso_from [String] Currency ISO string. +# @yieldparam iso_to [String] Currency ISO string. +# @yieldparam rate [Numeric] Exchange rate. +# @yieldparam date [Date] Exchange rate date. +# +# @return [Enumerator] +# +# @example +# store.each_rate do |iso_from, iso_to, rate, date| +# puts [iso_from, iso_to, rate, date].join +# end +def each_rate(&block); end + +# Wrap store operations in a thread-safe transaction +# (or IO or Database transaction, depending on your implementation) +# +# @yield [n] Block that will be wrapped in transaction. +# +# @example +# store.transaction do +# store.add_rate('USD', 'CAD', 0.9, Date.today) +# store.add_rate('USD', 'CLP', 0.0016, Date.today) +# end +def transaction(&block); end + +# Serialize store and its content to make Marshal.dump work. +# +# Returns an array with store class and any arguments needed to initialize the store in the current state. + +# @return [Array] [class, arg1, arg2] +def marshal_dump; end +``` + +### Usage With Rails + +In case you're using `money-rails`, its `monetize` helper will also be extended to provide options for setting the date on monetized attributes. There are three ways you can set the date on a monetized attribute: + +#### `with_model_date` option + +Use this option when you want to use a table column to set the date on a monetized attribute. For example, if you have a table: +```ruby +# Table name: products +# +# id :integer not null, primary key +# amount_cents :integer not null +# published_on :date +# created_at :timestamp not null +# +``` +and you want to use `published_on` column to set the date on the monetized `amount_cents`, do this: +```ruby +class Product < ActiveRecord::Base + monetize :amount_cents, with_model_date: :published_on +end +``` +which will cause the following: +```ruby +product = Product.new(amount_cents: 100, published_on: "2020-01-01") + +product.amount.date # => "2020-01-01" +``` + +If the value of the `with_model_date` column is `nil`, the date on the Money object will fall back to `Money.default_date`. + +#### `with_date` option + +This option can be used when you want to either hard-code a date for a monetized attribute, or you want to set the date dynamically. If you want to hard-code the date, pass a concrete value: +```ruby +class Product < ActiveRecord::Base + monetize :amount_cents, with_date: "2020-01-01" +end +``` +All `Product#amount` Money objects will now have the same date: 1/1/2020. + +You can also set the date on the Money object dynamically by using a callable: +```ruby +class Product < ActiveRecord::Base + monetize :amount_cents, with_date: ->(product) { product.published_on || product.created_at } +end +``` +The callable should accept a single param: the ActiveRecord object. + +In case the callable resolves to nil, the date on the Money object will fall back to `Money.default_date`. + +#### `Money.default_date_column` + +If you don't supply either of the above options to `monetize`, the gem will search for a default column to set the date on a monetized attribute. By default, this column is `created_at`. That means that if your class looks like this: +```ruby +class Product < ActiveRecord::Base + monetize :amount_cents +end +``` +the following will happen: +```ruby +product = Product.new(amount_cents: 100, created_at: "2020-01-01") + +product.amount.date # => "2020-01-01" +``` + +In case the default column doesn't exist, or its value is nil, the date on the Money object will fall back to `Money.default_date`. + +You can also override the default column, or even disable it, which you can find out how to do [here](#default_date_column). + +## Configuration + +The gem provides a couple of configuration options. + +### default_date + +With the `money_with_date` gem installed, _all_ Money objects have a date. If you don't supply a date when creating a Money object, a default date will be assigned. + +You can override the default date: +```ruby +Money.default_date = -> { Date.today } +``` +and fetch it like this: +```ruby +Money.default_date # outputs today's date +``` + +The default date can be either a callable or a concrete value. +If you set a callable, `Money.default_date` will call the callable and return its value. + +By default, `Money.default_date` is set to: +```ruby +Money.default_date = -> { Date.current } +``` +if `Date.current` exists (which is the case in Rails projects). If it doesn't exist, the default date is set to: +```ruby +Money.default_date = -> { Date.today } +``` + +### date_determines_equality + +Based on your use case, you might or might not want the `date` attribute to affect whether two Money objects are equal. For example, in some scenarios it makes sense that: +```ruby +Money.new(100, :usd, date: "2010-12-31") == Money.new(100, :usd, date: "2020-01-01") +``` +returns `true` (e.g. if you care only about money amounts), while in others it makes sense that it returns `false`. + +By default, the date on the Money object **doesn't** affect the equality of the object to other Money objects (in the above scenario, the expression would return `true`). + +However, if you need to, you can tell the gem to look at the date when comparing Money objects, so that the above expression would return `false`. You can achieve that by setting: +```ruby +Money.date_determines_equality = true +``` + +Setting this will affect the following Money methods: `#hash`, `#eql?` and `#<=>`. + +### default_date_column + +If you use `money-rails` and `monetize`, but don't supply either `with_date` or `with_model_date` options, the gem will try to find a default column to set the date on the monetized attribute. + +The default table column for setting the date on Money is `created_at`. + +If you'd like to use a different default column, set it with: +```ruby +# config/initializers/money.rb +Money.default_date_column = :my_date_column +``` + +If the column value is nil, or the column doesn't exist on the table, the date will fall back to `Money.default_date`. + +If you don't want the gem to use a default column for setting the date, set this: +```ruby +Money.default_date_column = nil +``` + +## Supported Versions + +`money_with_date` has the following version requirements: +- Ruby: **>= 2.6.0** +- money: **>= 6.14.0** and **<= 6.16.0** +- money-rails: **1.15.0** + +The gem has been tested against all possible combinations of supported Ruby, Rails, money, and money-rails versions: +- Ruby: `2.6`, `2.7`, `3.0`, and `3.1` +- Rails: `5.2.6.2`, `6.0.4.6`, `6.1.4.6`, and `7.0.2.2` +- money: `6.14.0`, `6.14.1`, and `6.16.0` +- money-rails: `1.15.0` + +Note: the gem hasn't been tested on Rails `5.2.6.2` with Rubies `3.0` and `3.1`, and on Rails `7.0.2.2` with Ruby `2.6` as those combinations of versions aren't compatible. + +In addition to running its own test suite, the CI for this gem also runs [money's](https://github.com/RubyMoney/money/tree/main/spec) and [money-rails's](https://github.com/RubyMoney/money-rails/tree/main/spec) test suites with this gem loaded, to prevent regressions. This has been achieved by cloning their test suites from GitHub and requiring this gem in their spec files. For technical information, check the CI [workflow](.github/workflows/ci.yml). + +## Compatibility + +This gem overrides money's and money-rails's public and _private_ APIs. As such, the gem can break with any new release of either of those gems if their API changes. To ensure breakages don't happen, the gem has been locked only to those versions of money and money-rails which have been fully tested for regressions. + +The minimum supported versions are 6.14.0 for money and 1.15.0 for money-rails. Versions older than those cannot be supported as the API in older versions of both gems cannot be extended to provide the functionality which this gem provides. + +### Positional bank parameter + +Up until version 6.14.0 of money, Money constructor accepted only three positional arguments (amount, currency, and bank): +```ruby +Money.new(1000, :usd, Money.default_bank) +``` + +From version 6.14.0, the constructor accepts two positional arguments and optional keyword arguments which can be used to override the default bank: +```ruby +Money.new(1000, :usd, bank: Money.default_bank) +``` + +For backwards-compatibility reasons, in version 6.14.0 and above, you can use either of those approaches to override the default bank. + +However, the old approach isn't compatible with this gem because it doesn't allow us to provide a date argument without modifying the constructor. + +So, if you want to use this gem, but are currently overriding the default bank with a positional argument, you'll have to refactor your code to use the new approach. + +Note that, even if you don't refactor the code, the gem will still work, but all Money objects created that way will be assigned the default date. + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. Run `bin/console` for an interactive prompt that will allow you to experiment. + +To run RuboCop, execute `bundle exec rake rubocop`. + +To run unit tests, execute `bundle exec rake spec:unit`. Unit tests can be run with different versions of money, money-rails, and Rails, which you can specify as Rake task arguments. For example, if you want to run unit tests on money 6.14.0, money-rails 1.15.0, and Rails 6.1.4.6, execute: `bundle exec rake "spec:unit[6.14.0, 1.15.0, 6.1.4.6]"`. + +To run money regression tests, execute `bundle exec rake spec:money`. You can also run them with a specific version of money: `bundle exec rake "spec:money[6.14.1]"`. + +To run money-rails regression tests, execute `bundle exec rake spec:money_rails`. The task also accepts a money-rails version argument: `bundle exec rake "spec:money_rails[1.15.0]"`. + +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/infinum/money_with_date. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/infinum/money_with_date/blob/master/CODE_OF_CONDUCT.md). + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). + +## Code of Conduct + +Everyone interacting in the MoneyWithDate project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/infinum/money_with_date/blob/master/CODE_OF_CONDUCT.md). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..955f515 --- /dev/null +++ b/Rakefile @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +require "rubocop/rake_task" + +RuboCop::RakeTask.new + +task default: %i[spec:unit rubocop] + +namespace :spec do # rubocop:disable Metrics/BlockLength + task :unit, [:money, :money_rails, :rails] do |_, args| + args.with_defaults(money: "6.16.0", money_rails: "1.15.0", rails: "7.0.2.2") + + ENV["MONEY_VERSION"] = args.money + ENV["MONEY_RAILS_VERSION"] = args.money_rails + ENV["RAILS_VERSION"] = args.rails + + sh 'echo "Running unit tests on money $MONEY_VERSION, money-rails $MONEY_RAILS_VERSION, rails $RAILS_VERSION"', + verbose: false + Rake::Task["prepare_env"].invoke + sh "bundle exec rspec", verbose: false + end + + task :money, [:money] do |_, args| + args.with_defaults(money: "6.16.0") + + ENV["MONEY_VERSION"] = args.money + + sh 'echo "Running money regressions tests on money $MONEY_VERSION"', verbose: false + Rake::Task["prepare_env"].invoke + sh "bin/prepare_money_specs", verbose: false + sh "bundle exec rspec --default-path spec_money", verbose: false + end + + task :money_rails, [:money_rails] do |_, args| + args.with_defaults(money_rails: "1.15.0") + + ENV["MONEY_RAILS_VERSION"] = args.money_rails + + sh 'echo "Running money-rails regressions tests on money-rails $MONEY_RAILS_VERSION"', verbose: false + Rake::Task["prepare_env"].invoke + sh "bin/prepare_money_rails_specs", verbose: false + + Bundler.with_original_env do + Dir.chdir("spec_money_rails") do + sh "BUNDLE_GEMFILE='' bundle install", verbose: false + sh "BUNDLE_GEMFILE='' bundle exec rake spec:all", verbose: false + end + end + end +end + +task :prepare_env do + sh "export BUNDLE_GEMFILE=Gemfile_test", verbose: false + sh "rm -f Gemfile_test.lock", verbose: false + sh "bundle install", verbose: false +end diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..9b4e0b4 --- /dev/null +++ b/bin/console @@ -0,0 +1,12 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "money_with_date" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +require "pry" +Pry.start diff --git a/bin/prepare_money_rails_specs b/bin/prepare_money_rails_specs new file mode 100755 index 0000000..a2638ac --- /dev/null +++ b/bin/prepare_money_rails_specs @@ -0,0 +1,61 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "rubygems" +require "bundler/setup" +require "open-uri" +require "zip" + +money_rails_version = ENV["MONEY_RAILS_VERSION"] + +# Matching money-rails versions to git commits +version_refs = { + "1.15.0" => "4d05fee299765e85ebf31282cbb491c5b2beb609" +} + +archive_folder_path = "#{__dir__}/../tmp" +archive_file_path = "#{archive_folder_path}/archive.zip" +destination_folder_path = "#{__dir__}/../spec_money_rails" + +FileUtils.mkdir_p archive_folder_path +FileUtils.rm_rf destination_folder_path + +# Fetching money-rails codebase from GH +money_rails_version_ref = version_refs[money_rails_version] +money_rails_version_url = "https://github.com/RubyMoney/money-rails/archive/#{money_rails_version_ref}.zip" +IO.copy_stream(URI.open(money_rails_version_url), archive_file_path) # rubocop:disable Security/Open + +# Unzipping the codebase archive +Zip::File.open(archive_file_path) do |zip_file| + zip_file.each do |f| + f_path = File.join(archive_folder_path, f.name) + FileUtils.mkdir_p(File.dirname(f_path)) + zip_file.extract(f, f_path) unless File.exist?(f_path) + end +end + +# Moving money-rails codebase to spec_money_rails folder +FileUtils.mv "#{archive_folder_path}/money-rails-#{money_rails_version_ref}", destination_folder_path, force: true + +# Requiring money_with_date in money-rails specs +File.open("#{destination_folder_path}/Gemfile", "a") { |f| f << 'gem "sprockets-rails"' } +Dir["#{destination_folder_path}/gemfiles/*.gemfile"].each do |gemfile_path| + File.open(gemfile_path, "a") { |f| f << 'gem "money_with_date", path: "../../"' } +end + +# Adding a simple spec to verify that money_with_date has been loaded +File.open("#{destination_folder_path}/spec/money_with_date_spec.rb", "w") do |f| + f << <<~RUBY + require 'spec_helper' + + RSpec.describe Money do + it "has a date" do + money = Money.new(100) + + expect(money.date).to be_a(Date) + end + end + RUBY +end + +FileUtils.rm_rf archive_folder_path diff --git a/bin/prepare_money_specs b/bin/prepare_money_specs new file mode 100755 index 0000000..96afe26 --- /dev/null +++ b/bin/prepare_money_specs @@ -0,0 +1,61 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "rubygems" +require "bundler/setup" +require "open-uri" +require "zip" + +money_version = ENV["MONEY_VERSION"] + +# Matching money versions to git commits +version_refs = { + "6.14.0" => "e26e222af68bf9b89ac52e1c6e5b2562a0577a8e", + "6.14.1" => "9bb10c79e24abb62ed5a0d1f88876cd459b307b7", + "6.16.0" => "ca59ced949e4e818262a59ffbcad3ec54affa81f" +} + +archive_folder_path = "#{__dir__}/../tmp" +archive_file_path = "#{archive_folder_path}/archive.zip" +destination_folder_path = "#{__dir__}/../spec_money" + +FileUtils.mkdir_p archive_folder_path +FileUtils.rm_rf destination_folder_path + +# Fetching money codebase from GH +money_version_ref = version_refs[money_version] +money_version_url = "https://github.com/RubyMoney/money/archive/#{money_version_ref}.zip" +IO.copy_stream(URI.open(money_version_url), archive_file_path) # rubocop:disable Security/Open + +# Unzipping the codebase archive +Zip::File.open(archive_file_path) do |zip_file| + zip_file.each do |f| + f_path = File.join(archive_folder_path, f.name) + FileUtils.mkdir_p(File.dirname(f_path)) + zip_file.extract(f, f_path) unless File.exist?(f_path) + end +end + +# Moving money spec files to spec_money folder +FileUtils.mv "#{archive_folder_path}/money-#{money_version_ref}/spec", destination_folder_path, force: true + +# Requiring money_with_date in money specs +spec_helper = File.read("#{destination_folder_path}/spec_helper.rb") +spec_helper = spec_helper.gsub('require "money"', 'require "money_with_date"') +spec_helper = spec_helper.gsub("./spec/support/**/*.rb", "./spec_money/support/**/*.rb") +File.write("#{destination_folder_path}/spec_helper.rb", spec_helper) + +# Adding a simple spec to verify that money_with_date has been loaded +File.open("#{destination_folder_path}/money_with_date_spec.rb", "w") do |f| + f << <<~RUBY + RSpec.describe Money do + it "has a date" do + money = Money.new(100) + + expect(money.date).to be_a(Date) + end + end + RUBY +end + +FileUtils.rm_rf archive_folder_path diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/lib/money_with_date.rb b/lib/money_with_date.rb new file mode 100644 index 0000000..4062412 --- /dev/null +++ b/lib/money_with_date.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "date" +require "money" + +require_relative "money_with_date/version" +require_relative "money_with_date/class_methods" +require_relative "money_with_date/instance_methods" +require_relative "money_with_date/bank/variable_exchange" +require_relative "money_with_date/rates_store/memory" + +::Money.prepend(::MoneyWithDate::InstanceMethods) +::Money.singleton_class.prepend(::MoneyWithDate::ClassMethods) + +# :nocov: +::Money.date_determines_equality = false +::Money.default_date = ::Date.respond_to?(:current) ? -> { ::Date.current } : -> { ::Date.today } + +require "money_with_date/railtie" if defined?(::Rails::Railtie) +# :nocov: diff --git a/lib/money_with_date/active_record/class_methods.rb b/lib/money_with_date/active_record/class_methods.rb new file mode 100644 index 0000000..647044a --- /dev/null +++ b/lib/money_with_date/active_record/class_methods.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module MoneyWithDate + module ActiveRecord + module ClassMethods + attr_accessor :default_date_column + end + end +end diff --git a/lib/money_with_date/active_record/monetizable.rb b/lib/money_with_date/active_record/monetizable.rb new file mode 100644 index 0000000..2b3e1fb --- /dev/null +++ b/lib/money_with_date/active_record/monetizable.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module MoneyWithDate + module ActiveRecord + module Monetizable + def read_monetized(name, subunit_name, options = {}, *args) + money = super(name, subunit_name, options, *args) + date = find_date_for(options[:with_model_date], options[:with_date]) + + if money&.date == date + money + else + instance_variable_set("@#{name}", money&.with_date(date)) + end + end + + private + + def find_date_for(instance_date_name, field_date_name) # rubocop:disable Metrics/MethodLength + if instance_date_name && respond_to?(instance_date_name) + public_send(instance_date_name) + elsif field_date_name.respond_to?(:call) + field_date_name.call(self) + elsif field_date_name + field_date_name + elsif ::Money.default_date_column && respond_to?(::Money.default_date_column) + public_send(::Money.default_date_column) + else + ::Money.default_date + end + end + end + end +end diff --git a/lib/money_with_date/bank/variable_exchange.rb b/lib/money_with_date/bank/variable_exchange.rb new file mode 100644 index 0000000..ca48fc8 --- /dev/null +++ b/lib/money_with_date/bank/variable_exchange.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module MoneyWithDate + module Bank + class VariableExchange < ::Money::Bank::VariableExchange + def initialize(store = MoneyWithDate::RatesStore::Memory.new, &block) + super + end + + # rubocop:disable all + def exchange_with(from, to_currency, &block) + to_currency = ::Money::Currency.wrap(to_currency) + if from.currency == to_currency + from + else + if (rate = get_rate(from.currency, to_currency, from.date)) + fractional = calculate_fractional(from, to_currency) + from.dup_with( + fractional: exchange(fractional, rate, &block), + currency: to_currency, + bank: self + ) + else + raise ::Money::Bank::UnknownRate, "No conversion rate known for '#{from.currency.iso_code}' -> '#{to_currency}' on date #{from.date}" + end + end + end + # rubocop:enable all + + def add_rate(from, to, rate, date) + set_rate(from, to, rate, date) + end + + # rubocop:disable Metrics/AbcSize + def set_rate(from, to, rate, date) + if store.method(:add_rate).parameters.size == 3 + store.add_rate(::Money::Currency.wrap(from).iso_code, ::Money::Currency.wrap(to).iso_code, rate) + else + store.add_rate(::Money::Currency.wrap(from).iso_code, ::Money::Currency.wrap(to).iso_code, rate, + ::Money.parse_date(date)) + end + end + + def get_rate(from, to, date) + if store.method(:get_rate).parameters.size == 2 + store.get_rate(::Money::Currency.wrap(from).iso_code, ::Money::Currency.wrap(to).iso_code) + else + store.get_rate(::Money::Currency.wrap(from).iso_code, ::Money::Currency.wrap(to).iso_code, + ::Money.parse_date(date)) + end + end + # rubocop:enable Metrics/AbcSize + + def rates + return super if store.method(:get_rate).parameters.size == 2 + + store.each_rate.each_with_object({}) do |(from, to, rate, date), hash| + hash[date.to_s] ||= {} + hash[date.to_s][[from, to].join(SERIALIZER_SEPARATOR)] = rate + end + end + + def import_rates(format, s, opts = {}) # rubocop:disable all + return super if store.method(:add_rate).parameters.size == 3 + + raise Money::Bank::UnknownRateFormat unless RATE_FORMATS.include?(format) + + if format == :ruby + warn "[WARNING] Using :ruby format when importing rates is potentially unsafe and " \ + "might lead to remote code execution via Marshal.load deserializer. Consider using " \ + "safe alternatives such as :json and :yaml." + end + + store.transaction do + data = FORMAT_SERIALIZERS[format].load(s) + + data.each do |date, rates| + rates.each do |key, rate| + from, to = key.split(SERIALIZER_SEPARATOR) + add_rate(from, to, rate, date) + end + end + end + + self + end + end + end +end diff --git a/lib/money_with_date/class_methods.rb b/lib/money_with_date/class_methods.rb new file mode 100644 index 0000000..cac26f8 --- /dev/null +++ b/lib/money_with_date/class_methods.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module MoneyWithDate + module ClassMethods + attr_accessor :date_determines_equality + + attr_writer :default_date + + def default_date + if @default_date.respond_to?(:call) + @default_date.call + else + @default_date + end + end + + def add_rate(from_currency, to_currency, rate, date = ::Money.default_date) + if ::Money.default_bank.method(:add_rate).parameters.size == 3 + ::Money.default_bank.add_rate(from_currency, to_currency, rate) + else + ::Money.default_bank.add_rate(from_currency, to_currency, rate, date) + end + end + + def parse_date(date) + return ::Money.default_date unless date + return date.to_date if date.respond_to?(:to_date) + + ::Date.parse(date) + rescue ArgumentError, TypeError, ::Date::Error # rubocop:disable Lint/ShadowedException + raise ArgumentError, "#{date.inspect} cannot be parsed as Date" + end + end +end diff --git a/lib/money_with_date/hooks.rb b/lib/money_with_date/hooks.rb new file mode 100644 index 0000000..857a991 --- /dev/null +++ b/lib/money_with_date/hooks.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module MoneyWithDate + class Hooks + def self.init + ::ActiveSupport.on_load(:active_record) do + require "money_with_date/active_record/monetizable" + require "money_with_date/active_record/class_methods" + + ::ActiveRecord::Base.prepend(::MoneyWithDate::ActiveRecord::Monetizable) + ::Money.singleton_class.prepend(::MoneyWithDate::ActiveRecord::ClassMethods) + + ::Money.default_date_column = :created_at + end + end + + def self.init? + money_rails_version = ::Gem::Version.new(::MoneyRails::VERSION) + + money_rails_version >= ::Gem::Version.new(::MoneyWithDate::MINIMUM_MONEY_RAILS_VERSION) && + money_rails_version <= ::Gem::Version.new(::MoneyWithDate::MAXIMUM_MONEY_RAILS_VERSION) + end + end +end diff --git a/lib/money_with_date/instance_methods.rb b/lib/money_with_date/instance_methods.rb new file mode 100644 index 0000000..548ad50 --- /dev/null +++ b/lib/money_with_date/instance_methods.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module MoneyWithDate + module InstanceMethods + attr_reader :date + + def initialize(obj, currency = ::Money.default_currency, options = {}) + if options.is_a?(::Hash) + @date = self.class.parse_date(options[:date]) + else + @date = ::Money.default_date + end + + raise ArgumentError, "#{@date.inspect} is not an instance of Date" unless @date.is_a?(::Date) + + super + end + + def hash + return super unless ::Money.date_determines_equality + + [fractional.hash, currency.hash, date.hash].hash + end + + def inspect + "#<#{self.class.name} fractional:#{fractional} currency:#{currency} date:#{date}>" + end + + def with_date(new_date) + new_date = self.class.parse_date(new_date) + + if date == new_date + self + else + dup_with(date: new_date) + end + end + + def dup_with(options = {}) + self.class.new( + options[:fractional] || fractional, + options[:currency] || currency, + bank: options[:bank] || bank, + date: options[:date] || date + ) + end + + def eql?(other) + return super unless ::Money.date_determines_equality + + if other.is_a?(::Money) + (fractional == other.fractional && currency == other.currency && date == other.date) || + (fractional.zero? && other.fractional.zero?) + else + false + end + end + + def <=>(other) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + return super unless ::Money.date_determines_equality + + unless other.is_a?(::Money) + return unless other.respond_to?(:zero?) && other.zero? + + return other.is_a?(::Money::Arithmetic::CoercedNumeric) ? 0 <=> fractional : fractional <=> 0 + end + + return fractional <=> other.fractional if zero? || other.zero? + + other = other.exchange_to(currency) + [fractional, date] <=> [other.fractional, other.date] + rescue ::Money::Bank::UnknownRate + nil + end + end +end diff --git a/lib/money_with_date/railtie.rb b/lib/money_with_date/railtie.rb new file mode 100644 index 0000000..9947682 --- /dev/null +++ b/lib/money_with_date/railtie.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "money_with_date/hooks" + +module MoneyWithDate + class Railtie < ::Rails::Railtie + initializer "money_with_date.initialize", after: "moneyrails.initialize" do + MoneyWithDate::Hooks.init if MoneyWithDate::Hooks.init? + end + end +end diff --git a/lib/money_with_date/rates_store/memory.rb b/lib/money_with_date/rates_store/memory.rb new file mode 100644 index 0000000..aed7c69 --- /dev/null +++ b/lib/money_with_date/rates_store/memory.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module MoneyWithDate + module RatesStore + class Memory < ::Money::RatesStore::Memory + def add_rate(currency_iso_from, currency_iso_to, rate, date) + guard.synchronize do + date_key = date_key_for(date) + rates[date_key] ||= {} + rates[date_key][rate_key_for(currency_iso_from, currency_iso_to)] = rate + end + end + + def get_rate(currency_iso_from, currency_iso_to, date) + guard.synchronize do + rates.dig(date_key_for(date), rate_key_for(currency_iso_from, currency_iso_to)) + end + end + + def each_rate + return to_enum(:each_rate) unless block_given? + + guard.synchronize do + rates.each do |date, date_rates| + date_rates.each do |key, rate| + iso_from, iso_to = key.split(INDEX_KEY_SEPARATOR) + yield iso_from, iso_to, rate, date_for_key(date) + end + end + end + end + + private + + def date_key_for(date) + date.to_date.to_s + end + + def date_for_key(key) + ::Date.parse(key) + end + end + end +end diff --git a/lib/money_with_date/version.rb b/lib/money_with_date/version.rb new file mode 100644 index 0000000..a059c80 --- /dev/null +++ b/lib/money_with_date/version.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module MoneyWithDate + VERSION = "0.1.0" + + MINIMUM_MONEY_VERSION = "6.14.0" + MINIMUM_MONEY_RAILS_VERSION = "1.15.0" + + MAXIMUM_MONEY_VERSION = "6.16.0" + MAXIMUM_MONEY_RAILS_VERSION = "1.15.0" +end diff --git a/money_with_date.gemspec b/money_with_date.gemspec new file mode 100644 index 0000000..60035d9 --- /dev/null +++ b/money_with_date.gemspec @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative "lib/money_with_date/version" + +Gem::Specification.new do |spec| + spec.name = "money_with_date" + spec.version = MoneyWithDate::VERSION + spec.authors = ["Lovro Bikić"] + spec.email = ["lovro.bikic@gmail.com"] + + spec.summary = "Extension for the money gem which adds dates to Money objects" + spec.homepage = "https://github.com/infinum/money_with_date" + spec.license = "MIT" + spec.required_ruby_version = ">= 2.6.0" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md" + + spec.metadata["rubygems_mfa_required"] = "true" + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject do |f| + (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) + end + end + spec.require_paths = ["lib"] + + spec.add_dependency "money", ">= #{MoneyWithDate::MINIMUM_MONEY_VERSION}", "<= #{MoneyWithDate::MAXIMUM_MONEY_VERSION}" # rubocop:disable Layout/LineLength + + spec.add_development_dependency "pry" + spec.add_development_dependency "rake" + spec.add_development_dependency "rspec" + spec.add_development_dependency "rubocop" + spec.add_development_dependency "rubocop-rake" + spec.add_development_dependency "rubocop-rspec" +end diff --git a/spec/money_with_date/active_record/class_methods_spec.rb b/spec/money_with_date/active_record/class_methods_spec.rb new file mode 100644 index 0000000..0a9b878 --- /dev/null +++ b/spec/money_with_date/active_record/class_methods_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "money_with_date/hooks" +require "money-rails" +require "active_record" + +ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") + +MoneyRails::Hooks.init +MoneyWithDate::Hooks.init + +RSpec.describe MoneyWithDate::ClassMethods do + describe ".default_date_column / .default_date_column=" do + around do |example| + old_default_date_column = Money.default_date_column + example.run + Money.default_date_column = old_default_date_column + end + + it "sets and retrieves the value" do + expect do + Money.default_date_column = :created_on + end.to change(Money, :default_date_column).from(:created_at).to(:created_on) + end + end +end diff --git a/spec/money_with_date/active_record/monetizable_spec.rb b/spec/money_with_date/active_record/monetizable_spec.rb new file mode 100644 index 0000000..b1786d1 --- /dev/null +++ b/spec/money_with_date/active_record/monetizable_spec.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +require "money_with_date/hooks" +require "money-rails" +require "active_record" + +ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") + +MoneyRails::Hooks.init +MoneyWithDate::Hooks.init + +ActiveRecord::Schema.define do + create_table :products, force: true do |t| + t.integer :price_cents + t.string :price_currency + t.date :created_on + t.timestamps + end +end + +RSpec.describe ".monetize" do + describe "money-rails options" do + let(:model) do + Class.new(ActiveRecord::Base) do + self.table_name = "products" + + def self.model_name + "Product" + end + + monetize :price_cents, with_model_currency: :price_currency + end + end + + it "doesn't affect them" do + record = model.new(price_cents: 100, price_currency: "EUR") + + expect(record.price).to eq(Money.new(100, :eur)) + end + end + + describe "no date options" do + let(:model) do + Class.new(ActiveRecord::Base) do + self.table_name = "products" + + def self.model_name + "Product" + end + + monetize :price_cents + end + end + + context "when default_date_column exists" do + it "is used to set the date" do + record = model.new(price_cents: 100, created_at: Date.today - 10) + + expect(record.price).to eq(Money.new(100, :usd, date: Date.today - 10)) + end + end + + context "when default_date_column is nil" do + it "uses the default date" do + record = model.new(price_cents: 100, created_at: nil) + + expect(record.price).to eq(Money.new(100, :usd, date: Date.today)) + end + end + + context "when default_date_column doesn't exist" do + around do |example| + old_default_date_column = Money.default_date_column + example.run + Money.default_date_column = old_default_date_column + end + + it "uses the default date" do + Money.default_date_column = :foo_bar + + record = model.new(price_cents: 100, created_at: Date.today - 10) + + expect(record.price).to eq(Money.new(100, :usd, date: Date.today)) + end + end + end + + describe "with_model_date option" do + let(:model) do + Class.new(ActiveRecord::Base) do + self.table_name = "products" + + def self.model_name + "Product" + end + + monetize :price_cents, with_model_date: :created_on + end + end + + context "when date attribute is nil" do + it "uses the default date" do + record = model.new(price_cents: 100, created_on: nil) + + expect(record.price).to eq(Money.new(100, :usd, date: Date.current)) + end + end + + context "when date attribute exists" do + it "is used to set the Money date" do + record = model.new(price_cents: 100, created_on: Date.today - 5) + + expect(record.price.date).to eq(Date.today - 5) + end + end + + it "returns nil if the amount doesn't exist" do + record = model.new(price_cents: nil, created_on: nil) + + expect(record.price).to eq(nil) + end + + it "works with nil amount if the date column changes" do + record = model.new(price_cents: nil, created_on: "2020-01-01") + + expect(record.price).to eq(nil) + + record.created_on = "2021-01-01" + + expect(record.price).to eq(nil) + end + + it "changes the date on the money object if the date column changes" do + record = model.new(price_cents: 100, created_on: "2020-01-01") + + expect(record.price.date).to eq(Date.parse("2020-01-01")) + + record.created_on = "2021-01-01" + + expect(record.price.date).to eq(Date.parse("2021-01-01")) + end + end + + context "with_date option" do + context "when a callable" do + let(:model) do + Class.new(ActiveRecord::Base) do + self.table_name = "products" + + def self.model_name + "Product" + end + + monetize :price_cents, with_date: ->(_) { Date.today + 5 } + end + end + + it "resolves the callable" do + record = model.new(price_cents: 100) + + expect(record.price).to eq(Money.new(100, :usd, date: Date.today + 5)) + end + end + + context "when a static value" do + let(:model) do + Class.new(ActiveRecord::Base) do + self.table_name = "products" + + def self.model_name + "Product" + end + + monetize :price_cents, with_date: Date.today + 5 + end + end + + it "uses that value" do + record = model.new(price_cents: 100) + + expect(record.price).to eq(Money.new(100, :usd, date: Date.today + 5)) + end + end + end + + context "with_model_date and with_date option" do + let(:model) do + Class.new(ActiveRecord::Base) do + self.table_name = "products" + + def self.model_name + "Product" + end + + monetize :price_cents, with_model_date: :created_on, with_date: Date.today + 5 + end + end + + it "gives precedence to with_model_date" do + record = model.new(price_cents: 100, created_on: Date.today - 5) + + expect(record.price).to eq(Money.new(100, :usd, date: Date.today - 5)) + end + + it "uses the default date if model date doesn't exist" do + record = model.new(price_cents: 100, created_on: nil) + + expect(record.price).to eq(Money.new(100, :usd, date: Date.today)) + end + end +end diff --git a/spec/money_with_date/bank/variable_exchange_spec.rb b/spec/money_with_date/bank/variable_exchange_spec.rb new file mode 100644 index 0000000..cfbd2ef --- /dev/null +++ b/spec/money_with_date/bank/variable_exchange_spec.rb @@ -0,0 +1,426 @@ +# frozen_string_literal: true + +RSpec.describe MoneyWithDate::Bank::VariableExchange do + let(:date_dependent_store) { MoneyWithDate::RatesStore::Memory.new } + let(:date_independent_store) { Money::RatesStore::Memory.new } + + describe "#initialize" do + context "without &block" do + it "defaults to Memory store" do + expect(described_class.new.store).to be_a(MoneyWithDate::RatesStore::Memory) + end + end + + context "with &block" do + let(:bank) do + proc = proc { |n| n.ceil } + described_class.new(&proc).tap do |bank| + bank.add_rate("USD", "EUR", 1.33, Date.today) + end + end + + describe "#exchange_with" do + it "uses the stored truncation method" do + expect(bank.exchange_with(Money.new(10, "USD"), "EUR")).to eq(Money.new(14, "EUR")) + end + + it "accepts a custom truncation method" do + proc = proc { |n| n.ceil + 1 } + + expect(bank.exchange_with(Money.new(10, "USD"), "EUR", &proc)).to eq(Money.new(15, "EUR")) + end + end + end + end + + describe "#set_rate / #get_rate" do + context "when store accepts a date param" do + subject(:bank) { described_class.new(date_dependent_store) } + + it "sets and gets exchange rates by date" do + bank.set_rate "USD", "EUR", 0.9, Date.today + bank.set_rate "USD", "EUR", 0.95, Date.today + 1 + + expect(bank.get_rate("USD", "EUR", Date.today)).to eq(0.9) + expect(bank.get_rate("USD", "EUR", Date.today + 1)).to eq(0.95) + end + + it "parses the date" do + bank.set_rate "USD", "EUR", 0.9, "2020-01-01" + bank.set_rate "USD", "EUR", 0.95, "2020-01-02" + + expect(bank.get_rate("USD", "EUR", Date.parse("2020-01-01"))).to eq(0.9) + end + + it "raises an error if date isn't supplied" do + expect do + bank.set_rate "USD", "EUR", 0.9 + end.to raise_error(ArgumentError).with_message("wrong number of arguments (given 3, expected 4)") + + expect do + bank.get_rate "USD", "EUR" + end.to raise_error(ArgumentError).with_message("wrong number of arguments (given 2, expected 3)") + end + end + + context "when store doesn't accept a date param" do + subject(:bank) { described_class.new(date_independent_store) } + + it "sets and gets exchange rates but doesn't save the date" do + bank.set_rate "USD", "EUR", 0.9, Date.today + bank.set_rate "USD", "EUR", 0.95, Date.today + 1 + + expect(bank.get_rate("USD", "EUR", Date.today)).to eq(0.95) + expect(bank.get_rate("USD", "EUR", Date.today + 1)).to eq(0.95) + end + end + end + + describe "#add_rate" do + context "when store accepts a date param" do + subject(:bank) { described_class.new(date_dependent_store) } + + it "delegates to store#set_rate with the date" do + expect(bank.add_rate("USD", "EUR", 1.25, Date.today)).to eq(1.25) + expect(bank.add_rate("USD", "EUR", 1.3, Date.today + 1)).to eq(1.3) + + expect(bank.get_rate("USD", "EUR", Date.today)).to eq(1.25) + end + end + + context "when store doesn't accept a date param" do + subject(:bank) { described_class.new(date_independent_store) } + + it "delegates to store#set_rate without the date" do + expect(bank.add_rate("USD", "EUR", 1.25, Date.today)).to eq(1.25) + expect(bank.add_rate("USD", "EUR", 1.3, Date.today + 1)).to eq(1.3) + + expect(bank.get_rate("USD", "EUR", Date.today)).to eq(1.3) + end + end + end + + describe "#exchange_with" do + subject(:bank) { described_class.new(date_dependent_store) } + + before do + bank.add_rate "USD", "EUR", 1.33, Date.today + bank.add_rate "USD", "EUR", 1.4, Date.today + 1 + end + + it "exchanges one currency to another with the correct date" do + money = Money.new(100, :usd, date: Date.today) + + exchanged_money = bank.exchange_with(money, :eur) + + expect(exchanged_money).to eq(Money.new(133, :eur)) + expect(exchanged_money.date).to eq(Date.today) + end + + it "returns the same money object if exchanging to same currency" do + money = Money.new(100, :usd, date: Date.today) + + exchanged_money = bank.exchange_with(money, :usd) + + expect(exchanged_money).to eq(money) + end + + it "raises an error if an exchange rate doesn't exist for the given date" do + money = Money.new(100, :usd, date: "2020-01-01") + error_message = "No conversion rate known for 'USD' -> 'EUR' on date 2020-01-01" + + expect do + bank.exchange_with(money, :eur) + end.to raise_error(::Money::Bank::UnknownRate).with_message(error_message) + end + + it "raises an error if an exchange rate doesn't exist at all" do + money = Money.new(100, :usd, date: "2020-01-01") + + expect do + bank.exchange_with(money, :bbb) + end.to raise_error(::Money::Currency::UnknownCurrency).with_message("Unknown currency 'bbb'") + end + + it "accepts a custom truncation method" do + money = Money.new(10, :usd, date: Date.today) + proc = proc { |n| n.ceil } + + exchanged_money = bank.exchange_with(money, :eur, &proc) + + expect(exchanged_money).to eq(Money.new(14, :eur)) + end + + it "preserves the class in the result when given a subclass of Money" do + special_money_class = Class.new(Money) + special_money = special_money_class.new(100, :usd, date: Date.today) + + expect(bank.exchange_with(special_money, :eur)).to be_a(special_money_class) + end + end + + describe "#export_rates" do + context "when store accepts a date param" do + subject(:bank) { described_class.new(date_dependent_store) } + + let(:rates) { { Date.today.to_s => { "USD_TO_EUR" => 1.25, "USD_TO_JPY" => 2.55 } } } + + before :each do + subject.set_rate("USD", "EUR", 1.25, Date.today) + subject.set_rate("USD", "JPY", 2.55, Date.today) + end + + context "with format == :json" do + it "should return rates formatted as json" do + json = subject.export_rates(:json) + + expect(JSON.parse(json)).to eq(rates) + end + end + + context "with format == :ruby" do + it "should return rates formatted as ruby objects" do + marshal = subject.export_rates(:ruby) + + expect(Marshal.load(marshal)).to eq(rates) # rubocop:disable Security/MarshalLoad + end + end + + context "with format == :yaml" do + it "should return rates formatted as yaml" do + yaml = subject.export_rates(:yaml) + + expect(YAML.safe_load(yaml)).to eq(rates) + end + end + + context "with unknown format" do + it "raises Money::Bank::UnknownRateFormat" do + expect do + subject.export_rates(:foo) + end.to raise_error(Money::Bank::UnknownRateFormat) + end + end + + context "with :file provided" do + it "writes rates to file" do + f = double("IO") + expect(File).to receive(:open).with("null", "w").and_yield(f) + expect(f).to receive(:write).with(JSON.dump(rates)) + + subject.export_rates(:json, "null") + end + end + + it "delegates execution to store, options are a no-op" do + expect(subject.store).to receive(:transaction) + + subject.export_rates(:yaml, nil, foo: 1) + end + end + + context "when store doesn't a date param" do + subject(:bank) { described_class.new(date_independent_store) } + + let(:rates) { { "USD_TO_EUR" => 1.25, "USD_TO_JPY" => 2.55 } } + + before :each do + subject.set_rate("USD", "EUR", 1.25, Date.today) + subject.set_rate("USD", "JPY", 2.55, Date.today) + end + + context "with format == :json" do + it "should return rates formatted as json" do + json = subject.export_rates(:json) + + expect(JSON.parse(json)).to eq(rates) + end + end + + context "with format == :ruby" do + it "should return rates formatted as ruby objects" do + marshal = subject.export_rates(:ruby) + + expect(Marshal.load(marshal)).to eq(rates) # rubocop:disable Security/MarshalLoad + end + end + + context "with format == :yaml" do + it "should return rates formatted as yaml" do + yaml = subject.export_rates(:yaml) + + expect(YAML.safe_load(yaml)).to eq(rates) + end + end + + context "with unknown format" do + it "raises Money::Bank::UnknownRateFormat" do + expect do + subject.export_rates(:foo) + end.to raise_error(Money::Bank::UnknownRateFormat) + end + end + + context "with :file provided" do + it "writes rates to file" do + f = double("IO") + expect(File).to receive(:open).with("null", "w").and_yield(f) + expect(f).to receive(:write).with(JSON.dump(rates)) + + subject.export_rates(:json, "null") + end + end + + it "delegates execution to store, options are a no-op" do + expect(subject.store).to receive(:transaction) + + subject.export_rates(:yaml, nil, foo: 1) + end + end + end + + describe "#import_rates" do + context "when store accepts a date param" do + subject(:bank) { described_class.new(date_dependent_store) } + + context "with format == :json" do + let(:dump) { JSON.dump({ "2020-01-01" => { "USD_TO_EUR" => 1.25, "USD_TO_JPY" => 2.55 } }) } + + it "loads the rates provided" do + subject.import_rates(:json, dump) + + expect(subject.get_rate("USD", "EUR", Date.parse("2020-01-01"))).to eq(1.25) + expect(subject.get_rate("USD", "JPY", Date.parse("2020-01-01"))).to eq(2.55) + end + end + + context "with format == :ruby" do + let(:dump) { Marshal.dump({ "2020-01-01" => { "USD_TO_EUR" => 1.25, "USD_TO_JPY" => 2.55 } }) } + + it "loads the rates provided" do + subject.import_rates(:ruby, dump) + + expect(subject.get_rate("USD", "EUR", Date.parse("2020-01-01"))).to eq(1.25) + expect(subject.get_rate("USD", "JPY", Date.parse("2020-01-01"))).to eq(2.55) + end + + it "prints a warning" do + allow(subject).to receive(:warn) + + subject.import_rates(:ruby, dump) + + expect(subject) + .to have_received(:warn) + .with(include("[WARNING] Using :ruby format when importing rates is potentially unsafe")) + end + end + + context "with format == :yaml" do + let(:dump) { "---\n'2020-01-01':\n USD_TO_EUR: 1.25\n USD_TO_JPY: 2.55\n" } + + it "loads the rates provided" do + subject.import_rates(:yaml, dump) + + expect(subject.get_rate("USD", "EUR", Date.parse("2020-01-01"))).to eq(1.25) + expect(subject.get_rate("USD", "JPY", Date.parse("2020-01-01"))).to eq(2.55) + end + end + + context "with unknown format" do + it "raises Money::Bank::UnknownRateFormat" do + expect do + subject.import_rates(:foo, "") + end.to raise_error Money::Bank::UnknownRateFormat + end + end + + it "delegates execution to store#transaction" do + dump = "---\n'2020-01-01':\n USD_TO_EUR: 1.25\n USD_TO_JPY: 2.55\n" + + expect(subject.store).to receive(:transaction) + + subject.import_rates(:yaml, dump, foo: 1) + end + end + + context "when store doesn't accept a date param" do + subject(:bank) { described_class.new(date_independent_store) } + + context "with format == :json" do + let(:dump) { JSON.dump({ "USD_TO_EUR" => 1.25, "USD_TO_JPY" => 2.55 }) } + + it "loads the rates provided" do + subject.import_rates(:json, dump) + + expect(subject.get_rate("USD", "EUR", Date.parse("2020-01-01"))).to eq(1.25) + expect(subject.get_rate("USD", "JPY", Date.parse("2020-01-01"))).to eq(2.55) + end + end + + context "with format == :ruby" do + let(:dump) { Marshal.dump({ "USD_TO_EUR" => 1.25, "USD_TO_JPY" => 2.55 }) } + + it "loads the rates provided" do + subject.import_rates(:ruby, dump) + + expect(subject.get_rate("USD", "EUR", Date.parse("2020-01-01"))).to eq(1.25) + expect(subject.get_rate("USD", "JPY", Date.parse("2020-01-01"))).to eq(2.55) + end + + it "prints a warning" do + allow(subject).to receive(:warn) + + subject.import_rates(:ruby, dump) + + expect(subject) + .to have_received(:warn) + .with(include("[WARNING] Using :ruby format when importing rates is potentially unsafe")) + end + end + + context "with format == :yaml" do + let(:dump) { "--- \nUSD_TO_EUR: 1.25\nUSD_TO_JPY: 2.55\n" } + + it "loads the rates provided" do + subject.import_rates(:yaml, dump) + + expect(subject.get_rate("USD", "EUR", Date.parse("2020-01-01"))).to eq(1.25) + expect(subject.get_rate("USD", "JPY", Date.parse("2020-01-01"))).to eq(2.55) + end + end + + context "with unknown format" do + it "raises Money::Bank::UnknownRateFormat" do + expect do + subject.import_rates(:foo, "") + end.to raise_error Money::Bank::UnknownRateFormat + end + end + + it "delegates execution to store#transaction" do + dump = "---\n'2020-01-01':\n USD_TO_EUR: 1.25\n USD_TO_JPY: 2.55\n" + + expect(subject.store).to receive(:transaction) + + subject.import_rates(:yaml, dump, foo: 1) + end + end + end + + describe "#marshal_dump" do + subject(:bank) { described_class.new(date_dependent_store) } + + it "does not raise an error" do + expect do + Marshal.dump(subject) + end.to_not raise_error + end + + it "works with Marshal.load" do + bank = Marshal.load(Marshal.dump(subject)) + + expect(bank.rates).to eq(subject.rates) + expect(bank.rounding_method).to eq(subject.rounding_method) + end + end +end diff --git a/spec/money_with_date/class_methods_spec.rb b/spec/money_with_date/class_methods_spec.rb new file mode 100644 index 0000000..cadc13d --- /dev/null +++ b/spec/money_with_date/class_methods_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +RSpec.describe MoneyWithDate::ClassMethods do + describe ".default_date / .default_date=" do + around(:each) do |example| + old_default_date = Money.default_date + example.run + Money.default_date = old_default_date + end + + context "when default date is a callable" do + before do + Money.default_date = -> { Date.today - 5 } + end + + it "resolves the callable" do + expect(Money.default_date).to eq(Date.today - 5) + end + end + + context "when default date is static" do + let(:value) { Date.today - 5 } + + before do + Money.default_date = value + end + + it "returns that value" do + expect(Money.default_date).to eq(value) + expect(Money.default_date.object_id).to eq(value.object_id) + end + end + end + + describe ".add_rate" do + around(:each) do |example| + old_default_bank = Money.default_bank + example.run + Money.default_bank = old_default_bank + end + + context "when bank object doesn't accept a date param" do + before do + Money.default_bank = Money::Bank::VariableExchange.new(Money::RatesStore::Memory.new) + end + + it "doesn't forward date to the bank" do + Money.add_rate "USD", "EUR", 0.9, Date.today + Money.add_rate "USD", "EUR", 0.8, Date.today - 1 + + expect(Money.default_bank.get_rate("USD", "EUR")).to eq(0.8) + end + end + + context "when bank object accepts a date param" do + context "when store object accepts a date param" do + before do + Money.default_bank = MoneyWithDate::Bank::VariableExchange.new(MoneyWithDate::RatesStore::Memory.new) + end + + it "saves the rate with the date param" do + Money.add_rate "USD", "EUR", 0.9, Date.today + Money.add_rate "USD", "EUR", 0.8, Date.today - 1 + + expect(Money.default_bank.get_rate("USD", "EUR", Date.today)).to eq(0.9) + expect(Money.default_bank.get_rate("USD", "EUR", Date.today - 1)).to eq(0.8) + end + end + + context "when store object doesn't accept a date param" do + before do + Money.default_bank = MoneyWithDate::Bank::VariableExchange.new(::Money::RatesStore::Memory.new) + end + + it "saves the rate without date" do + Money.add_rate "USD", "EUR", 0.9, Date.today + Money.add_rate "USD", "EUR", 0.8, Date.today - 1 + + expect(Money.default_bank.get_rate("USD", "EUR", Date.today)).to eq(0.8) + expect(Money.default_bank.get_rate("USD", "EUR", Date.today - 1)).to eq(0.8) + end + end + end + end +end diff --git a/spec/money_with_date/hooks_spec.rb b/spec/money_with_date/hooks_spec.rb new file mode 100644 index 0000000..7cc7203 --- /dev/null +++ b/spec/money_with_date/hooks_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +RSpec.describe MoneyWithDate::Hooks do + describe ".init?" do + subject { described_class.init? } + + context "when money-rails version is supported" do + before do + stub_const("MoneyRails::VERSION", "1.15.0") + end + + it { is_expected.to eq(true) } + end + + context "when money-rails version is too low" do + before do + stub_const("MoneyRails::VERSION", "1.14.0") + end + + it { is_expected.to eq(false) } + end + + context "when money-rails version is too high" do + before do + stub_const("MoneyRails::VERSION", "1.16.0") + end + + it { is_expected.to eq(false) } + end + end +end diff --git a/spec/money_with_date/instance_methods_spec.rb b/spec/money_with_date/instance_methods_spec.rb new file mode 100644 index 0000000..6031da7 --- /dev/null +++ b/spec/money_with_date/instance_methods_spec.rb @@ -0,0 +1,356 @@ +# frozen_string_literal: true + +RSpec.describe MoneyWithDate::InstanceMethods do + describe "#initialize" do + context "without date param" do + it "sets the default date" do + money = Money.new(20) + + expect(money.date).to eq(Date.today) + end + end + + context "without date param but unparseable default date" do + around do |example| + old_default_date = Money.default_date + example.run + Money.default_date = old_default_date + end + + it "raises an ArgumentError" do + Money.default_date = -> { "not a date" } + + expect do + Money.new(20) + end.to raise_error(ArgumentError).with_message('"not a date" is not an instance of Date') + end + end + + context "with nil date param" do + it "sets the default date" do + money = Money.new(20, :usd, date: nil) + + expect(money.date).to eq(Date.today) + end + end + + context "with Time date param" do + it "converts it to date" do + money = Money.new(20, :usd, date: Time.new(2020, 1, 1, 20, 30, 30)) + + expect(money.date).to eq(Date.new(2020, 1, 1)) + end + end + + context "with parseable string date param" do + it "converts it to date" do + money = Money.new(20, :usd, date: "2020-01-01") + + expect(money.date).to eq(Date.new(2020, 1, 1)) + end + end + + context "with unparseable string date param" do + it "raises an argument error" do + expect do + Money.new(20, :usd, date: "not a date") + end.to raise_error(ArgumentError).with_message('"not a date" cannot be parsed as Date') + end + end + + context "with numeric date param" do + it "raises an argument error" do + expect do + Money.new(20, :usd, date: 20) + end.to raise_error(ArgumentError).with_message("20 cannot be parsed as Date") + end + end + + context "with positional bank param" do + it "sets the default date" do + money = Money.new(20, :usd, Money::Bank::VariableExchange.new) + + expect(money.date).to eq(Money.default_date) + end + end + end + + describe "#hash" do + context "by default" do + it "is equal if the dates are equal" do + first_money = Money.new(20, :usd, date: Date.today) + second_money = Money.new(20, :usd, date: Date.today) + + expect(first_money.hash).to eq(second_money.hash) + end + + it "is equal if the dates are different" do + first_money = Money.new(20, :usd, date: Date.today) + second_money = Money.new(20, :usd, date: Date.today - 1) + + expect(first_money.hash).to eq(second_money.hash) + end + end + + context "when Money.date_determines_equality is true" do + around do |example| + Money.date_determines_equality = true + example.run + Money.date_determines_equality = false + end + + it "is equal if the dates are equal" do + first_money = Money.new(20, :usd, date: Date.today) + second_money = Money.new(20, :usd, date: Date.today) + + expect(first_money.hash).to eq(second_money.hash) + end + + it "changes if the dates are different" do + first_money = Money.new(20, :usd, date: Date.today) + second_money = Money.new(20, :usd, date: Date.today - 1) + + expect(first_money.hash).not_to eq(second_money.hash) + end + end + end + + describe "#inspect" do + it "outputs date along with other variables" do + money = Money.new(20, :usd, date: Date.parse("2020-01-01")) + + expect(money.inspect).to eq("#") + end + end + + describe "#with_date" do + it "returns self if given the same date" do + money = Money.new(20, :usd, date: Date.today) + + new_money = money.with_date(Date.today) + + expect(money.object_id).to eq(new_money.object_id) + end + + it "duplicates Money but changes the date if new date is different" do + money = Money.new(20, :usd, date: Date.today) + + new_money = nil + expect do + new_money = money.with_date(Date.today - 1) + end.not_to change(money, :date) + + expect(new_money.fractional).to eq(money.fractional) + expect(new_money.currency).to eq(money.currency) + expect(new_money.date).to eq(Date.today - 1) + end + end + + describe "#eql?" do + subject { money.eql?(other) } + + context "by default" do + context "when other object is not an instance of Money" do + let(:money) { Money.new(20) } + let(:other) { "foo bar" } + + it { is_expected.to eq(false) } + end + + context "when money and other object are both zero" do + let(:money) { Money.new(0, :usd, date: Date.today) } + let(:other) { Money.new(0, :usd, date: Date.today - 1) } + + it { is_expected.to eq(true) } + end + + context "when money and other object are more than zero" do + context "when fractional, currency, and date are equal" do + let(:money) { Money.new(20, :usd, date: Date.today) } + let(:other) { Money.new(20, :usd, date: Date.today) } + + it { is_expected.to eq(true) } + end + + context "when fractionals differ" do + let(:money) { Money.new(20, :usd, date: Date.today) } + let(:other) { Money.new(40, :usd, date: Date.today) } + + it { is_expected.to eq(false) } + end + + context "when currencies differ" do + let(:money) { Money.new(20, :usd, date: Date.today) } + let(:other) { Money.new(20, :eur, date: Date.today) } + + it { is_expected.to eq(false) } + end + + context "when dates differ" do + let(:money) { Money.new(20, :usd, date: Date.today) } + let(:other) { Money.new(20, :usd, date: Date.today - 1) } + + it { is_expected.to eq(true) } + end + end + end + + context "when Money.date_determines_equality is true" do + around do |example| + Money.date_determines_equality = true + example.run + Money.date_determines_equality = false + end + + context "when other object is not an instance of Money" do + let(:money) { Money.new(20) } + let(:other) { "foo bar" } + + it { is_expected.to eq(false) } + end + + context "when money and other object are both zero" do + let(:money) { Money.new(0, :usd, date: Date.today) } + let(:other) { Money.new(0, :usd, date: Date.today - 1) } + + it { is_expected.to eq(true) } + end + + context "when money and other object are more than zero" do + context "when fractional, currency, and date are equal" do + let(:money) { Money.new(20, :usd, date: Date.today) } + let(:other) { Money.new(20, :usd, date: Date.today) } + + it { is_expected.to eq(true) } + end + + context "when fractionals differ" do + let(:money) { Money.new(20, :usd, date: Date.today) } + let(:other) { Money.new(40, :usd, date: Date.today) } + + it { is_expected.to eq(false) } + end + + context "when currencies differ" do + let(:money) { Money.new(20, :usd, date: Date.today) } + let(:other) { Money.new(20, :eur, date: Date.today) } + + it { is_expected.to eq(false) } + end + + context "when dates differ" do + let(:money) { Money.new(20, :usd, date: Date.today) } + let(:other) { Money.new(20, :usd, date: Date.today - 1) } + + it { is_expected.to eq(false) } + end + end + end + end + + describe "#<=>" do + context "by default" do + context "when other iz zero" do + subject { Money.new(20) <=> 0 } + + it { is_expected.to eq(1) } + end + + context "when other is Money with different currency but the value is zero" do + subject { Money.new(20, :usd) <=> Money.new(0, :eur) } + + it { is_expected.to eq(1) } + end + + context "when other is Money with different currency but the exchange rate isn't known" do + subject { Money.new(20, :usd) <=> Money.new(20, :eur) } + + it { is_expected.to eq(nil) } + end + + context "when other is Money with different currency but known exchange rate" do + subject { Money.new(20, :usd) <=> Money.new(20, :eur) } + + around do |example| + old_bank = Money.default_bank + example.run + Money.default_bank = old_bank + end + + before do + Money.default_bank = Money::Bank::VariableExchange.new + Money.add_rate(:eur, :usd, 1.25) + end + + it { is_expected.to eq(-1) } + end + + context "when other is Money with same currency but different date" do + subject { Money.new(20, :usd, date: Date.today - 1) <=> Money.new(20, :usd, date: Date.today) } + + it { is_expected.to eq(0) } + end + end + + context "when Money.date_determines_equality is true" do + around do |example| + Money.date_determines_equality = true + example.run + Money.date_determines_equality = false + end + + context "when zero is on the left side" do + subject { Money.new(20) <=> 0 } + + it { is_expected.to eq(1) } + end + + context "when zero is on the right side" do + subject { 0 <=> Money.new(20) } + + it { is_expected.to eq(-1) } + end + + context "when other is not Money and not zero" do + subject { Money.new(20) <=> 5 } + + it { is_expected.to eq(nil) } + end + + context "when other is Money with different currency but the value is zero" do + subject { Money.new(20, :usd) <=> Money.new(0, :eur) } + + it { is_expected.to eq(1) } + end + + context "when other is Money with different currency but the exchange rate isn't known" do + subject { Money.new(20, :usd) <=> Money.new(20, :eur) } + + it { is_expected.to eq(nil) } + end + + context "when other is Money with different currency but known exchange rate" do + subject { Money.new(20, :usd) <=> Money.new(20, :eur) } + + around do |example| + old_bank = Money.default_bank + example.run + Money.default_bank = old_bank + end + + before do + Money.default_bank = Money::Bank::VariableExchange.new + Money.add_rate(:eur, :usd, 1.25) + end + + it { is_expected.to eq(-1) } + end + + context "when other is Money with same currency but different date" do + subject { Money.new(20, :usd, date: Date.today - 1) <=> Money.new(20, :usd, date: Date.today) } + + it { is_expected.to eq(-1) } + end + end + end +end diff --git a/spec/money_with_date/rates_store/memory_spec.rb b/spec/money_with_date/rates_store/memory_spec.rb new file mode 100644 index 0000000..51fef68 --- /dev/null +++ b/spec/money_with_date/rates_store/memory_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +RSpec.describe MoneyWithDate::RatesStore::Memory do + let(:subject) { described_class.new } + + describe "#add_rate and #get_rate" do + it "stores rates in memory by date" do + expect(subject.add_rate("USD", "CAD", 0.9, Date.today)).to eq(0.9) + expect(subject.add_rate("USD", "CAD", 0.95, Date.today - 1)).to eq(0.95) + + expect(subject.get_rate("USD", "CAD", Date.today)).to eq(0.9) + expect(subject.get_rate("USD", "CAD", Date.today - 1)).to eq(0.95) + end + end + + describe "#add_rate" do + it "uses a mutex by default" do + expect(subject.instance_variable_get(:@guard)).to receive(:synchronize) + subject.add_rate("USD", "EUR", 1.25, Date.today) + end + end + + describe "#each_rate" do + before do + subject.add_rate("USD", "CAD", 0.9, Date.parse("2020-01-01")) + subject.add_rate("CAD", "USD", 1.1, Date.parse("2021-01-01")) + end + + it "iterates over rates" do + expect do |b| + subject.each_rate(&b) + end.to yield_successive_args(["USD", "CAD", 0.9, Date.parse("2020-01-01")], + ["CAD", "USD", 1.1, Date.parse("2021-01-01")]) + end + + it "is an Enumeator" do + expect(subject.each_rate).to be_kind_of(Enumerator) + result = subject.each_rate.each_with_object({}) { |(from, to, rate), m| m[[from, to].join] = rate } + expect(result).to match({ "USDCAD" => 0.9, "CADUSD" => 1.1 }) + end + end + + describe "#transaction" do + context "mutex" do + it "uses mutex" do + expect(subject.instance_variable_get("@guard")).to receive(:synchronize) + subject.transaction { 1 + 1 } + end + + it "wraps block in mutex transaction only once" do + expect do + subject.transaction do + subject.add_rate("USD", "CAD", 1, Date.today) + end + end.not_to raise_error + end + end + end + + describe "#marshal_dump" do + let(:subject) { described_class.new(optional: true) } + + it "can reload" do + bank = MoneyWithDate::Bank::VariableExchange.new(subject) + bank = Marshal.load(Marshal.dump(bank)) + expect(bank.store.instance_variable_get(:@options)).to eq(subject.instance_variable_get(:@options)) + expect(bank.store.instance_variable_get(:@index)).to eq(subject.instance_variable_get(:@index)) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..acb7223 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "simplecov" + +SimpleCov.start do + enable_coverage :branch + primary_coverage :branch + + minimum_coverage line: 100, branch: 100 + + add_filter "/spec/" +end + +require "money_with_date" + +Money.default_currency = :usd +Money.rounding_mode = BigDecimal::ROUND_HALF_UP + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end