attio-ruby 0.1.3 → 0.1.5

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.
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ module Util
5
+ # Utility class for formatting currency amounts
6
+ class CurrencyFormatter
7
+ # Map of currency codes to their symbols
8
+ CURRENCY_SYMBOLS = {
9
+ "USD" => "$",
10
+ "EUR" => "€",
11
+ "GBP" => "£",
12
+ "JPY" => "¥",
13
+ "CNY" => "¥",
14
+ "INR" => "₹",
15
+ "KRW" => "₩",
16
+ "CAD" => "$",
17
+ "AUD" => "$",
18
+ "CHF" => "CHF ",
19
+ "SEK" => "SEK ",
20
+ "NOK" => "NOK ",
21
+ "DKK" => "DKK ",
22
+ "PLN" => "zł",
23
+ "BRL" => "R$",
24
+ "MXN" => "$",
25
+ "NZD" => "$",
26
+ "SGD" => "$",
27
+ "HKD" => "$",
28
+ "ZAR" => "R",
29
+ "THB" => "฿",
30
+ "PHP" => "₱",
31
+ "IDR" => "Rp",
32
+ "MYR" => "RM",
33
+ "VND" => "₫",
34
+ "TRY" => "₺",
35
+ "RUB" => "₽",
36
+ "UAH" => "₴",
37
+ "ILS" => "₪",
38
+ "AED" => "د.إ",
39
+ "SAR" => "﷼",
40
+ "CLP" => "$",
41
+ "COP" => "$",
42
+ "PEN" => "S/",
43
+ "ARS" => "$"
44
+ }.freeze
45
+
46
+ # Currencies that typically don't use decimal places
47
+ NO_DECIMAL_CURRENCIES = %w[JPY KRW VND IDR CLP].freeze
48
+
49
+ class << self
50
+ # Format an amount with the appropriate currency symbol
51
+ # @param amount [Numeric] The amount to format
52
+ # @param currency_code [String] The ISO 4217 currency code
53
+ # @param options [Hash] Formatting options
54
+ # @option options [Integer] :decimal_places Number of decimal places (auto-determined by default)
55
+ # @option options [String] :thousands_separator Character for thousands separation (default: ",")
56
+ # @option options [String] :decimal_separator Character for decimal separation (default: ".")
57
+ # @return [String] The formatted currency string
58
+ def format(amount, currency_code = "USD", options = {})
59
+ currency_code = currency_code.to_s.upcase
60
+ symbol = symbol_for(currency_code)
61
+
62
+ # Determine decimal places
63
+ decimal_places = options[:decimal_places] || decimal_places_for(currency_code)
64
+ thousands_sep = options[:thousands_separator] || ","
65
+ decimal_sep = options[:decimal_separator] || "."
66
+
67
+ # Handle zero amounts
68
+ if amount == 0
69
+ if decimal_places > 0
70
+ return "#{symbol}0#{decimal_sep}#{"0" * decimal_places}"
71
+ else
72
+ return "#{symbol}0"
73
+ end
74
+ end
75
+
76
+ # Handle negative amounts
77
+ negative = amount < 0
78
+ abs_amount = amount.abs
79
+
80
+ # Format the amount
81
+ if decimal_places == 0
82
+ # No decimal places
83
+ formatted = format_with_separators(abs_amount.to_i, thousands_sep)
84
+ formatted = "-#{formatted}" if negative
85
+ "#{symbol}#{formatted}"
86
+ else
87
+ # With decimal places
88
+ whole = abs_amount.to_i
89
+ decimal = ((abs_amount - whole) * (10**decimal_places)).round
90
+ formatted_whole = format_with_separators(whole, thousands_sep)
91
+ formatted_whole = "-#{formatted_whole}" if negative
92
+ formatted_decimal = decimal.to_s.rjust(decimal_places, "0")
93
+ "#{symbol}#{formatted_whole}#{decimal_sep}#{formatted_decimal}"
94
+ end
95
+ end
96
+
97
+ # Get the currency symbol for a given code
98
+ # @param currency_code [String] The ISO 4217 currency code
99
+ # @return [String] The currency symbol or code with space
100
+ def symbol_for(currency_code)
101
+ currency_code = currency_code.to_s.upcase
102
+ CURRENCY_SYMBOLS[currency_code] || "#{currency_code} "
103
+ end
104
+
105
+ # Determine the number of decimal places for a currency
106
+ # @param currency_code [String] The ISO 4217 currency code
107
+ # @return [Integer] Number of decimal places
108
+ def decimal_places_for(currency_code)
109
+ currency_code = currency_code.to_s.upcase
110
+ NO_DECIMAL_CURRENCIES.include?(currency_code) ? 0 : 2
111
+ end
112
+
113
+ # Check if a currency typically uses decimal places
114
+ # @param currency_code [String] The ISO 4217 currency code
115
+ # @return [Boolean] True if the currency uses decimals
116
+ def uses_decimals?(currency_code)
117
+ decimal_places_for(currency_code) > 0
118
+ end
119
+
120
+ # Format just the numeric part without currency symbol
121
+ # @param amount [Numeric] The amount to format
122
+ # @param currency_code [String] The ISO 4217 currency code
123
+ # @param options [Hash] Formatting options (same as format method)
124
+ # @return [String] The formatted number without currency symbol
125
+ def format_number(amount, currency_code = "USD", options = {})
126
+ result = format(amount, currency_code, options)
127
+ symbol = symbol_for(currency_code)
128
+ result.sub(/^#{Regexp.escape(symbol)}/, "")
129
+ end
130
+
131
+ private
132
+
133
+ # Add thousands separators to a number
134
+ # @param number [Integer] The number to format
135
+ # @param separator [String] The separator character
136
+ # @return [String] The formatted number
137
+ def format_with_separators(number, separator)
138
+ number.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1#{separator}").reverse
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Attio
6
+ module Util
7
+ # Utility class for time period calculations
8
+ class TimePeriod
9
+ attr_reader :start_date, :end_date
10
+
11
+ def initialize(start_date, end_date)
12
+ @start_date = parse_date(start_date)
13
+ @end_date = parse_date(end_date)
14
+ end
15
+
16
+ private
17
+
18
+ def parse_date(date)
19
+ case date
20
+ when Date
21
+ date
22
+ when String
23
+ Date.parse(date)
24
+ else
25
+ date.to_date
26
+ end
27
+ end
28
+
29
+ public
30
+
31
+ # Named constructors for common periods
32
+
33
+ # Current year to date
34
+ def self.year_to_date
35
+ today = Date.today
36
+ new(Date.new(today.year, 1, 1), today)
37
+ end
38
+
39
+ # Current month to date
40
+ def self.month_to_date
41
+ today = Date.today
42
+ new(Date.new(today.year, today.month, 1), today)
43
+ end
44
+
45
+ # Current quarter to date
46
+ def self.quarter_to_date
47
+ today = Date.today
48
+ quarter = (today.month - 1) / 3 + 1
49
+ quarter_start = Date.new(today.year, (quarter - 1) * 3 + 1, 1)
50
+ new(quarter_start, today)
51
+ end
52
+
53
+ # Specific quarter
54
+ def self.quarter(year, quarter_num)
55
+ raise ArgumentError, "Quarter must be between 1 and 4" unless (1..4).cover?(quarter_num)
56
+ quarter_start = Date.new(year, (quarter_num - 1) * 3 + 1, 1)
57
+ quarter_end = (quarter_start >> 3) - 1
58
+ new(quarter_start, quarter_end)
59
+ end
60
+
61
+ # Current quarter (full quarter, not QTD)
62
+ def self.current_quarter
63
+ today = Date.today
64
+ quarter(today.year, (today.month - 1) / 3 + 1)
65
+ end
66
+
67
+ # Previous quarter
68
+ def self.previous_quarter
69
+ today = Date.today
70
+ current_q = (today.month - 1) / 3 + 1
71
+ if current_q == 1
72
+ quarter(today.year - 1, 4)
73
+ else
74
+ quarter(today.year, current_q - 1)
75
+ end
76
+ end
77
+
78
+ # Specific month
79
+ def self.month(year, month_num)
80
+ raise ArgumentError, "Month must be between 1 and 12" unless (1..12).cover?(month_num)
81
+ month_start = Date.new(year, month_num, 1)
82
+ month_end = (month_start >> 1) - 1
83
+ new(month_start, month_end)
84
+ end
85
+
86
+ # Current month (full month, not MTD)
87
+ def self.current_month
88
+ today = Date.today
89
+ month(today.year, today.month)
90
+ end
91
+
92
+ # Previous month
93
+ def self.previous_month
94
+ today = Date.today
95
+ if today.month == 1
96
+ month(today.year - 1, 12)
97
+ else
98
+ month(today.year, today.month - 1)
99
+ end
100
+ end
101
+
102
+ # Specific year
103
+ def self.year(year_num)
104
+ new(Date.new(year_num, 1, 1), Date.new(year_num, 12, 31))
105
+ end
106
+
107
+ # Current year (full year, not YTD)
108
+ def self.current_year
109
+ year(Date.today.year)
110
+ end
111
+
112
+ # Previous year
113
+ def self.previous_year
114
+ year(Date.today.year - 1)
115
+ end
116
+
117
+ # Last N days (including today)
118
+ def self.last_days(num_days)
119
+ today = Date.today
120
+ new(today - num_days + 1, today)
121
+ end
122
+
123
+ # Last 7 days
124
+ def self.last_week
125
+ last_days(7)
126
+ end
127
+
128
+ # Last 30 days
129
+ def self.last_30_days
130
+ last_days(30)
131
+ end
132
+
133
+ # Last 90 days
134
+ def self.last_90_days
135
+ last_days(90)
136
+ end
137
+
138
+ # Last 365 days
139
+ def self.last_year_rolling
140
+ last_days(365)
141
+ end
142
+
143
+ # Custom range
144
+ def self.between(start_date, end_date)
145
+ new(start_date, end_date)
146
+ end
147
+
148
+ # Instance methods
149
+
150
+ # Check if a date falls within this period
151
+ def includes?(date)
152
+ date = date.to_date
153
+ date.between?(@start_date, @end_date)
154
+ end
155
+
156
+ # Get the date range
157
+ def to_range
158
+ @start_date..@end_date
159
+ end
160
+
161
+ # Number of days in the period
162
+ def days
163
+ (@end_date - @start_date).to_i + 1
164
+ end
165
+
166
+ # String representation
167
+ def to_s
168
+ if @start_date == @end_date
169
+ @start_date.to_s
170
+ else
171
+ "#{@start_date} to #{@end_date}"
172
+ end
173
+ end
174
+
175
+ # Human-readable label
176
+ def label
177
+ today = Date.today
178
+
179
+ # Check for common patterns
180
+ if @start_date == Date.new(today.year, 1, 1) && @end_date == today
181
+ "Year to Date"
182
+ elsif @start_date == Date.new(today.year, today.month, 1) && @end_date == today
183
+ "Month to Date"
184
+ elsif @start_date == Date.new(today.year, 1, 1) && @end_date == Date.new(today.year, 12, 31)
185
+ today.year.to_s
186
+ elsif @start_date.day == 1 && @end_date == (@start_date >> 1) - 1
187
+ @start_date.strftime("%B %Y")
188
+ elsif days == 7 && @end_date == today
189
+ "Last 7 Days"
190
+ elsif days == 30 && @end_date == today
191
+ "Last 30 Days"
192
+ elsif days == 90 && @end_date == today
193
+ "Last 90 Days"
194
+ else
195
+ # Check for quarters
196
+ quarter = detect_quarter
197
+ return quarter if quarter
198
+
199
+ to_s
200
+ end
201
+ end
202
+
203
+ private
204
+
205
+ def detect_quarter
206
+ # Check if this is a complete quarter
207
+ [1, 2, 3, 4].each do |q|
208
+ quarter_start = Date.new(@start_date.year, (q - 1) * 3 + 1, 1)
209
+ quarter_end = (quarter_start >> 3) - 1
210
+
211
+ if @start_date == quarter_start && @end_date == quarter_end
212
+ return "Q#{q} #{@start_date.year}"
213
+ end
214
+ end
215
+ nil
216
+ end
217
+ end
218
+ end
219
+ end
data/lib/attio/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Attio
4
4
  # Current version of the Attio Ruby gem
5
- VERSION = "0.1.3"
5
+ VERSION = "0.1.5"
6
6
  end
data/lib/attio-ruby.rb CHANGED
@@ -2,4 +2,4 @@
2
2
 
3
3
  # This file exists to match the gem name for auto-requiring
4
4
  # It simply requires the main attio module
5
- require_relative "attio"
5
+ require_relative "attio"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: attio-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Beene
@@ -335,7 +335,6 @@ files:
335
335
  - LICENSE
336
336
  - README.md
337
337
  - Rakefile
338
- - attio-ruby.gemspec
339
338
  - docs/CODECOV_SETUP.md
340
339
  - examples/app_specific_typed_record.md
341
340
  - examples/basic_usage.rb
@@ -349,6 +348,7 @@ files:
349
348
  - lib/attio/api_resource.rb
350
349
  - lib/attio/builders/name_builder.rb
351
350
  - lib/attio/client.rb
351
+ - lib/attio/concerns/time_filterable.rb
352
352
  - lib/attio/errors.rb
353
353
  - lib/attio/internal/record.rb
354
354
  - lib/attio/oauth/client.rb
@@ -370,7 +370,9 @@ files:
370
370
  - lib/attio/resources/webhook.rb
371
371
  - lib/attio/resources/workspace_member.rb
372
372
  - lib/attio/util/configuration.rb
373
+ - lib/attio/util/currency_formatter.rb
373
374
  - lib/attio/util/id_extractor.rb
375
+ - lib/attio/util/time_period.rb
374
376
  - lib/attio/util/webhook_signature.rb
375
377
  - lib/attio/version.rb
376
378
  - lib/attio/webhook/event.rb
data/attio-ruby.gemspec DELETED
@@ -1,61 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/attio/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "attio-ruby"
7
- spec.version = Attio::VERSION
8
- spec.authors = ["Robert Beene"]
9
- spec.email = ["robert@ismly.com"]
10
-
11
- spec.summary = "Ruby client library for the Attio API"
12
- spec.description = "A comprehensive Ruby client library for the Attio CRM API with OAuth support, type safety, and extensive test coverage"
13
- spec.homepage = "https://github.com/rbeene/attio_ruby"
14
- spec.license = "MIT"
15
- spec.required_ruby_version = ">= 3.4.0"
16
-
17
- spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
- spec.metadata["homepage_uri"] = spec.homepage
19
- spec.metadata["source_code_uri"] = "https://github.com/rbeene/attio_ruby"
20
- spec.metadata["changelog_uri"] = "https://github.com/rbeene/attio_ruby/blob/main/CHANGELOG.md"
21
- spec.metadata["documentation_uri"] = "https://rubydoc.info/gems/attio-ruby"
22
- spec.metadata["bug_tracker_uri"] = "https://github.com/rbeene/attio_ruby/issues"
23
-
24
- # Specify which files should be added to the gem when it is released.
25
- spec.files = Dir.chdir(__dir__) do
26
- `git ls-files -z`.split("\x0").reject do |f|
27
- (File.expand_path(f) == __FILE__) ||
28
- f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
29
- end
30
- end
31
- spec.bindir = "exe"
32
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
33
- spec.require_paths = ["lib"]
34
-
35
- # Runtime dependencies
36
- spec.add_dependency "faraday", "~> 2.0"
37
- spec.add_dependency "faraday-retry", "~> 2.0"
38
- spec.add_dependency "ostruct", "~> 0.6"
39
-
40
- # Development dependencies
41
- spec.add_development_dependency "bundler", "~> 2.0"
42
- spec.add_development_dependency "rake", "~> 13.0"
43
- spec.add_development_dependency "rspec", "~> 3.12"
44
- spec.add_development_dependency "webmock", "~> 3.18"
45
- spec.add_development_dependency "simplecov", "~> 0.22"
46
- spec.add_development_dependency "simplecov-cobertura", "~> 2.1"
47
- spec.add_development_dependency "yard", "~> 0.9"
48
- spec.add_development_dependency "redcarpet", "~> 3.6"
49
- spec.add_development_dependency "rubocop", "~> 1.50"
50
- spec.add_development_dependency "rubocop-rspec", "~> 2.20"
51
- spec.add_development_dependency "rubocop-performance", "~> 1.17"
52
- spec.add_development_dependency "standard", "~> 1.28"
53
- spec.add_development_dependency "benchmark-ips", "~> 2.12"
54
- spec.add_development_dependency "pry", "~> 0.14"
55
- spec.add_development_dependency "pry-byebug", "~> 3.10"
56
- spec.add_development_dependency "dotenv", "~> 2.8"
57
- spec.add_development_dependency "timecop", "~> 0.9"
58
- spec.add_development_dependency "bundle-audit", "~> 0.1"
59
- spec.add_development_dependency "brakeman", "~> 6.0"
60
- spec.metadata["rubygems_mfa_required"] = "true"
61
- end