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.
@@ -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
@@ -30,53 +32,52 @@ module Attio
30
32
  # @param attributes [Hash] Deal attributes
31
33
  # @option attributes [String] :name Deal name (recommended)
32
34
  # @option attributes [Numeric] :value Deal value (recommended)
33
- # @option attributes [String] :stage Deal stage (recommended) - defaults: "Lead", "In Progress", "Won 🎉", "Lost"
35
+ # @option attributes [String] :stage Deal stage (recommended) - configurable via Attio.configuration
34
36
  # @option attributes [String] :status Deal status (alias for stage)
35
37
  # @option attributes [String] :owner Owner email or workspace member (recommended)
36
38
  # @option attributes [Array<String>] :associated_people Email addresses of associated people
37
39
  # @option attributes [Array<String>] :associated_company Domains of associated companies
38
40
  # @option attributes [Hash] :values Raw values hash (for advanced use)
39
41
  def create(name:, value: nil, stage: nil, status: nil, owner: nil,
40
- associated_people: nil, associated_company: nil, values: {}, **opts)
42
+ associated_people: nil, associated_company: nil, values: {}, **opts)
41
43
  # Name is required and simple
42
44
  values[:name] = name if name && !values[:name]
43
-
45
+
44
46
  # Add optional fields
45
47
  values[:value] = value if value && !values[:value]
46
-
48
+
47
49
  # Handle stage vs status - API uses "stage" but we support both
48
50
  if (stage || status) && !values[:stage]
49
51
  values[:stage] = stage || status
50
52
  end
51
-
52
-
53
+
53
54
  # Handle owner - can be email address or workspace member reference
54
55
  if owner && !values[:owner]
55
56
  values[:owner] = owner
56
57
  end
57
-
58
+
58
59
  # Handle associated people - convert email array to proper format
59
60
  if associated_people && !values[:associated_people]
60
61
  values[:associated_people] = associated_people.map do |email|
61
62
  {
62
63
  target_object: "people",
63
64
  email_addresses: [
64
- { email_address: email }
65
+ {email_address: email}
65
66
  ]
66
67
  }
67
68
  end
68
69
  end
69
-
70
+
70
71
  # Handle associated company - convert domain array to proper format
71
72
  if associated_company && !values[:associated_company]
72
73
  # associated_company can be array of domains or single domain
73
74
  domains = associated_company.is_a?(Array) ? associated_company : [associated_company]
74
75
  values[:associated_company] = {
75
76
  target_object: "companies",
76
- domains: domains.map { |domain| { domain: domain } }
77
+ domains: domains.map { |domain| {domain: domain} }
77
78
  }
78
79
  end
79
-
80
+
80
81
  super(values: values, **opts)
81
82
  end
82
83
 
@@ -85,15 +86,15 @@ module Attio
85
86
  # @return [Attio::ListObject] List of matching deals
86
87
  def in_stage(stage_names:, **opts)
87
88
  # If only one stage, use simple equality
88
- if stage_names.length == 1
89
- filter = { stage: stage_names.first }
89
+ filter = if stage_names.length == 1
90
+ {stage: stage_names.first}
90
91
  else
91
92
  # Multiple stages need $or operator
