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.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +56 -0
- data/examples/deals.rb +2 -2
- data/lib/attio/api_resource.rb +5 -4
- data/lib/attio/concerns/time_filterable.rb +153 -0
- data/lib/attio/internal/record.rb +1 -1
- data/lib/attio/resources/company.rb +3 -4
- data/lib/attio/resources/deal.rb +288 -57
- data/lib/attio/resources/meta.rb +5 -7
- data/lib/attio/resources/object.rb +2 -2
- data/lib/attio/resources/person.rb +2 -3
- data/lib/attio/resources/typed_record.rb +5 -5
- data/lib/attio/resources/workspace_member.rb +1 -1
- data/lib/attio/util/currency_formatter.rb +143 -0
- data/lib/attio/util/time_period.rb +219 -0
- data/lib/attio/version.rb +1 -1
- data/lib/attio-ruby.rb +1 -1
- metadata +4 -2
- data/attio-ruby.gemspec +0 -61
@@ -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
data/lib/attio-ruby.rb
CHANGED
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.
|
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
|