business 1.17.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.circleci/config.yml +37 -0
- data/.rubocop.yml +13 -0
- data/.ruby-version +1 -1
- data/CHANGELOG.md +29 -1
- data/Gemfile +2 -0
- data/README.md +119 -76
- data/business.gemspec +10 -5
- data/lib/business.rb +3 -1
- data/lib/business/calendar.rb +81 -46
- data/lib/business/version.rb +3 -1
- data/spec/{calendar_spec.rb → business/calendar_spec.rb} +273 -106
- metadata +52 -20
- data/.travis.yml +0 -17
- data/lib/business/data/achus.yml +0 -58
- data/lib/business/data/bacs.yml +0 -129
- data/lib/business/data/bankgirot.yml +0 -208
- data/lib/business/data/becs.yml +0 -36
- data/lib/business/data/becsnz.yml +0 -50
- data/lib/business/data/betalingsservice.yml +0 -54
- data/lib/business/data/padca.yml +0 -27
- data/lib/business/data/target.yml +0 -101
- data/lib/business/data/targetfrance.yml +0 -130
- data/lib/business/data/weekdays.yml +0 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9a053f4e9b8b8fa5ed407b58d1e81155dac75f1f0c305410403011c608c69c60
|
|
4
|
+
data.tar.gz: 90cd733d6991516ae8c438824e669850d554147fbb47172f1c4f0dd0bdd44e37
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f09af74f16a84326d8b193f219b1d4de9362c618d197977e4c887df49a34e73d9ad3db154cfe7606fa43b3017b4448175ac37e598ea497e8f508888cf3a2bdb2
|
|
7
|
+
data.tar.gz: 9dc9a98ae02f7cb056af0bd710ac754594931c796d934b18695fafec5caecf1d7e4f107966863bc46d11e71e576b214cb774fd1a19dbe1d8c024483e55e27503
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
version: 2.1
|
|
2
|
+
|
|
3
|
+
jobs:
|
|
4
|
+
test:
|
|
5
|
+
docker:
|
|
6
|
+
- image: circleci/ruby:<< parameters.ruby-version >>
|
|
7
|
+
parameters:
|
|
8
|
+
ruby-version:
|
|
9
|
+
type: string
|
|
10
|
+
steps:
|
|
11
|
+
- checkout
|
|
12
|
+
- restore_cache:
|
|
13
|
+
keys:
|
|
14
|
+
- bundle-v1-<< parameters.ruby-version >>-{{ checksum "business.gemspec" }}
|
|
15
|
+
- bundle-v1-<< parameters.ruby-version >>-
|
|
16
|
+
- run:
|
|
17
|
+
name: Install dependencies
|
|
18
|
+
command: bundle install --clean --no-cache --path vendor/bundle --jobs=4 --retry=3
|
|
19
|
+
- save_cache:
|
|
20
|
+
key: bundle-v1-<< parameters.ruby-version >>-{{ checksum "business.gemspec" }}
|
|
21
|
+
paths:
|
|
22
|
+
- vendor/bundle
|
|
23
|
+
- run:
|
|
24
|
+
name: Run tests
|
|
25
|
+
command: bundle exec rspec
|
|
26
|
+
- run:
|
|
27
|
+
name: Run rubocop
|
|
28
|
+
command: bundle exec rubocop --parallel --extra-details --display-style-guide
|
|
29
|
+
|
|
30
|
+
workflows:
|
|
31
|
+
default:
|
|
32
|
+
jobs:
|
|
33
|
+
- test:
|
|
34
|
+
name: Ruby << matrix.ruby-version >>
|
|
35
|
+
matrix:
|
|
36
|
+
parameters:
|
|
37
|
+
ruby-version: ["2.4.9", "2.5.8", "2.6.6", "2.7.1"]
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
inherit_gem:
|
|
2
|
+
gc_ruboconfig: rubocop.yml
|
|
3
|
+
|
|
4
|
+
RSpec/NestedGroups:
|
|
5
|
+
Exclude:
|
|
6
|
+
- spec/business/calendar_spec.rb
|
|
7
|
+
|
|
8
|
+
# Disabled as API uses set_holidays etc
|
|
9
|
+
Naming/AccessorMethodName:
|
|
10
|
+
Enabled: false
|
|
11
|
+
|
|
12
|
+
Gemspec/RequiredRubyVersion:
|
|
13
|
+
Enabled: false
|
data/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
2.7.1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
|
-
##
|
|
1
|
+
## 2.2.0 - March 4, 2021
|
|
2
|
+
|
|
3
|
+
- Add `Business::Calendar#name` - thanks @mattmcf!
|
|
4
|
+
|
|
5
|
+
## 2.1.0 - June 8, 2020
|
|
6
|
+
|
|
7
|
+
- Add seperate `working_day?` and `holiday?` methods to the calendar
|
|
8
|
+
|
|
9
|
+
## 2.0.0 - May 4, 2020
|
|
10
|
+
|
|
11
|
+
🚨 **BREAKING CHANGES** 🚨
|
|
12
|
+
|
|
13
|
+
For more on the breaking changes that have been introduced in v2.0.0 please [see the readme](README.md#v200-breaking-changes).
|
|
14
|
+
|
|
15
|
+
- Remove bundled calendars see [this pr](https://github.com/gocardless/business/pull/54) for more context. If you need to use any of the previously bundled calendars, [see here](https://github.com/gocardless/business/tree/b12c186ca6fd4ffdac85175742ff7e4d0a705ef4/lib/business/data)
|
|
16
|
+
- `Business::Calendar.load_paths=` is now required
|
|
17
|
+
|
|
18
|
+
## 1.18.0 - April 30, 2020
|
|
19
|
+
|
|
20
|
+
### Note we have dropped support for Ruby < 2.4.x
|
|
21
|
+
|
|
22
|
+
- Correct Danish public holidays
|
|
23
|
+
|
|
24
|
+
## 1.17.1 - November 19, 2019
|
|
25
|
+
|
|
26
|
+
- Change May Bank Holiday 2020 for UK (Bacs) - this was moved to the 8th.
|
|
27
|
+
- Add 2020 holidays for PAD.
|
|
28
|
+
|
|
29
|
+
## 1.17.0 - October 30, 2019
|
|
2
30
|
|
|
3
31
|
- Add holiday calendar for France (Target(SEPA) + French bank holidays)
|
|
4
32
|
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -1,61 +1,128 @@
|
|
|
1
1
|
# Business
|
|
2
2
|
|
|
3
3
|
[](http://badge.fury.io/rb/business)
|
|
4
|
-
[](https://circleci.com/gh/gocardless/business)
|
|
5
5
|
|
|
6
6
|
Date calculations based on business calendars.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
- [v2.0.0 breaking changes](#v200-breaking-changes)
|
|
9
|
+
- [Getting Started](#getting-started)
|
|
10
|
+
- [Creating a calendar](#creating-a-calendar)
|
|
11
|
+
- [Using a calendar file](#use-a-calendar-file)
|
|
12
|
+
- [Checking for business days](#checking-for-business-days)
|
|
13
|
+
- [Business day arithmetic](#business-day-arithmetic)
|
|
14
|
+
- [But other libraries already do this](#but-other-libraries-already-do-this)
|
|
15
|
+
- [License & Contributing](#license--contributing)
|
|
9
16
|
|
|
10
|
-
|
|
17
|
+
## v2.0.0 breaking changes
|
|
18
|
+
|
|
19
|
+
We have removed the bundled calendars as of version 2.0.0, if you need the calendars that were included:
|
|
20
|
+
|
|
21
|
+
- Download the calendars you wish to use from [v1.18.0](https://github.com/gocardless/business/tree/b12c186ca6fd4ffdac85175742ff7e4d0a705ef4/lib/business/data)
|
|
22
|
+
- Place them in a suitable directory in your project, typically `lib/calendars`
|
|
23
|
+
- Add this directory path to your instance of `Business::Calendar` using the `load_paths` method.dd the directory to where you placed the yml files before you load the calendar
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
Business::Calendar.load_paths = ["lib/calendars"] # your_project/lib/calendars/ contains bacs.yml
|
|
27
|
+
Business::Calendar.load("bacs")
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If you wish to stay on the last version that contained bundled calendars, pin `business` to `v1.18.0`
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# Gemfile
|
|
34
|
+
gem "business", "v1.18.0"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Getting started
|
|
38
|
+
|
|
39
|
+
To install business, simply:
|
|
11
40
|
|
|
12
41
|
```bash
|
|
13
|
-
|
|
42
|
+
gem install business
|
|
14
43
|
```
|
|
15
44
|
|
|
16
|
-
|
|
45
|
+
If you are using a Gemfile:
|
|
17
46
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
47
|
+
```ruby
|
|
48
|
+
gem "business", "~> 2.0"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Creating a calendar
|
|
52
|
+
|
|
53
|
+
Get started with business by creating an instance of the calendar class, that accepts a hash that specifies which days of the week are considered working days, which days are holidays and which are extra working dates.
|
|
21
54
|
|
|
22
55
|
```ruby
|
|
23
56
|
calendar = Business::Calendar.new(
|
|
24
57
|
working_days: %w( mon tue wed thu fri ),
|
|
25
58
|
holidays: ["01/01/2014", "03/01/2014"] # array items are either parseable date strings, or real Date objects
|
|
59
|
+
extra_working_dates: [nil], # Makes the calendar to consider a weekend day as a working day.
|
|
26
60
|
)
|
|
27
61
|
```
|
|
28
62
|
|
|
29
|
-
|
|
63
|
+
### Use a calendar file
|
|
30
64
|
|
|
31
|
-
|
|
32
|
-
details). Load them by calling the `load` class method on `Calendar`. The
|
|
33
|
-
`load_cached` variant of this method caches the calendars by name after loading
|
|
34
|
-
them, to avoid reading and parsing the config file multiple times.
|
|
65
|
+
Defining a calendar as a Ruby object may not be convenient, so we provide a way of defining these calendars as YAML. Below we will walk through the necessary [steps](#example-calendar) to build your first calendar. All keys are optional and will default to the following:
|
|
35
66
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
```
|
|
67
|
+
Note: Elements of `holidays` and `extra_working_dates` may be either strings that `Date.parse()` [can understand](https://ruby-doc.org/stdlib-2.7.1/libdoc/date/rdoc/Date.html#method-c-parse), or `YYYY-MM-DD` (which is considered as a Date by Ruby YAML itself)[https://github.com/ruby/psych/blob/6ec6e475e8afcf7868b0407fc08014aed886ecf1/lib/psych/scalar_scanner.rb#L60].
|
|
68
|
+
|
|
69
|
+
#### YAML file Structure
|
|
40
70
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
71
|
+
```yml
|
|
72
|
+
working_days: # Optional, default [Monday-Friday]
|
|
73
|
+
-
|
|
74
|
+
holidays: # Optional, default: [] ie: "no holidays" assumed
|
|
75
|
+
-
|
|
76
|
+
extra_working_dates: # Optional, default: [], ie: no changes in `working_days` will happen
|
|
77
|
+
-
|
|
78
|
+
```
|
|
44
79
|
|
|
45
|
-
|
|
46
|
-
eiter strings that `Date.parse()` can understand,
|
|
47
|
-
or YYYY-MM-DD (which is considered as a Date by Ruby YAML itself).
|
|
80
|
+
#### Example calendar
|
|
48
81
|
|
|
49
82
|
```yaml
|
|
83
|
+
# lib/calendars/my_calendar.yml
|
|
84
|
+
working_days:
|
|
85
|
+
- Monday
|
|
86
|
+
- Wednesday
|
|
87
|
+
- Friday
|
|
50
88
|
holidays:
|
|
51
|
-
-
|
|
89
|
+
- 1st April 2020
|
|
90
|
+
- 2021-04-01
|
|
91
|
+
extra_working_dates:
|
|
92
|
+
- 9th March 2020 # A Saturday
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Ensure the calendar file is saved to a directory that will hold all your calendars, typically `lib/calendars`, then add this directory to your instance of `Business::Calendar` using the `load_paths` method before you call your calendar.
|
|
96
|
+
|
|
97
|
+
`load_paths` also accepts an array of plain Ruby hashes with the format:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
{ "calendar_name" => { "working_days" => [] }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
#### Example loading both a path and ruby hashes
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
Business::Calendar.load_paths = [
|
|
107
|
+
"lib/calendars",
|
|
108
|
+
{ "foo_calendar" => { "working_days" => ["monday"] } },
|
|
109
|
+
{ "bar_calendar" => { "working_days" => ["sunday"] } },
|
|
110
|
+
]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Now you can load the calendar by calling the `Business::Calendar.load(calendar_name)`. In order to avoid parsing the calendar file multiple times, there is a `Business::Calendar.load_cached(calendar_name)` method that caches the calendars by name after loading them.
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
calendar = Business::Calendar.load("my_calendar") # lib/calendars/my_calendar.yml
|
|
117
|
+
calendar = Business::Calendar.load("foo_calendar")
|
|
118
|
+
# or
|
|
119
|
+
calendar = Business::Calendar.load_cached("my_calendar")
|
|
120
|
+
calendar = Business::Calendar.load_cached("foo_calendar")
|
|
52
121
|
```
|
|
53
122
|
|
|
54
|
-
|
|
123
|
+
## Checking for business days
|
|
55
124
|
|
|
56
|
-
To check whether a given date is a business day (falls on one of the specified
|
|
57
|
-
working days or working dates, and is not a holiday), use the `business_day?`
|
|
58
|
-
method on `Calendar`.
|
|
125
|
+
To check whether a given date is a business day (falls on one of the specified working days or working dates, and is not a holiday), use the `business_day?` method on `Business::Calendar`.
|
|
59
126
|
|
|
60
127
|
```ruby
|
|
61
128
|
calendar.business_day?(Date.parse("Monday, 9 June 2014"))
|
|
@@ -64,21 +131,23 @@ calendar.business_day?(Date.parse("Sunday, 8 June 2014"))
|
|
|
64
131
|
# => false
|
|
65
132
|
```
|
|
66
133
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
To use a calendar you've written yourself, you need to add the directory it's
|
|
70
|
-
stored in as an additional calendar load path:
|
|
134
|
+
More specifically you can check if a given `business_day?` is either a `working_day?` or a `holiday?` using methods on `Business::Calendar`.
|
|
71
135
|
|
|
72
136
|
```ruby
|
|
73
|
-
|
|
137
|
+
# Assuming "Monday, 9 June 2014" is a holiday
|
|
138
|
+
calendar.working_day?(Date.parse("Monday, 9 June 2014"))
|
|
139
|
+
# => true
|
|
140
|
+
calendar.holiday?(Date.parse("Monday, 9 June 2014"))
|
|
141
|
+
# => true
|
|
142
|
+
# Monday is a working day, but we have a holiday so it's not
|
|
143
|
+
# a business day
|
|
144
|
+
calendar.business_day?(Date.parse("Monday, 9 June 2014"))
|
|
145
|
+
# => false
|
|
74
146
|
```
|
|
75
147
|
|
|
76
|
-
|
|
148
|
+
## Business day arithmetic
|
|
77
149
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
The `add_business_days` and `subtract_business_days` are used to perform
|
|
81
|
-
business day arithmetic on dates.
|
|
150
|
+
The `add_business_days` and `subtract_business_days` are used to perform business day arithmetic on dates.
|
|
82
151
|
|
|
83
152
|
```ruby
|
|
84
153
|
date = Date.parse("Thursday, 12 June 2014")
|
|
@@ -88,10 +157,7 @@ calendar.subtract_business_days(date, 4).strftime("%A, %d %B %Y")
|
|
|
88
157
|
# => "Friday, 06 June 2014"
|
|
89
158
|
```
|
|
90
159
|
|
|
91
|
-
The `roll_forward` and `roll_backward` methods snap a date to a nearby business
|
|
92
|
-
day. If provided with a business day, they will return that date. Otherwise,
|
|
93
|
-
they will advance (forward for `roll_forward` and backward for `roll_backward`)
|
|
94
|
-
until a business day is found.
|
|
160
|
+
The `roll_forward` and `roll_backward` methods snap a date to a nearby business day. If provided with a business day, they will return that date. Otherwise, they will advance (forward for `roll_forward` and backward for `roll_backward`) until a business day is found.
|
|
95
161
|
|
|
96
162
|
```ruby
|
|
97
163
|
date = Date.parse("Saturday, 14 June 2014")
|
|
@@ -101,10 +167,7 @@ calendar.roll_backward(date).strftime("%A, %d %B %Y")
|
|
|
101
167
|
# => "Friday, 13 June 2014"
|
|
102
168
|
```
|
|
103
169
|
|
|
104
|
-
To count the number of business days between two dates, pass the dates to
|
|
105
|
-
`business_days_between`. This method counts from start of the first date to
|
|
106
|
-
start of the second date. So, assuming no holidays, there would be two business
|
|
107
|
-
days between a Monday and a Wednesday.
|
|
170
|
+
To count the number of business days between two dates, pass the dates to `business_days_between`. This method counts from start of the first date to start of the second date. So, assuming no holidays, there would be two business days between a Monday and a Wednesday.
|
|
108
171
|
|
|
109
172
|
```ruby
|
|
110
173
|
date = Date.parse("Saturday, 14 June 2014")
|
|
@@ -112,39 +175,19 @@ calendar.business_days_between(date, date + 7)
|
|
|
112
175
|
# => 5
|
|
113
176
|
```
|
|
114
177
|
|
|
115
|
-
### Included Calendars
|
|
116
|
-
|
|
117
|
-
We include some calendar data with this Gem but give no guarantees of its
|
|
118
|
-
accuracy.
|
|
119
|
-
The calendars that we include are:
|
|
120
|
-
|
|
121
|
-
* Bacs
|
|
122
|
-
* Bankgirot
|
|
123
|
-
* BECS (Australia)
|
|
124
|
-
* BECSNZ (New Zealand)
|
|
125
|
-
* PAD (Canada)
|
|
126
|
-
* Betalingsservice
|
|
127
|
-
* Target (SEPA)
|
|
128
|
-
* TargetFrance (SEPA + French bank holidays)
|
|
129
|
-
|
|
130
178
|
## But other libraries already do this
|
|
131
179
|
|
|
132
|
-
Another gem, [business_time](https://github.com/bokmann/business_time), also
|
|
133
|
-
|
|
134
|
-
|
|
180
|
+
Another gem, [business_time](https://github.com/bokmann/business_time), also exists for this purpose. We previously used business_time, but encountered several issues that prompted us to start business.
|
|
181
|
+
|
|
182
|
+
Firstly, business_time works by monkey-patching `Date`, `Time`, and `FixNum`. While this enables syntax like `Time.now + 1.business_day`, it means that all configuration has to be global. GoCardless handles payments across several geographies, so being able to work with multiple working-day calendars is
|
|
183
|
+
essential for us. Business provides a simple `Calendar` class, that is initialized with a configuration that specifies which days of the week are considered to be working days, and which dates are holidays.
|
|
135
184
|
|
|
136
|
-
|
|
137
|
-
While this enables syntax like `Time.now + 1.business_day`, it means that all
|
|
138
|
-
configuration has to be global. GoCardless handles payments across several
|
|
139
|
-
geographies, so being able to work with multiple working-day calendars is
|
|
140
|
-
essential for us. Business provides a simple `Calendar` class, that is
|
|
141
|
-
initialized with a configuration that specifies which days of the week are
|
|
142
|
-
considered to be working days, and which dates are holidays.
|
|
185
|
+
Secondly, business_time supports calculations on times as well as dates. For our purposes, date-based calculations are sufficient. Supporting time-based calculations as well makes the code significantly more complex. We chose to avoid this extra complexity by sticking solely to date-based mathematics.
|
|
143
186
|
|
|
144
|
-
|
|
145
|
-
our purposes, date-based calculations are sufficient. Supporting time-based
|
|
146
|
-
calculations as well makes the code significantly more complex. We chose to
|
|
147
|
-
avoid this extra complexity by sticking solely to date-based mathematics.
|
|
187
|
+
<p align="center"><img src="http://3.bp.blogspot.com/-aq4iOz2OZzs/Ty8xaQwMhtI/AAAAAAAABrM/-vn4tcRA9-4/s1600/daily-morning-awesomeness-243.jpeg" alt="I'm late for business" width="250"/></p>
|
|
148
188
|
|
|
189
|
+
## License & Contributing
|
|
190
|
+
- business is available as open source under the terms of the [MIT License](LICENSE).
|
|
191
|
+
- Bug reports and pull requests are welcome on GitHub at https://github.com/gocardless/business.
|
|
149
192
|
|
|
150
|
-
|
|
193
|
+
GoCardless ♥ open source. If you do too, come [join us](https://gocardless.com/about/jobs).
|
data/business.gemspec
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
# coding: utf-8
|
|
2
|
-
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
lib = File.expand_path("lib", __dir__)
|
|
3
5
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
4
|
-
require
|
|
6
|
+
require "business/version"
|
|
5
7
|
|
|
6
8
|
Gem::Specification.new do |spec|
|
|
7
9
|
spec.name = "business"
|
|
8
10
|
spec.version = Business::VERSION
|
|
9
11
|
spec.authors = ["Harry Marr"]
|
|
10
12
|
spec.email = ["developers@gocardless.com"]
|
|
11
|
-
spec.summary =
|
|
12
|
-
spec.description =
|
|
13
|
+
spec.summary = "Date calculations based on business calendars"
|
|
14
|
+
spec.description = "Date calculations based on business calendars"
|
|
13
15
|
spec.homepage = "https://github.com/gocardless/business"
|
|
14
16
|
spec.licenses = ["MIT"]
|
|
15
17
|
|
|
16
|
-
spec.files = `git ls-files`.split(
|
|
18
|
+
spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
|
|
17
19
|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
|
18
20
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
|
19
21
|
spec.require_paths = ["lib"]
|
|
20
22
|
|
|
23
|
+
spec.add_development_dependency "gc_ruboconfig", "~> 2.24.0"
|
|
21
24
|
spec.add_development_dependency "rspec", "~> 3.1"
|
|
25
|
+
spec.add_development_dependency "rspec_junit_formatter", "~> 0.4.1"
|
|
26
|
+
spec.add_development_dependency "rubocop", "~> 1.10.0"
|
|
22
27
|
end
|
data/lib/business.rb
CHANGED
data/lib/business/calendar.rb
CHANGED
|
@@ -1,63 +1,90 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "date"
|
|
2
5
|
|
|
3
6
|
module Business
|
|
4
7
|
class Calendar
|
|
8
|
+
VALID_KEYS = %w[holidays working_days extra_working_dates].freeze
|
|
9
|
+
|
|
5
10
|
class << self
|
|
6
|
-
attr_accessor :
|
|
11
|
+
attr_accessor :load_paths
|
|
7
12
|
end
|
|
8
13
|
|
|
9
14
|
def self.calendar_directories
|
|
10
|
-
|
|
11
|
-
directories + [File.join(File.dirname(__FILE__), 'data')]
|
|
15
|
+
@load_paths
|
|
12
16
|
end
|
|
13
17
|
private_class_method :calendar_directories
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
# rubocop:disable Metrics/MethodLength
|
|
20
|
+
def self.load(calendar_name)
|
|
21
|
+
data = find_calendar_data(calendar_name)
|
|
22
|
+
raise "No such calendar '#{calendar_name}'" unless data
|
|
23
|
+
|
|
24
|
+
unless (data.keys - VALID_KEYS).empty?
|
|
25
|
+
raise "Only valid keys are: #{VALID_KEYS.join(', ')}"
|
|
18
26
|
end
|
|
19
|
-
raise "No such calendar '#{calendar}'" unless directory
|
|
20
27
|
|
|
21
|
-
|
|
22
|
-
|
|
28
|
+
new(
|
|
29
|
+
name: calendar_name,
|
|
30
|
+
holidays: data["holidays"],
|
|
31
|
+
working_days: data["working_days"],
|
|
32
|
+
extra_working_dates: data["extra_working_dates"],
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
# rubocop:enable Metrics/MethodLength
|
|
23
36
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
37
|
+
def self.find_calendar_data(calendar_name)
|
|
38
|
+
calendar_directories.detect do |path|
|
|
39
|
+
if path.is_a?(Hash)
|
|
40
|
+
break path[calendar_name] if path[calendar_name]
|
|
41
|
+
else
|
|
42
|
+
next unless File.exist?(File.join(path, "#{calendar_name}.yml"))
|
|
27
43
|
|
|
28
|
-
|
|
29
|
-
|
|
44
|
+
break YAML.load_file(File.join(path, "#{calendar_name}.yml"))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
30
47
|
end
|
|
31
48
|
|
|
32
49
|
@lock = Mutex.new
|
|
33
50
|
def self.load_cached(calendar)
|
|
34
51
|
@lock.synchronize do
|
|
35
|
-
@cache ||= {
|
|
36
|
-
unless @cache.include?(calendar)
|
|
37
|
-
@cache[calendar] = self.load(calendar)
|
|
38
|
-
end
|
|
52
|
+
@cache ||= {}
|
|
53
|
+
@cache[calendar] = self.load(calendar) unless @cache.include?(calendar)
|
|
39
54
|
@cache[calendar]
|
|
40
55
|
end
|
|
41
56
|
end
|
|
42
57
|
|
|
43
58
|
DAY_NAMES = %( mon tue wed thu fri sat sun )
|
|
44
59
|
|
|
45
|
-
attr_reader :holidays, :working_days, :extra_working_dates
|
|
60
|
+
attr_reader :name, :holidays, :working_days, :extra_working_dates
|
|
61
|
+
|
|
62
|
+
def initialize(name:, extra_working_dates: nil, working_days: nil, holidays: nil)
|
|
63
|
+
@name = name
|
|
64
|
+
set_extra_working_dates(extra_working_dates)
|
|
65
|
+
set_working_days(working_days)
|
|
66
|
+
set_holidays(holidays)
|
|
46
67
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
set_holidays(config[:holidays])
|
|
68
|
+
unless (@holidays & @extra_working_dates).none?
|
|
69
|
+
raise ArgumentError, "Holidays cannot be extra working dates"
|
|
70
|
+
end
|
|
51
71
|
end
|
|
52
72
|
|
|
53
73
|
# Return true if the date given is a business day (typically that means a
|
|
54
74
|
# non-weekend day) and not a holiday.
|
|
55
75
|
def business_day?(date)
|
|
56
76
|
date = date.to_date
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
77
|
+
working_day?(date) && !holiday?(date)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def working_day?(date)
|
|
81
|
+
date = date.to_date
|
|
82
|
+
extra_working_dates.include?(date) ||
|
|
83
|
+
working_days.include?(date.strftime("%a").downcase)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def holiday?(date)
|
|
87
|
+
holidays.include?(date.to_date)
|
|
61
88
|
end
|
|
62
89
|
|
|
63
90
|
# Roll forward to the next business day. If the date given is a business
|
|
@@ -79,19 +106,19 @@ module Business
|
|
|
79
106
|
# Roll forward to the next business day regardless of whether the given
|
|
80
107
|
# date is a business day or not.
|
|
81
108
|
def next_business_day(date)
|
|
82
|
-
|
|
109
|
+
loop do
|
|
83
110
|
date += day_interval_for(date)
|
|
84
|
-
|
|
85
|
-
|
|
111
|
+
break date if business_day?(date)
|
|
112
|
+
end
|
|
86
113
|
end
|
|
87
114
|
|
|
88
115
|
# Roll backward to the previous business day regardless of whether the given
|
|
89
116
|
# date is a business day or not.
|
|
90
117
|
def previous_business_day(date)
|
|
91
|
-
|
|
118
|
+
loop do
|
|
92
119
|
date -= day_interval_for(date)
|
|
93
|
-
|
|
94
|
-
|
|
120
|
+
break date if business_day?(date)
|
|
121
|
+
end
|
|
95
122
|
end
|
|
96
123
|
|
|
97
124
|
# Add a number of business days to a date. If a non-business day is given,
|
|
@@ -102,9 +129,10 @@ module Business
|
|
|
102
129
|
def add_business_days(date, delta)
|
|
103
130
|
date = roll_forward(date)
|
|
104
131
|
delta.times do
|
|
105
|
-
|
|
132
|
+
loop do
|
|
106
133
|
date += day_interval_for(date)
|
|
107
|
-
|
|
134
|
+
break date if business_day?(date)
|
|
135
|
+
end
|
|
108
136
|
end
|
|
109
137
|
date
|
|
110
138
|
end
|
|
@@ -117,9 +145,10 @@ module Business
|
|
|
117
145
|
def subtract_business_days(date, delta)
|
|
118
146
|
date = roll_backward(date)
|
|
119
147
|
delta.times do
|
|
120
|
-
|
|
148
|
+
loop do
|
|
121
149
|
date -= day_interval_for(date)
|
|
122
|
-
|
|
150
|
+
break date if business_day?(date)
|
|
151
|
+
end
|
|
123
152
|
end
|
|
124
153
|
date
|
|
125
154
|
end
|
|
@@ -127,8 +156,11 @@ module Business
|
|
|
127
156
|
# Count the number of business days between two dates.
|
|
128
157
|
# This method counts from start of date1 to start of date2. So,
|
|
129
158
|
# business_days_between(mon, weds) = 2 (assuming no holidays)
|
|
159
|
+
# rubocop:disable Metrics/AbcSize
|
|
160
|
+
# rubocop:disable Metrics/MethodLength
|
|
130
161
|
def business_days_between(date1, date2)
|
|
131
|
-
date1
|
|
162
|
+
date1 = date1.to_date
|
|
163
|
+
date2 = date2.to_date
|
|
132
164
|
|
|
133
165
|
# To optimise this method we split the range into full weeks and a
|
|
134
166
|
# remaining period.
|
|
@@ -150,14 +182,16 @@ module Business
|
|
|
150
182
|
num_biz_days -= holidays.count do |holiday|
|
|
151
183
|
in_range = full_weeks_range.cover?(holiday)
|
|
152
184
|
# Only pick a holiday if its on a working day (e.g., not a weekend)
|
|
153
|
-
on_biz_day = working_days.include?(holiday.strftime(
|
|
185
|
+
on_biz_day = working_days.include?(holiday.strftime("%a").downcase)
|
|
154
186
|
in_range && on_biz_day
|
|
155
187
|
end
|
|
156
188
|
|
|
157
|
-
remaining_range = (date2-remaining_days...date2)
|
|
189
|
+
remaining_range = (date2 - remaining_days...date2)
|
|
158
190
|
# Loop through each day in remaining_range and count if a business day
|
|
159
191
|
num_biz_days + remaining_range.count { |a| business_day?(a) }
|
|
160
192
|
end
|
|
193
|
+
# rubocop:enable Metrics/AbcSize
|
|
194
|
+
# rubocop:enable Metrics/MethodLength
|
|
161
195
|
|
|
162
196
|
def day_interval_for(date)
|
|
163
197
|
date.is_a?(Date) ? 1 : 3600 * 24
|
|
@@ -170,9 +204,12 @@ module Business
|
|
|
170
204
|
raise "Invalid day #{day}" unless DAY_NAMES.include?(normalised_day)
|
|
171
205
|
end
|
|
172
206
|
end
|
|
173
|
-
extra_working_dates_names = @extra_working_dates.map
|
|
207
|
+
extra_working_dates_names = @extra_working_dates.map do |d|
|
|
208
|
+
d.strftime("%a").downcase
|
|
209
|
+
end
|
|
174
210
|
return if (extra_working_dates_names & @working_days).none?
|
|
175
|
-
|
|
211
|
+
|
|
212
|
+
raise ArgumentError, "Extra working dates cannot be on working days"
|
|
176
213
|
end
|
|
177
214
|
|
|
178
215
|
def parse_dates(dates)
|
|
@@ -182,8 +219,6 @@ module Business
|
|
|
182
219
|
# Internal method for assigning holidays from a calendar config.
|
|
183
220
|
def set_holidays(holidays)
|
|
184
221
|
@holidays = parse_dates(holidays)
|
|
185
|
-
return if (@holidays & @extra_working_dates).none?
|
|
186
|
-
raise ArgumentError, 'Holidays cannot be extra working dates'
|
|
187
222
|
end
|
|
188
223
|
|
|
189
224
|
def set_extra_working_dates(extra_working_dates)
|
|
@@ -192,7 +227,7 @@ module Business
|
|
|
192
227
|
|
|
193
228
|
# If no working days are provided in the calendar config, these are used.
|
|
194
229
|
def default_working_days
|
|
195
|
-
%w
|
|
230
|
+
%w[mon tue wed thu fri]
|
|
196
231
|
end
|
|
197
232
|
end
|
|
198
233
|
end
|