92
- filter = {
93
- "$or": stage_names.map { |stage| { stage: stage } }
93
+ {
94
+ "$or": stage_names.map { |stage| {stage: stage} }
94
95
  }
95
96
  end
96
-
97
+
97
98
  list(**opts.merge(params: {filter: filter}))
98
99
  end
99
100
 
@@ -124,7 +125,7 @@ module Attio
124
125
  filters = []
125
126
  filters << {value: {"$gte": min}} if min
126
127
  filters << {value: {"$lte": max}} if max
127
-
128
+
128
129
  filter = if filters.length == 1
129
130
  filters.first
130
131
  elsif filters.length > 1
@@ -132,7 +133,7 @@ module Attio
132
133
  else
133
134
  {}
134
135
  end
135
-
136
+
136
137
  list(**opts.merge(params: {filter: filter}))
137
138
  end
138
139
 
@@ -142,14 +143,15 @@ module Attio
142
143
  # def closing_soon(days: 30, **opts)
143
144
  # today = Date.today
144
145
  # end_date = today + days
145
- #
146
+ #
146
147
  # list(**opts.merge(params: {
147
148
  # filter: {
148
149
  # "$and": [
149
150
  # {close_date: {"$gte": today.iso8601}},
150
151
  # {close_date: {"$lte": end_date.iso8601}},
151
- # {stage: {"$ne": "Won 🎉"}},
152
- # {stage: {"$ne": "Lost"}}
152
+ # # Exclude won and lost statuses
153
+ # {"$not": {stage: {"$in": Attio.configuration.won_statuses}}},
154
+ # {"$not": {stage: {"$in": Attio.configuration.lost_statuses}}}
153
155
  # ]
154
156
  # }
155
157
  # }))
@@ -168,12 +170,138 @@ module Attio
168
170
  }
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_statuses = ::Attio.configuration.won_statuses
211
+ won_conditions = won_statuses.map { |status| {"stage" => status} }
212
+ won_filter = {
213
+ "$and" => [
214
+ ((won_conditions.size > 1) ? {"$or" => won_conditions} : won_conditions.first),
215
+ date_filter
216
+ ].compact
217
+ }
218
+ won_response = list(**opts.merge(params: {filter: won_filter}))
219
+
220
+ # Fetch lost deals closed in the period
221
+ lost_statuses = ::Attio.configuration.lost_statuses
222
+ lost_conditions = lost_statuses.map { |status| {"stage" => status} }
223
+ lost_filter = {
224
+ "$and" => [
225
+ ((lost_conditions.size > 1) ? {"$or" => lost_conditions} : lost_conditions.first),
226
+ date_filter
227
+ ].compact
228
+ }
229
+ lost_response = list(**opts.merge(params: {filter: lost_filter}))
230
+
231
+ won_deals = won_response.data
232
+ lost_deals = lost_response.data
233
+ total_closed = won_deals.size + lost_deals.size
234
+
235
+ {
236
+ period: period.label,
237
+ won_count: won_deals.size,
238
+ won_amount: won_deals.sum(&:amount),
239
+ lost_count: lost_deals.size,
240
+ lost_amount: lost_deals.sum(&:amount),
241
+ total_closed: total_closed,
242
+ win_rate: (total_closed > 0) ? (won_deals.size.to_f / total_closed * 100).round(2) : 0.0
243
+ }
244
+ end
245
+
246
+ # Get current quarter metrics
247
+ # @return [Hash] Metrics for the current quarter
248
+ def current_quarter_metrics(**opts)
249
+ metrics_for_period(Util::TimePeriod.current_quarter, **opts)
250
+ end
251
+
252
+ # Get year-to-date metrics
253
+ # @return [Hash] Metrics for year to date
254
+ def year_to_date_metrics(**opts)
255
+ metrics_for_period(Util::TimePeriod.year_to_date, **opts)
256
+ end
257
+
258
+ # Get month-to-date metrics
259
+ # @return [Hash] Metrics for month to date
260
+ def month_to_date_metrics(**opts)
261
+ metrics_for_period(Util::TimePeriod.month_to_date, **opts)
262
+ end
263
+
264
+ # Get last 30 days metrics
265
+ # @return [Hash] Metrics for last 30 days
266
+ def last_30_days_metrics(**opts)
267
+ metrics_for_period(Util::TimePeriod.last_30_days, **opts)
268
+ end
269
+
270
+ # Get high-value deals above a threshold
271
+ # @param threshold [Numeric] The minimum value threshold (defaults to 50,000)
272
+ # @return [Array<Attio::Deal>] List of high-value deals
273
+ def high_value(threshold = 50_000, **opts)
274
+ all(**opts).select { |deal| deal.amount > threshold }
275
+ end
276
+
277
+ # Get deals without owners
278
+ # @return [Array<Attio::Deal>] List of unassigned deals
279
+ def unassigned(**opts)
280
+ all(**opts).select { |deal| deal.owner.nil? }
281
+ end
282
+
283
+ # Get recently created deals
284
+ # @param days [Integer] Number of days to look back (defaults to 7)
285
+ # @return [Array<Attio::Deal>] List of recently created deals
286
+ def recently_created(days = 7, **opts)
287
+ created_in_period(Util::TimePeriod.last_days(days), **opts)
288
+ end
289
+
290
+ # Get deals created in a specific period
291
+ # @param period [Util::TimePeriod] The time period
292
+ # @return [Array<Attio::Deal>] List of deals created in the period
293
+ def created_in_period(period, **opts)
294
+ all(**opts).select do |deal|
295
+ created_at = deal.created_at
296
+ created_at && period.includes?(created_at)
297
+ end
298
+ end
299
+
172
300
  private
173
-
301
+
174
302
  # Build filter for status field (maps to stage)
175
303
  def filter_by_status(value)
176
- { stage: value }
304
+ {stage: value}
177
305
  end
178
306
  end
179
307
 
@@ -183,23 +311,53 @@ module Attio
183
311
  self[:name]
184
312
  end
185
313
 
