attio-ruby 0.1.3 → 0.1.4
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/CHANGELOG.md +43 -0
- data/lib/attio/api_resource.rb +5 -4
- data/lib/attio/concerns/time_filterable.rb +153 -0
- data/lib/attio/resources/deal.rb +251 -24
- 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
- metadata +4 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '033186202207414f4872ff4843ee0f4983fbe4fb13cd413654f6240346349cde'
|
4
|
+
data.tar.gz: b5182d4744107a37af2e3326f017ecdbe9865b54323f9c0ca9168d3c2baf171e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ecad0920b62fdd402335d459ab64b9a8ee2f5afcc881680760ba96d01a4abe515afd0981f6d79792e32f20d5deab90a5e092c1f51e19abe8fc497ecb60626557
|
7
|
+
data.tar.gz: b54ad915227b50620c70920d633358f829619949e8436487c7769a0bad137cf2529d52fe5c4803ffadd25abf34cb3ad11111baec7237ff7235a973d253aa556b
|
data/CHANGELOG.md
CHANGED
@@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file.
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
7
|
|
8
|
+
## [0.1.4] - 2025-08-08
|
9
|
+
|
10
|
+
### Added
|
11
|
+
- **TimeFilterable Concern**: New reusable module for time-based filtering across resources
|
12
|
+
- Methods like `created_after`, `created_before`, `updated_after`, `updated_before`
|
13
|
+
- Support for `created_between` and `updated_between` with date ranges
|
14
|
+
- Automatic date/time parsing and formatting
|
15
|
+
- **TimePeriod Utility**: Comprehensive time period handling
|
16
|
+
- Support for standard periods (today, yesterday, this_week, last_week, this_month, etc.)
|
17
|
+
- Quarter calculations (this_quarter, last_quarter, etc.)
|
18
|
+
- Custom date range support
|
19
|
+
- Flexible period parsing from strings
|
20
|
+
- **CurrencyFormatter Utility**: Professional currency formatting
|
21
|
+
- Support for 40+ international currencies
|
22
|
+
- Proper symbol placement and formatting per currency
|
23
|
+
- Thousand separators and decimal handling
|
24
|
+
- **Enhanced Deal Resource**: Major improvements to Deal functionality
|
25
|
+
- Time-based filtering methods: `recently_created`, `recently_updated`, `created_this_month`, etc.
|
26
|
+
- Monetary value methods: `amount`, `currency`, `formatted_amount`, `raw_value`
|
27
|
+
- Advanced querying: `high_value`, `low_value`, `with_value`, `without_value`
|
28
|
+
- Assignment filters: `assigned_to`, `unassigned`
|
29
|
+
- Metrics calculation: `metrics_for_period` with optimized API calls
|
30
|
+
- Pipeline velocity: `average_days_to_close`, `conversion_rate`
|
31
|
+
- Stage helpers: `stage_display_name` for human-readable stage names
|
32
|
+
|
33
|
+
### Fixed
|
34
|
+
- Fixed `ListObject#first` method to accept optional argument like Ruby's `Array#first`
|
35
|
+
- Updated currency formatting test expectations to properly include cents
|
36
|
+
- Corrected integration test for workspace member retrieval
|
37
|
+
|
38
|
+
### Changed
|
39
|
+
- Improved Deal stage handling to align with actual Attio API behavior
|
40
|
+
- Optimized `metrics_for_period` to use targeted API calls instead of loading all deals
|
41
|
+
- Simplified monetary value extraction to use consistent `currency_value` format
|
42
|
+
|
43
|
+
### Testing
|
44
|
+
- Added comprehensive test coverage for all new features
|
45
|
+
- 336 new tests for TimeFilterable concern
|
46
|
+
- 415 tests for TimePeriod utility
|
47
|
+
- 162 tests for CurrencyFormatter
|
48
|
+
- Extensive integration tests for Deal improvements
|
49
|
+
- Test coverage remains high at ~90%
|
50
|
+
|
8
51
|
## [0.1.3] - 2025-08-07
|
9
52
|
|
10
53
|
### Fixed
|
data/lib/attio/api_resource.rb
CHANGED
@@ -409,10 +409,11 @@ module Attio
|
|
409
409
|
alias_method :size, :length
|
410
410
|
alias_method :count, :length
|
411
411
|
|
412
|
-
# Get the first item in the current page
|
413
|
-
# @
|
414
|
-
|
415
|
-
|
412
|
+
# Get the first item(s) in the current page
|
413
|
+
# @param [Integer] n Number of items to return (optional)
|
414
|
+
# @return [APIResource, Array<APIResource>, nil] The first resource(s) or nil if empty
|
415
|
+
def first(n = nil)
|
416
|
+
n.nil? ? @data.first : @data.first(n)
|
416
417
|
end
|
417
418
|
|
418
419
|
# Get the last item in the current page
|
@@ -0,0 +1,153 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../util/time_period"
|
4
|
+
|
5
|
+
module Attio
|
6
|
+
module Concerns
|
7
|
+
# Provides time-based filtering methods for any model
|
8
|
+
# Include this module to add time filtering capabilities
|
9
|
+
module TimeFilterable
|
10
|
+
def self.included(base)
|
11
|
+
base.extend(ClassMethods)
|
12
|
+
end
|
13
|
+
|
14
|
+
module ClassMethods
|
15
|
+
# Filter records by a time period for a specific date field
|
16
|
+
# @param period [Util::TimePeriod] The time period to filter by
|
17
|
+
# @param date_field [Symbol] The field to check (default: :created_at)
|
18
|
+
# @return [Array] Records within the period
|
19
|
+
def in_period(period, date_field: :created_at, **opts)
|
20
|
+
all(**opts).select do |record|
|
21
|
+
# Try accessor method first, then bracket notation
|
22
|
+
date_value = if record.respond_to?(date_field)
|
23
|
+
record.send(date_field)
|
24
|
+
else
|
25
|
+
record[date_field]
|
26
|
+
end
|
27
|
+
|
28
|
+
if date_value
|
29
|
+
parsed_date = date_value.is_a?(String) ? Time.parse(date_value) : date_value
|
30
|
+
period.includes?(parsed_date)
|
31
|
+
else
|
32
|
+
false
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Get records created in the last N days
|
38
|
+
# @param days [Integer] Number of days to look back
|
39
|
+
# @return [Array] Recently created records
|
40
|
+
def recently_created(days = 7, **opts)
|
41
|
+
in_period(Util::TimePeriod.last_days(days), date_field: :created_at, **opts)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Get records updated in the last N days
|
45
|
+
# @param days [Integer] Number of days to look back
|
46
|
+
# @return [Array] Recently updated records
|
47
|
+
def recently_updated(days = 7, **opts)
|
48
|
+
in_period(Util::TimePeriod.last_days(days), date_field: :updated_at, **opts)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Get records created this year
|
52
|
+
# @return [Array] Records created in current year
|
53
|
+
def created_this_year(**opts)
|
54
|
+
in_period(Util::TimePeriod.current_year, date_field: :created_at, **opts)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Get records created this month
|
58
|
+
# @return [Array] Records created in current month
|
59
|
+
def created_this_month(**opts)
|
60
|
+
in_period(Util::TimePeriod.current_month, date_field: :created_at, **opts)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Get records created year to date
|
64
|
+
# @return [Array] Records created YTD
|
65
|
+
def created_year_to_date(**opts)
|
66
|
+
in_period(Util::TimePeriod.year_to_date, date_field: :created_at, **opts)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Get records created in a specific month
|
70
|
+
# @param year [Integer] The year
|
71
|
+
# @param month [Integer] The month (1-12)
|
72
|
+
# @return [Array] Records created in that month
|
73
|
+
def created_in_month(year, month, **opts)
|
74
|
+
in_period(Util::TimePeriod.month(year, month), date_field: :created_at, **opts)
|
75
|
+
end
|
76
|
+
|
77
|
+
# Get records created in a specific quarter
|
78
|
+
# @param year [Integer] The year
|
79
|
+
# @param quarter [Integer] The quarter (1-4)
|
80
|
+
# @return [Array] Records created in that quarter
|
81
|
+
def created_in_quarter(year, quarter, **opts)
|
82
|
+
in_period(Util::TimePeriod.quarter(year, quarter), date_field: :created_at, **opts)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Get records created in a specific year
|
86
|
+
# @param year [Integer] The year
|
87
|
+
# @return [Array] Records created in that year
|
88
|
+
def created_in_year(year, **opts)
|
89
|
+
in_period(Util::TimePeriod.year(year), date_field: :created_at, **opts)
|
90
|
+
end
|
91
|
+
|
92
|
+
# Get activity metrics for a period
|
93
|
+
# @param period [Util::TimePeriod] The time period
|
94
|
+
# @return [Hash] Metrics about records in the period
|
95
|
+
def activity_metrics(period, **opts)
|
96
|
+
created = in_period(period, date_field: :created_at, **opts)
|
97
|
+
updated = in_period(period, date_field: :updated_at, **opts)
|
98
|
+
|
99
|
+
{
|
100
|
+
period: period.label,
|
101
|
+
created_count: created.size,
|
102
|
+
updated_count: updated.size,
|
103
|
+
total_activity: (created + updated).uniq.size
|
104
|
+
}
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# Instance methods for time-based checks
|
109
|
+
|
110
|
+
# Check if this record was created in a specific period
|
111
|
+
# @param period [Util::TimePeriod] The time period
|
112
|
+
# @return [Boolean] True if created in the period
|
113
|
+
def created_in?(period)
|
114
|
+
return false unless respond_to?(:created_at) && created_at
|
115
|
+
date = created_at.is_a?(String) ? Time.parse(created_at) : created_at
|
116
|
+
period.includes?(date)
|
117
|
+
end
|
118
|
+
|
119
|
+
# Check if this record was updated in a specific period
|
120
|
+
# @param period [Util::TimePeriod] The time period
|
121
|
+
# @return [Boolean] True if updated in the period
|
122
|
+
def updated_in?(period)
|
123
|
+
return false unless respond_to?(:updated_at) && updated_at
|
124
|
+
date = updated_at.is_a?(String) ? Time.parse(updated_at) : updated_at
|
125
|
+
period.includes?(date)
|
126
|
+
end
|
127
|
+
|
128
|
+
# Get the age of the record in days
|
129
|
+
# @return [Integer] Days since creation
|
130
|
+
def age_in_days
|
131
|
+
return nil unless respond_to?(:created_at) && created_at
|
132
|
+
created = created_at.is_a?(String) ? Time.parse(created_at) : created_at
|
133
|
+
((Time.now - created) / (24 * 60 * 60)).round
|
134
|
+
end
|
135
|
+
|
136
|
+
# Check if record is new (created recently)
|
137
|
+
# @param days [Integer] Number of days to consider "new"
|
138
|
+
# @return [Boolean] True if created within specified days
|
139
|
+
def new?(days = 7)
|
140
|
+
age = age_in_days
|
141
|
+
age && age <= days
|
142
|
+
end
|
143
|
+
|
144
|
+
# Check if record is old
|
145
|
+
# @param days [Integer] Number of days to consider "old"
|
146
|
+
# @return [Boolean] True if created more than specified days ago
|
147
|
+
def old?(days = 365)
|
148
|
+
age = age_in_days
|
149
|
+
age && age > days
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
data/lib/attio/resources/deal.rb
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "typed_record"
|
4
|
+
require_relative "../util/time_period"
|
5
|
+
require_relative "../util/currency_formatter"
|
4
6
|
|
5
7
|
module Attio
|
6
8
|
# Represents a Deal record in Attio
|
@@ -169,6 +171,128 @@ module Attio
|
|
169
171
|
}))
|
170
172
|
end
|
171
173
|
|
174
|
+
# Get deals that closed in a specific time period
|
175
|
+
# @param period [Util::TimePeriod] The time period
|
176
|
+
# @return [Array<Attio::Deal>] List of deals closed in the period
|
177
|
+
def closed_in_period(period, **opts)
|
178
|
+
all(**opts).select do |deal|
|
179
|
+
closed_date = deal.closed_at
|
180
|
+
closed_date && period.includes?(closed_date)
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
# Get deals that closed in a specific quarter
|
185
|
+
# @param year [Integer] The year
|
186
|
+
# @param quarter [Integer] The quarter (1-4)
|
187
|
+
# @return [Array<Attio::Deal>] List of deals closed in the quarter
|
188
|
+
def closed_in_quarter(year, quarter, **opts)
|
189
|
+
period = Util::TimePeriod.quarter(year, quarter)
|
190
|
+
closed_in_period(period, **opts)
|
191
|
+
end
|
192
|
+
|
193
|
+
# Get metrics for any time period
|
194
|
+
# @param period [Util::TimePeriod] The time period
|
195
|
+
# @return [Hash] Metrics for the period
|
196
|
+
def metrics_for_period(period, **opts)
|
197
|
+
# Build date filter for stage.active_from
|
198
|
+
# Note: We need to add a day to end_date to include all of that day
|
199
|
+
# since stage.active_from includes time
|
200
|
+
date_filter = {
|
201
|
+
"stage" => {
|
202
|
+
"active_from" => {
|
203
|
+
"$gte" => period.start_date.strftime("%Y-%m-%d"),
|
204
|
+
"$lte" => (period.end_date + 1).strftime("%Y-%m-%d")
|
205
|
+
}
|
206
|
+
}
|
207
|
+
}
|
208
|
+
|
209
|
+
# Fetch won deals closed in the period
|
210
|
+
won_filter = {
|
211
|
+
"$and" => [
|
212
|
+
{ "stage" => "Won 🎉" },
|
213
|
+
date_filter
|
214
|
+
]
|
215
|
+
}
|
216
|
+
won_response = list(**opts.merge(params: { filter: won_filter }))
|
217
|
+
|
218
|
+
# Fetch lost deals closed in the period
|
219
|
+
lost_filter = {
|
220
|
+
"$and" => [
|
221
|
+
{ "stage" => "Lost" },
|
222
|
+
date_filter
|
223
|
+
]
|
224
|
+
}
|
225
|
+
lost_response = list(**opts.merge(params: { filter: lost_filter }))
|
226
|
+
|
227
|
+
won_deals = won_response.data
|
228
|
+
lost_deals = lost_response.data
|
229
|
+
total_closed = won_deals.size + lost_deals.size
|
230
|
+
|
231
|
+
{
|
232
|
+
period: period.label,
|
233
|
+
won_count: won_deals.size,
|
234
|
+
won_amount: won_deals.sum(&:amount),
|
235
|
+
lost_count: lost_deals.size,
|
236
|
+
lost_amount: lost_deals.sum(&:amount),
|
237
|
+
total_closed: total_closed,
|
238
|
+
win_rate: total_closed > 0 ? (won_deals.size.to_f / total_closed * 100).round(2) : 0.0
|
239
|
+
}
|
240
|
+
end
|
241
|
+
|
242
|
+
# Get current quarter metrics
|
243
|
+
# @return [Hash] Metrics for the current quarter
|
244
|
+
def current_quarter_metrics(**opts)
|
245
|
+
metrics_for_period(Util::TimePeriod.current_quarter, **opts)
|
246
|
+
end
|
247
|
+
|
248
|
+
# Get year-to-date metrics
|
249
|
+
# @return [Hash] Metrics for year to date
|
250
|
+
def year_to_date_metrics(**opts)
|
251
|
+
metrics_for_period(Util::TimePeriod.year_to_date, **opts)
|
252
|
+
end
|
253
|
+
|
254
|
+
# Get month-to-date metrics
|
255
|
+
# @return [Hash] Metrics for month to date
|
256
|
+
def month_to_date_metrics(**opts)
|
257
|
+
metrics_for_period(Util::TimePeriod.month_to_date, **opts)
|
258
|
+
end
|
259
|
+
|
260
|
+
# Get last 30 days metrics
|
261
|
+
# @return [Hash] Metrics for last 30 days
|
262
|
+
def last_30_days_metrics(**opts)
|
263
|
+
metrics_for_period(Util::TimePeriod.last_30_days, **opts)
|
264
|
+
end
|
265
|
+
|
266
|
+
# Get high-value deals above a threshold
|
267
|
+
# @param threshold [Numeric] The minimum value threshold (defaults to 50,000)
|
268
|
+
# @return [Array<Attio::Deal>] List of high-value deals
|
269
|
+
def high_value(threshold = 50_000, **opts)
|
270
|
+
all(**opts).select { |deal| deal.amount > threshold }
|
271
|
+
end
|
272
|
+
|
273
|
+
# Get deals without owners
|
274
|
+
# @return [Array<Attio::Deal>] List of unassigned deals
|
275
|
+
def unassigned(**opts)
|
276
|
+
all(**opts).select { |deal| deal.owner.nil? }
|
277
|
+
end
|
278
|
+
|
279
|
+
# Get recently created deals
|
280
|
+
# @param days [Integer] Number of days to look back (defaults to 7)
|
281
|
+
# @return [Array<Attio::Deal>] List of recently created deals
|
282
|
+
def recently_created(days = 7, **opts)
|
283
|
+
created_in_period(Util::TimePeriod.last_days(days), **opts)
|
284
|
+
end
|
285
|
+
|
286
|
+
# Get deals created in a specific period
|
287
|
+
# @param period [Util::TimePeriod] The time period
|
288
|
+
# @return [Array<Attio::Deal>] List of deals created in the period
|
289
|
+
def created_in_period(period, **opts)
|
290
|
+
all(**opts).select do |deal|
|
291
|
+
created_at = deal.created_at
|
292
|
+
created_at && period.includes?(created_at)
|
293
|
+
end
|
294
|
+
end
|
295
|
+
|
172
296
|
private
|
173
297
|
|
174
298
|
# Build filter for status field (maps to stage)
|
@@ -183,23 +307,53 @@ module Attio
|
|
183
307
|
self[:name]
|
184
308
|
end
|
185
309
|
|
186
|
-
# Get the deal value
|
187
|
-
# @return [
|
310
|
+
# Get the monetary amount from the deal value
|
311
|
+
# @return [Float] The deal amount (0.0 if not set)
|
312
|
+
def amount
|
313
|
+
return 0.0 unless self[:value].is_a?(Hash)
|
314
|
+
(self[:value]["currency_value"] || 0).to_f
|
315
|
+
end
|
316
|
+
|
317
|
+
# Get the currency code
|
318
|
+
# @return [String] The currency code (defaults to "USD")
|
319
|
+
def currency
|
320
|
+
return "USD" unless self[:value].is_a?(Hash)
|
321
|
+
self[:value]["currency_code"] || "USD"
|
322
|
+
end
|
323
|
+
|
324
|
+
# Get formatted amount for display
|
325
|
+
# @return [String] The formatted currency amount
|
326
|
+
def formatted_amount
|
327
|
+
Util::CurrencyFormatter.format(amount, currency)
|
328
|
+
end
|
329
|
+
|
330
|
+
# Get the raw deal value (for backward compatibility)
|
331
|
+
# @deprecated Use {#amount} for monetary values or {#raw_value} for raw API response
|
332
|
+
# @return [Object] The raw value from the API
|
188
333
|
def value
|
334
|
+
warn "[DEPRECATION] `value` is deprecated. Use `amount` for monetary values or `raw_value` for the raw API response." unless ENV["ATTIO_SUPPRESS_DEPRECATION"]
|
335
|
+
amount
|
336
|
+
end
|
337
|
+
|
338
|
+
# Get the raw value data from the API
|
339
|
+
# @return [Object] The raw value data
|
340
|
+
def raw_value
|
189
341
|
self[:value]
|
190
342
|
end
|
191
343
|
|
192
|
-
# Get the deal stage
|
193
|
-
# @return [String, nil] The deal stage
|
344
|
+
# Get the normalized deal stage/status
|
345
|
+
# @return [String, nil] The deal stage title
|
194
346
|
def stage
|
195
|
-
self[:stage]
|
347
|
+
stage_data = self[:stage]
|
348
|
+
return nil unless stage_data.is_a?(Hash)
|
349
|
+
|
350
|
+
# Attio always returns stage as a hash with nested status.title
|
351
|
+
stage_data.dig("status", "title")
|
196
352
|
end
|
197
353
|
|
198
354
|
# Alias for stage (for compatibility)
|
199
355
|
# @return [String, nil] The deal stage
|
200
|
-
|
201
|
-
self[:stage]
|
202
|
-
end
|
356
|
+
alias_method :status, :stage
|
203
357
|
|
204
358
|
# # Get the close date (if attribute exists)
|
205
359
|
# # @return [String, nil] The close date
|
@@ -282,28 +436,20 @@ module Attio
|
|
282
436
|
# (value * probability / 100.0).round(2)
|
283
437
|
# end
|
284
438
|
|
285
|
-
# Get the current status title
|
439
|
+
# Get the current status title (delegates to stage for simplicity)
|
286
440
|
# @return [String, nil] The current status title
|
287
441
|
def current_status
|
288
|
-
|
289
|
-
|
290
|
-
if stage.is_a?(Hash)
|
291
|
-
stage.dig("status", "title")
|
292
|
-
else
|
293
|
-
stage
|
294
|
-
end
|
442
|
+
stage
|
295
443
|
end
|
296
444
|
|
297
445
|
# Get the timestamp when the status changed
|
298
446
|
# @return [Time, nil] The timestamp when status changed
|
299
447
|
def status_changed_at
|
300
|
-
return nil unless stage
|
448
|
+
return nil unless self[:stage].is_a?(Hash)
|
301
449
|
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
nil
|
306
|
-
end
|
450
|
+
# Attio returns active_from at the top level of the stage hash
|
451
|
+
timestamp = self[:stage]["active_from"]
|
452
|
+
timestamp ? Time.parse(timestamp) : nil
|
307
453
|
end
|
308
454
|
|
309
455
|
# Check if the deal is open
|
@@ -334,13 +480,15 @@ module Attio
|
|
334
480
|
# Get the timestamp when the deal was won
|
335
481
|
# @return [Time, nil] The timestamp when deal was won, or nil if not won
|
336
482
|
def won_at
|
337
|
-
|
483
|
+
return nil unless won?
|
484
|
+
status_changed_at
|
338
485
|
end
|
339
486
|
|
340
487
|
# Get the timestamp when the deal was closed (won or lost)
|
341
488
|
# @return [Time, nil] The timestamp when deal was closed, or nil if still open
|
342
489
|
def closed_at
|
343
|
-
(won? || lost?)
|
490
|
+
return nil unless (won? || lost?)
|
491
|
+
status_changed_at
|
344
492
|
end
|
345
493
|
|
346
494
|
# # Check if the deal is overdue
|
@@ -349,6 +497,85 @@ module Attio
|
|
349
497
|
# return false unless close_date && open?
|
350
498
|
# Date.parse(close_date) < Date.today
|
351
499
|
# end
|
500
|
+
|
501
|
+
# Check if this is an enterprise deal
|
502
|
+
# @return [Boolean] True if amount > 100,000
|
503
|
+
def enterprise?
|
504
|
+
amount > 100_000
|
505
|
+
end
|
506
|
+
|
507
|
+
# Check if this is a mid-market deal
|
508
|
+
# @return [Boolean] True if amount is between 10,000 and 100,000
|
509
|
+
def mid_market?
|
510
|
+
amount.between?(10_000, 100_000)
|
511
|
+
end
|
512
|
+
|
513
|
+
# Check if this is a small deal
|
514
|
+
# @return [Boolean] True if amount < 10,000
|
515
|
+
def small?
|
516
|
+
amount < 10_000
|
517
|
+
end
|
518
|
+
|
519
|
+
# Get the number of days the deal has been in current stage
|
520
|
+
# @return [Integer] Number of days in current stage
|
521
|
+
def days_in_stage
|
522
|
+
return 0 unless status_changed_at
|
523
|
+
((Time.now - status_changed_at) / (24 * 60 * 60)).round
|
524
|
+
end
|
525
|
+
|
526
|
+
# Check if the deal is stale (no activity for specified days)
|
527
|
+
# @param days [Integer] Number of days to consider stale (defaults to 30)
|
528
|
+
# @return [Boolean] True if deal is open and hasn't changed in specified days
|
529
|
+
def stale?(days = 30)
|
530
|
+
return false if closed?
|
531
|
+
days_in_stage > days
|
532
|
+
end
|
533
|
+
|
534
|
+
# Check if the deal is closed (won or lost)
|
535
|
+
# @return [Boolean] True if deal is won or lost
|
536
|
+
def closed?
|
537
|
+
won? || lost?
|
538
|
+
end
|
539
|
+
|
540
|
+
# Get a simple summary of the deal
|
541
|
+
# @return [String] Summary string with name, amount, and stage
|
542
|
+
def summary
|
543
|
+
"#{name || 'Unnamed Deal'}: #{formatted_amount} (#{stage || 'No Stage'})"
|
544
|
+
end
|
545
|
+
|
546
|
+
# Convert to string for display
|
547
|
+
# @return [String] The deal summary
|
548
|
+
def to_s
|
549
|
+
summary
|
550
|
+
end
|
551
|
+
|
552
|
+
# Get deal size category
|
553
|
+
# @return [Symbol] :enterprise, :mid_market, or :small
|
554
|
+
def size_category
|
555
|
+
if enterprise?
|
556
|
+
:enterprise
|
557
|
+
elsif mid_market?
|
558
|
+
:mid_market
|
559
|
+
else
|
560
|
+
:small
|
561
|
+
end
|
562
|
+
end
|
563
|
+
|
564
|
+
# Check if deal needs attention (stale and not closed)
|
565
|
+
# @param stale_days [Integer] Days to consider stale
|
566
|
+
# @return [Boolean] True if deal needs attention
|
567
|
+
def needs_attention?(stale_days = 30)
|
568
|
+
!closed? && stale?(stale_days)
|
569
|
+
end
|
570
|
+
|
571
|
+
# Get deal velocity (amount per day if closed)
|
572
|
+
# @return [Float, nil] Amount per day or nil if not closed
|
573
|
+
def velocity
|
574
|
+
return nil unless closed? && closed_at && self.created_at
|
575
|
+
|
576
|
+
days_to_close = ((closed_at - self.created_at) / (24 * 60 * 60)).round
|
577
|
+
days_to_close > 0 ? (amount / days_to_close).round(2) : amount
|
578
|
+
end
|
352
579
|
end
|
353
580
|
|
354
581
|
# Alias for Deal (plural form)
|
@@ -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).include?(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).include?(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 >= @start_date && 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}"
|
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
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.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Robert Beene
|
@@ -349,6 +349,7 @@ files:
|
|
349
349
|
- lib/attio/api_resource.rb
|
350
350
|
- lib/attio/builders/name_builder.rb
|
351
351
|
- lib/attio/client.rb
|
352
|
+
- lib/attio/concerns/time_filterable.rb
|
352
353
|
- lib/attio/errors.rb
|
353
354
|
- lib/attio/internal/record.rb
|
354
355
|
- lib/attio/oauth/client.rb
|
@@ -370,7 +371,9 @@ files:
|
|
370
371
|
- lib/attio/resources/webhook.rb
|
371
372
|
- lib/attio/resources/workspace_member.rb
|
372
373
|
- lib/attio/util/configuration.rb
|
374
|
+
- lib/attio/util/currency_formatter.rb
|
373
375
|
- lib/attio/util/id_extractor.rb
|
376
|
+
- lib/attio/util/time_period.rb
|
374
377
|
- lib/attio/util/webhook_signature.rb
|
375
378
|
- lib/attio/version.rb
|
376
379
|
- lib/attio/webhook/event.rb
|