186
- # Get the deal value
187
- # @return [Numeric, nil] The deal value
314
+ # Get the monetary amount from the deal value
315
+ # @return [Float] The deal amount (0.0 if not set)
316
+ def amount
317
+ return 0.0 unless self[:value].is_a?(Hash)
318
+ (self[:value]["currency_value"] || 0).to_f
319
+ end
320
+
321
+ # Get the currency code
322
+ # @return [String] The currency code (defaults to "USD")
323
+ def currency
324
+ return "USD" unless self[:value].is_a?(Hash)
325
+ self[:value]["currency_code"] || "USD"
326
+ end
327
+
328
+ # Get formatted amount for display
329
+ # @return [String] The formatted currency amount
330
+ def formatted_amount
331
+ Util::CurrencyFormatter.format(amount, currency)
332
+ end
333
+
334
+ # Get the raw deal value (for backward compatibility)
335
+ # @deprecated Use {#amount} for monetary values or {#raw_value} for raw API response
336
+ # @return [Object] The raw value from the API
188
337
  def value
338
+ warn "[DEPRECATION] `value` is deprecated. Use `amount` for monetary values or `raw_value` for the raw API response." unless ENV["ATTIO_SUPPRESS_DEPRECATION"]
339
+ amount
340
+ end
341
+
342
+ # Get the raw value data from the API
343
+ # @return [Object] The raw value data
344
+ def raw_value
189
345
  self[:value]
190
346
  end
191
347
 
192
- # Get the deal stage (API uses "stage" but we provide status for compatibility)
193
- # @return [String, nil] The deal stage
348
+ # Get the normalized deal stage/status
349
+ # @return [String, nil] The deal stage title
194
350
  def stage
195
- self[:stage]
351
+ stage_data = self[:stage]
352
+ return nil unless stage_data.is_a?(Hash)
353
+
354
+ # Attio always returns stage as a hash with nested status.title
355
+ stage_data.dig("status", "title")
196
356
  end
197
-
357
+
198
358
  # Alias for stage (for compatibility)
199
359
  # @return [String, nil] The deal stage
200
- def status
201
- self[:stage]
202
- end
360
+ alias_method :status, :stage
203
361
 
204
362
  # # Get the close date (if attribute exists)
205
363
  # # @return [String, nil] The close date
@@ -231,7 +389,7 @@ module Attio
231
389
  def update_stage(new_stage, **opts)
232
390
  self.class.update(id, values: {stage: new_stage}, **opts)
233
391
  end
234
-
392
+
235
393
  # Alias for update_stage (for compatibility)
236
394
  # @param new_status [String] The new status/stage
237
395
  # @return [Attio::Deal] The updated deal
@@ -257,7 +415,7 @@ module Attio
257
415
  # @return [Attio::Company, nil] The company record if associated
258
416
  def company_record(**opts)
259
417
  return nil unless company
260
-
418
+
261
419
  company_id = company.is_a?(Hash) ? company["target_record_id"] : company
262
420
  Company.retrieve(company_id, **opts) if company_id
263
421
  end
@@ -266,7 +424,7 @@ module Attio
266
424
  # @return [Attio::WorkspaceMember, nil] The owner record if assigned
267
425
  def owner_record(**opts)
268
426
  return nil unless owner
269
-
427
+
270
428
  owner_id = if owner.is_a?(Hash)
271
429
  owner["referenced_actor_id"] || owner["target_record_id"]
272
430
  else
@@ -282,35 +440,27 @@ module Attio
282
440
  # (value * probability / 100.0).round(2)
283
441
  # end
284
442
 
285
- # Get the current status title
443
+ # Get the current status title (delegates to stage for simplicity)
286
444
  # @return [String, nil] The current status title
287
445
  def current_status
288
- return nil unless stage
289
-
290
- if stage.is_a?(Hash)
291
- stage.dig("status", "title")
292
- else
293
- stage
294
- end
446
+ stage
295
447
  end
296
448
 
297
449
  # Get the timestamp when the status changed
298
450
  # @return [Time, nil] The timestamp when status changed
299
451
  def status_changed_at
300
- return nil unless stage
301
-
302
- if stage.is_a?(Hash) && stage["active_from"]
303
- Time.parse(stage["active_from"])
304
- else
305
- nil
306
- end
452
+ return nil unless self[:stage].is_a?(Hash)
453
+
454
+ # Attio returns active_from at the top level of the stage hash
455
+ timestamp = self[:stage]["active_from"]
456
+ timestamp ? Time.parse(timestamp) : nil
307
457
  end
308
458
 
309
459
  # Check if the deal is open
310
460
  # @return [Boolean] True if the deal is open
311
461
  def open?
312
462
  return false unless current_status
313
-
463
+
314
464
  all_open_statuses = Attio.configuration.open_statuses + Attio.configuration.in_progress_statuses
315
465
  all_open_statuses.include?(current_status)
316
466
  end
@@ -319,7 +469,7 @@ module Attio
319
469
  # @return [Boolean] True if the deal is won
320
470
  def won?
321
471
  return false unless current_status
322
-
472
+
323
473
  Attio.configuration.won_statuses.include?(current_status)
324
474
  end
325
475
 
@@ -327,20 +477,22 @@ module Attio
327
477
  # @return [Boolean] True if the deal is lost
328
478
  def lost?
329
479
  return false unless current_status
330
-
480
+
331
481
  Attio.configuration.lost_statuses.include?(current_status)
332
482
  end
333
483
 
334
484
  # Get the timestamp when the deal was won
335
485
  # @return [Time, nil] The timestamp when deal was won, or nil if not won
336
486
  def won_at
337
- won? ? status_changed_at : nil
487
+ return nil unless won?
488
+ status_changed_at
338
489
  end
339
490
 
340
491
  # Get the timestamp when the deal was closed (won or lost)
341
492
  # @return [Time, nil] The timestamp when deal was closed, or nil if still open
342
493
  def closed_at
343
- (won? || lost?) ? status_changed_at : nil
494
+ return nil unless won? || lost?
495
+ status_changed_at
344
496
  end
345
497
 
346
498
  # # Check if the deal is overdue
@@ -349,10 +501,89 @@ module Attio
349
501
  # return false unless close_date && open?
350
502
  # Date.parse(close_date) < Date.today
351
503
  # end
504
+
505
+ # Check if this is an enterprise deal
506
+ # @return [Boolean] True if amount > 100,000
507
+ def enterprise?
508
+ amount > 100_000
509
+ end
510
+
511
+ # Check if this is a mid-market deal
512
+ # @return [Boolean] True if amount is between 10,000 and 100,000
513
+ def mid_market?
514
+ amount.between?(10_000, 100_000)
515
+ end
516
+
517
+ # Check if this is a small deal
518
+ # @return [Boolean] True if amount < 10,000
519
+ def small?
520
+ amount < 10_000
521
+ end
522
+
523
+ # Get the number of days the deal has been in current stage
524
+ # @return [Integer] Number of days in current stage
525
+ def days_in_stage
526
+ return 0 unless status_changed_at
527
+ ((Time.now - status_changed_at) / (24 * 60 * 60)).round
528
+ end
529
+
530
+ # Check if the deal is stale (no activity for specified days)
531
+ # @param days [Integer] Number of days to consider stale (defaults to 30)
532
+ # @return [Boolean] True if deal is open and hasn't changed in specified days
533
+ def stale?(days = 30)
534
+ return false if closed?
535
+ days_in_stage > days
536
+ end
537
+
538
+ # Check if the deal is closed (won or lost)
539
+ # @return [Boolean] True if deal is won or lost
540
+ def closed?
541
+ won? || lost?
542
+ end
543
+
544
+ # Get a simple summary of the deal
545
+ # @return [String] Summary string with name, amount, and stage
546
+ def summary
547
+ "#{name || "Unnamed Deal"}: #{formatted_amount} (#{stage || "No Stage"})"
548
+ end
549
+
550
+ # Convert to string for display
551
+ # @return [String] The deal summary
552
+ def to_s
553
+ summary
554
+ end
555
+
556
+ # Get deal size category
557
+ # @return [Symbol] :enterprise, :mid_market, or :small
558
+ def size_category
559
+ if enterprise?
560
+ :enterprise
561
+ elsif mid_market?
562
+ :mid_market
563
+ else
564
+ :small
565
+ end
566
+ end
567
+
568
+ # Check if deal needs attention (stale and not closed)
569
+ # @param stale_days [Integer] Days to consider stale
570
+ # @return [Boolean] True if deal needs attention
571
+ def needs_attention?(stale_days = 30)
572
+ !closed? && stale?(stale_days)
573
+ end
574
+
575
+ # Get deal velocity (amount per day if closed)
576
+ # @return [Float, nil] Amount per day or nil if not closed
577
+ def velocity
578
+ return nil unless closed? && closed_at && created_at
579
+
580
+ days_to_close = ((closed_at - created_at) / (24 * 60 * 60)).round
581
+ (days_to_close > 0) ? (amount / days_to_close).round(2) : amount
582
+ end
352
583
  end
353
584
 
354
585
  # Alias for Deal (plural form)
355
586
  # @example
356
587
  # Attio::Deals.create(name: "New Deal", value: 10000)
357
588
  Deals = Deal
358
- end
589
+ end
@@ -26,7 +26,7 @@ module Attio
26
26
  # Build workspace object from flat attributes
27
27
  def workspace
28
28
  return nil unless self[:workspace_id]
29
-
29
+
30
30
  {
31
31
  id: self[:workspace_id],
32
32
  name: self[:workspace_name],
@@ -34,22 +34,22 @@ module Attio
34
34
  logo_url: self[:workspace_logo_url]
35
35
  }.compact
36
36
  end
37
-
37
+
38
38
  # Build token object from flat attributes
39
39
  def token
40
40
  return nil unless self[:client_id]
41
-
41
+
42
42
  {
43
43
  id: self[:client_id],
44
44
  type: self[:token_type] || "Bearer",
45
45
  scope: self[:scope]
46
46
  }.compact
47
47
  end
48
-
48
+
49
49
  # Build actor object from flat attributes
50
50
  def actor
51
51
  return nil unless self[:authorized_by_workspace_member_id]
52
-
52
+
53
53
  {
54
54
  type: "workspace-member",
55
55
  id: self[:authorized_by_workspace_member_id]
@@ -138,7 +138,5 @@ module Attio
138
138
  def inspect
139
139
  "#<#{self.class.name}:#{object_id} workspace=#{workspace_slug.inspect} token=#{token_name.inspect}>"
140
140
  end
141
-
142
- private
143
141
  end
144
142
  end
@@ -53,7 +53,7 @@ module Attio
53
53
  known_opts.each do |opt|
54
54
  opts[opt] = conditions.delete(opt) if conditions.key?(opt)
55
55
  end
56
-
56
+
57
57
  # Currently only supports slug
58
58
  if conditions.key?(:slug)
59
59
  slug = conditions[:slug]
@@ -66,7 +66,7 @@ module Attio
66
66
  raise ArgumentError, "find_by only supports slug attribute for objects"
67
67
  end
68
68
  end
69
-
69
+
70
70
  # Find by API slug (deprecated - use find_by(slug: ...) instead)
71
71
  def self.find_by_slug(slug, **opts)
72
72
  find_by(slug: slug, **opts)
@@ -251,7 +251,6 @@ module Attio
251
251
  super(values: values, **opts)
252
252
  end
253
253
 
254
-
255
254
  # Search people by query
256
255
  # @param query [String] Query to search for
257
256
  def search(query, **opts)
@@ -268,7 +267,7 @@ module Attio
268
267
  end
269
268
 
270
269
  private
271
-
270
+
272
271
  # Build filter for email field
273
272
  def filter_by_email(value)
274
273
  {
@@ -279,7 +278,7 @@ module Attio
279
278
  }
280
279
  }
281
280
  end
282
-
281
+
283
282
  # Build filter for name field (searches across first, last, and full name)
284
283
  def filter_by_name(value)
285
284
  {
@@ -64,18 +64,18 @@ module Attio
64
64
  # Supports Rails-style hash syntax: find_by(name: "Test")
65
65
  def find_by(**conditions)
66
66
  raise ArgumentError, "find_by requires at least one condition" if conditions.empty?
67
-
67
+
68
68
  # Extract any opts that aren't conditions (like api_key)
69
69
  opts = {}
70
70
  known_opts = [:api_key, :timeout, :idempotency_key]
71
71
  known_opts.each do |opt|
72
72
  opts[opt] = conditions.delete(opt) if conditions.key?(opt)
73
73
  end
74
-
74
+
75
75
  # Build filter from conditions
76
76
  filters = []
77
77
  search_query = nil
78
-
78
+
79
79
  conditions.each do |field, value|
80
80
  # Check if there's a special filter method for this field
81
81
  filter_method = "filter_by_#{field}"
@@ -92,7 +92,7 @@ module Attio
92
92
  filters << {field => value}
93
93
  end
94
94
  end
95
-
95
+
96
96
  # If we have a search query, use search instead of filter
97
97
  if search_query
98
98
  search(search_query, **opts).first
@@ -105,7 +105,7 @@ module Attio
105
105
  else
106
106
  {}
107
107
  end
108
-
108
+
109
109
  list(**opts.merge(params: {
110
110
  filter: final_filter
111
111
  })).first
@@ -111,7 +111,7 @@ module Attio
111
111
  known_opts.each do |opt|
112
112
  opts[opt] = conditions.delete(opt) if conditions.key?(opt)
113
113
  end
114
-
114
+
115
115
  # Currently only supports email
116
116
  if conditions.key?(:email)
117
117
  email = conditions[:email]