attio-ruby 0.1.0 → 0.1.2

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,1613 @@
1
+ # Creating App-Specific TypedRecord Classes
2
+
3
+ This guide demonstrates how to create custom record classes for your own Attio objects by inheriting from `Attio::TypedRecord`. This allows you to work with your custom objects using the same clean, object-oriented interface that the gem provides for People and Companies.
4
+
5
+ ## Table of Contents
6
+ - [Overview](#overview)
7
+ - [Basic Structure](#basic-structure)
8
+ - [Common Scenario: Deal Records](#common-scenario-deal-records)
9
+ - [Naming Conventions](#naming-conventions)
10
+ - [Required Methods](#required-methods)
11
+ - [Optional Overrides](#optional-overrides)
12
+ - [Advanced Examples](#advanced-examples)
13
+ - [Best Practices](#best-practices)
14
+ - [Integration with Other Records](#integration-with-other-records)
15
+
16
+ ## Overview
17
+
18
+ The `Attio::TypedRecord` class is designed to be extended for any custom object you've created in your Attio workspace. It provides:
19
+
20
+ - Automatic object type injection in all API calls
21
+ - Simplified CRUD operations
22
+ - Consistent interface matching Person and Company classes
23
+ - Built-in change tracking and persistence
24
+ - Support for custom attribute accessors and business logic
25
+
26
+ ## Basic Structure
27
+
28
+ Every custom record class must:
29
+
30
+ 1. Inherit from `Attio::TypedRecord`
31
+ 2. Define the `object_type` (using your object's slug or UUID)
32
+ 3. Optionally add convenience methods for attributes
33
+ 4. Optionally override class methods for custom creation/search logic
34
+
35
+ ```ruby
36
+ module Attio
37
+ class YourCustomRecord < TypedRecord
38
+ object_type "your_object_slug" # Required!
39
+
40
+ # Your custom methods here
41
+ end
42
+ end
43
+ ```
44
+
45
+ ## Common Scenario: Deal Records
46
+
47
+ Here's a complete implementation of a Deal record class showing all common patterns:
48
+
49
+ ```ruby
50
+ # lib/attio/resources/deal.rb
51
+ # frozen_string_literal: true
52
+
53
+ require_relative "typed_record"
54
+
55
+ module Attio
56
+ # Represents a deal/opportunity record in Attio
57
+ # Provides convenient methods for working with sales pipeline data
58
+ class Deal < TypedRecord
59
+ # REQUIRED: Set the object type to match your Attio object
60
+ # This can be either a slug (like "deals") or a UUID
61
+ object_type "deals"
62
+
63
+ # ==========================================
64
+ # ATTRIBUTE ACCESSORS (following conventions)
65
+ # ==========================================
66
+
67
+ # Simple string attribute setter/getter
68
+ # Convention: Use simple assignment for single-value attributes
69
+ def name=(name)
70
+ self[:name] = name
71
+ end
72
+
73
+ def name
74
+ self[:name]
75
+ end
76
+
77
+ # Numeric attribute with type conversion
78
+ # Convention: Convert types when it makes sense
79
+ def amount=(value)
80
+ self[:amount] = value.to_f if value
81
+ end
82
+
83
+ def amount
84
+ self[:amount]
85
+ end
86
+
87
+ # Select/dropdown attribute
88
+ # Convention: Validate allowed values if known
89
+ VALID_STAGES = ["prospecting", "qualification", "proposal", "negotiation", "closed_won", "closed_lost"].freeze
90
+
91
+ def stage=(stage)
92
+ if stage && !VALID_STAGES.include?(stage.to_s)
93
+ raise ArgumentError, "Invalid stage: #{stage}. Must be one of: #{VALID_STAGES.join(', ')}"
94
+ end
95
+ self[:stage] = stage
96
+ end
97
+
98
+ def stage
99
+ self[:stage]
100
+ end
101
+
102
+ # Date attribute with parsing
103
+ # Convention: Accept multiple date formats for convenience
104
+ def close_date=(date)
105
+ case date
106
+ when String
107
+ self[:close_date] = Date.parse(date).iso8601
108
+ when Date
109
+ self[:close_date] = date.iso8601
110
+ when Time, DateTime
111
+ self[:close_date] = date.to_date.iso8601
112
+ when nil
113
+ self[:close_date] = nil
114
+ else
115
+ raise ArgumentError, "Close date must be a String, Date, Time, or DateTime"
116
+ end
117
+ end
118
+
119
+ def close_date
120
+ date_str = self[:close_date]
121
+ Date.parse(date_str) if date_str
122
+ end
123
+
124
+ # Percentage attribute with validation
125
+ # Convention: Validate business rules
126
+ def probability=(value)
127
+ prob = value.to_f
128
+ if prob < 0 || prob > 100
129
+ raise ArgumentError, "Probability must be between 0 and 100"
130
+ end
131
+ self[:probability] = prob
132
+ end
133
+
134
+ def probability
135
+ self[:probability]
136
+ end
137
+
138
+ # Reference to another object (similar to Person#company=)
139
+ # Convention: Support both object instances and ID strings
140
+ def account=(account)
141
+ if account.is_a?(Company)
142
+ # Extract ID properly from company instance
143
+ company_id = account.id.is_a?(Hash) ? account.id["record_id"] : account.id
144
+ self[:account] = [{
145
+ target_object: "companies",
146
+ target_record_id: company_id
147
+ }]
148
+ elsif account.is_a?(String)
149
+ self[:account] = [{
150
+ target_object: "companies",
151
+ target_record_id: account
152
+ }]
153
+ elsif account.nil?
154
+ self[:account] = nil
155
+ else
156
+ raise ArgumentError, "Account must be a Company instance or ID string"
157
+ end
158
+ end
159
+
160
+ # Reference to Person (deal owner)
161
+ def owner=(person)
162
+ if person.is_a?(Person)
163
+ person_id = person.id.is_a?(Hash) ? person.id["record_id"] : person.id
164
+ self[:owner] = [{
165
+ target_object: "people",
166
+ target_record_id: person_id
167
+ }]
168
+ elsif person.is_a?(String)
169
+ self[:owner] = [{
170
+ target_object: "people",
171
+ target_record_id: person
172
+ }]
173
+ elsif person.nil?
174
+ self[:owner] = nil
175
+ else
176
+ raise ArgumentError, "Owner must be a Person instance or ID string"
177
+ end
178
+ end
179
+
180
+ # Multi-select or array attribute
181
+ # Convention: Provide both array and add/remove methods
182
+ def tags=(tags_array)
183
+ self[:tags] = Array(tags_array)
184
+ end
185
+
186
+ def tags
187
+ self[:tags] || []
188
+ end
189
+
190
+ def add_tag(tag)
191
+ current_tags = tags
192
+ self[:tags] = (current_tags << tag).uniq
193
+ end
194
+
195
+ def remove_tag(tag)
196
+ current_tags = tags
197
+ self[:tags] = current_tags - [tag]
198
+ end
199
+
200
+ # Text/notes field
201
+ def notes=(notes)
202
+ self[:notes] = notes
203
+ end
204
+
205
+ def notes
206
+ self[:notes]
207
+ end
208
+
209
+ # ==========================================
210
+ # COMPUTED PROPERTIES AND BUSINESS LOGIC
211
+ # ==========================================
212
+
213
+ # Calculate weighted pipeline value
214
+ def weighted_value
215
+ return 0 unless amount && probability
216
+ amount * (probability / 100.0)
217
+ end
218
+
219
+ # Check if deal is in active pipeline
220
+ def active?
221
+ !closed?
222
+ end
223
+
224
+ def closed?
225
+ ["closed_won", "closed_lost"].include?(stage)
226
+ end
227
+
228
+ def won?
229
+ stage == "closed_won"
230
+ end
231
+
232
+ def lost?
233
+ stage == "closed_lost"
234
+ end
235
+
236
+ # Days until close date
237
+ def days_to_close
238
+ return nil unless close_date
239
+ (close_date - Date.today).to_i
240
+ end
241
+
242
+ def overdue?
243
+ return false unless close_date
244
+ close_date < Date.today && !closed?
245
+ end
246
+
247
+ # ==========================================
248
+ # CLASS METHODS (following Person/Company patterns)
249
+ # ==========================================
250
+
251
+ class << self
252
+ # Override create to provide a more intuitive interface
253
+ # Convention: List common attributes as named parameters
254
+ def create(name:, amount: nil, stage: "prospecting", owner: nil,
255
+ account: nil, close_date: nil, probability: nil,
256
+ tags: nil, notes: nil, values: {}, **opts)
257
+ # Build the values hash
258
+ values[:name] = name
259
+ values[:amount] = amount if amount
260
+ values[:stage] = stage
261
+
262
+ # Handle references
263
+ if owner && !values[:owner]
264
+ owner_ref = if owner.is_a?(Person)
265
+ owner_id = owner.id.is_a?(Hash) ? owner.id["record_id"] : owner.id
266
+ {
267
+ target_object: "people",
268
+ target_record_id: owner_id
269
+ }
270
+ elsif owner.is_a?(String)
271
+ {
272
+ target_object: "people",
273
+ target_record_id: owner
274
+ }
275
+ end
276
+ values[:owner] = [owner_ref] if owner_ref
277
+ end
278
+
279
+ if account && !values[:account]
280
+ account_ref = if account.is_a?(Company)
281
+ account_id = account.id.is_a?(Hash) ? account.id["record_id"] : account.id
282
+ {
283
+ target_object: "companies",
284
+ target_record_id: account_id
285
+ }
286
+ elsif account.is_a?(String)
287
+ {
288
+ target_object: "companies",
289
+ target_record_id: account
290
+ }
291
+ end
292
+ values[:account] = [account_ref] if account_ref
293
+ end
294
+
295
+ # Handle dates
296
+ if close_date && !values[:close_date]
297
+ values[:close_date] = case close_date
298
+ when String
299
+ Date.parse(close_date).iso8601
300
+ when Date
301
+ close_date.iso8601
302
+ when Time, DateTime
303
+ close_date.to_date.iso8601
304
+ end
305
+ end
306
+
307
+ values[:probability] = probability if probability
308
+ values[:tags] = Array(tags) if tags
309
+ values[:notes] = notes if notes
310
+
311
+ super(values: values, **opts)
312
+ end
313
+
314
+ # Find deals by stage
315
+ # Convention: Provide find_by_* methods for common queries
316
+ def find_by_stage(stage, **opts)
317
+ list(**opts.merge(
318
+ filter: {
319
+ stage: { "$eq": stage }
320
+ }
321
+ ))
322
+ end
323
+
324
+ # Find deals by owner
325
+ def find_by_owner(owner, **opts)
326
+ owner_id = case owner
327
+ when Person
328
+ owner.id.is_a?(Hash) ? owner.id["record_id"] : owner.id
329
+ when String
330
+ owner
331
+ else
332
+ raise ArgumentError, "Owner must be a Person instance or ID string"
333
+ end
334
+
335
+ list(**opts.merge(
336
+ filter: {
337
+ owner: { "$references": owner_id }
338
+ }
339
+ ))
340
+ end
341
+
342
+ # Find deals by account
343
+ def find_by_account(account, **opts)
344
+ account_id = case account
345
+ when Company
346
+ account.id.is_a?(Hash) ? account.id["record_id"] : account.id
347
+ when String
348
+ account
349
+ else
350
+ raise ArgumentError, "Account must be a Company instance or ID string"
351
+ end
352
+
353
+ list(**opts.merge(
354
+ filter: {
355
+ account: { "$references": account_id }
356
+ }
357
+ ))
358
+ end
359
+
360
+ # Find deals closing in the next N days
361
+ def closing_soon(days = 30, **opts)
362
+ today = Date.today
363
+ future_date = today + days
364
+
365
+ list(**opts.merge(
366
+ filter: {
367
+ "$and": [
368
+ { close_date: { "$gte": today.iso8601 } },
369
+ { close_date: { "$lte": future_date.iso8601 } },
370
+ { stage: { "$nin": ["closed_won", "closed_lost"] } }
371
+ ]
372
+ }
373
+ ))
374
+ end
375
+
376
+ # Find overdue deals
377
+ def overdue(**opts)
378
+ list(**opts.merge(
379
+ filter: {
380
+ "$and": [
381
+ { close_date: { "$lt": Date.today.iso8601 } },
382
+ { stage: { "$nin": ["closed_won", "closed_lost"] } }
383
+ ]
384
+ }
385
+ ))
386
+ end
387
+
388
+ # Find high-value deals
389
+ def high_value(threshold = 100000, **opts)
390
+ list(**opts.merge(
391
+ filter: {
392
+ amount: { "$gte": threshold }
393
+ }
394
+ ))
395
+ end
396
+
397
+ # Search deals by name
398
+ # Convention: Override search to provide meaningful search behavior
399
+ def search(query, **opts)
400
+ list(**opts.merge(
401
+ filter: {
402
+ name: { "$contains": query }
403
+ }
404
+ ))
405
+ end
406
+
407
+ # Get pipeline summary
408
+ def pipeline_summary(**opts)
409
+ # This would need to aggregate data client-side
410
+ # as Attio doesn't support aggregation queries
411
+ deals = all(**opts)
412
+
413
+ stages = {}
414
+ VALID_STAGES.each do |stage|
415
+ stage_deals = deals.select { |d| d.stage == stage }
416
+ stages[stage] = {
417
+ count: stage_deals.size,
418
+ total_value: stage_deals.sum(&:amount),
419
+ weighted_value: stage_deals.sum(&:weighted_value)
420
+ }
421
+ end
422
+
423
+ stages
424
+ end
425
+ end
426
+ end
427
+
428
+ # Convenience alias (following Person/People pattern)
429
+ Deals = Deal
430
+ end
431
+ ```
432
+
433
+ ## Naming Conventions
434
+
435
+ Follow these conventions to maintain consistency with the built-in Person and Company classes:
436
+
437
+ ### Class Naming
438
+ - Use singular form: `Deal`, not `Deals`
439
+ - Add a plural constant alias: `Deals = Deal`
440
+ - Use PascalCase for the class name
441
+
442
+ ### Object Type
443
+ - Use the plural form from your Attio workspace: `"deals"`, `"tickets"`, `"projects"`
444
+ - Can also use the object's UUID if you don't have a slug
445
+
446
+ ### Attribute Methods
447
+ - Simple attributes: `name=` / `name`
448
+ - Boolean attributes: `active?`, `closed?`, `overdue?`
449
+ - Add methods: `add_tag`, `add_comment`, `add_attachment`
450
+ - Remove methods: `remove_tag`, `remove_comment`
451
+ - Set methods for complex attributes: `set_priority`, `set_status`
452
+
453
+ ### Class Methods
454
+ - `create` - Override with named parameters for common attributes
455
+ - `find_by_*` - Specific finders like `find_by_email`, `find_by_stage`
456
+ - `search` - Override to define how text search works
457
+ - Domain-specific queries: `overdue`, `high_priority`, `closing_soon`
458
+
459
+ ## Required Methods
460
+
461
+ The only truly required element is the `object_type` declaration:
462
+
463
+ ```ruby
464
+ class YourRecord < TypedRecord
465
+ object_type "your_object_slug" # THIS IS REQUIRED!
466
+ end
467
+ ```
468
+
469
+ Everything else is optional, but you should consider implementing:
470
+
471
+ 1. **Attribute accessors** for all your object's fields
472
+ 2. **create class method** with named parameters
473
+ 3. **search class method** with appropriate filters
474
+
475
+ ## Optional Overrides
476
+
477
+ All these methods can be overridden but have sensible defaults:
478
+
479
+ ### Class Methods (already implemented in TypedRecord)
480
+ - `list(**opts)` - Automatically includes object type
481
+ - `retrieve(record_id, **opts)` - Automatically includes object type
482
+ - `update(record_id, values:, **opts)` - Automatically includes object type
483
+ - `delete(record_id, **opts)` - Automatically includes object type
484
+ - `find(record_id, **opts)` - Alias for retrieve
485
+ - `all(**opts)` - Alias for list
486
+ - `find_by(attribute, value, **opts)` - Generic attribute finder
487
+
488
+ ### Instance Methods (inherited from TypedRecord)
489
+ - `save(**opts)` - Saves changes if any
490
+ - `destroy(**opts)` - Deletes the record
491
+ - `persisted?` - Checks if record exists in Attio
492
+ - `changed?` - Checks if there are unsaved changes
493
+
494
+ ## Advanced Examples
495
+
496
+ ### Project Management System
497
+
498
+ ```ruby
499
+ module Attio
500
+ class Project < TypedRecord
501
+ object_type "projects"
502
+
503
+ STATUSES = ["planning", "active", "on_hold", "completed", "cancelled"].freeze
504
+ PRIORITIES = ["low", "medium", "high", "critical"].freeze
505
+
506
+ # Basic attributes
507
+ def name=(name)
508
+ self[:name] = name
509
+ end
510
+
511
+ def description=(desc)
512
+ self[:description] = desc
513
+ end
514
+
515
+ def status=(status)
516
+ unless STATUSES.include?(status)
517
+ raise ArgumentError, "Invalid status: #{status}"
518
+ end
519
+ self[:status] = status
520
+ end
521
+
522
+ def priority=(priority)
523
+ unless PRIORITIES.include?(priority)
524
+ raise ArgumentError, "Invalid priority: #{priority}"
525
+ end
526
+ self[:priority] = priority
527
+ end
528
+
529
+ # Date handling
530
+ def start_date=(date)
531
+ self[:start_date] = parse_date(date)
532
+ end
533
+
534
+ def end_date=(date)
535
+ self[:end_date] = parse_date(date)
536
+ end
537
+
538
+ def start_date
539
+ Date.parse(self[:start_date]) if self[:start_date]
540
+ end
541
+
542
+ def end_date
543
+ Date.parse(self[:end_date]) if self[:end_date]
544
+ end
545
+
546
+ # Team members (array of person references)
547
+ def team_members=(people)
548
+ self[:team_members] = people.map do |person|
549
+ person_id = case person
550
+ when Person
551
+ person.id.is_a?(Hash) ? person.id["record_id"] : person.id
552
+ when String
553
+ person
554
+ else
555
+ raise ArgumentError, "Team members must be Person instances or ID strings"
556
+ end
557
+
558
+ {
559
+ target_object: "people",
560
+ target_record_id: person_id
561
+ }
562
+ end
563
+ end
564
+
565
+ def add_team_member(person)
566
+ current = self[:team_members] || []
567
+ person_ref = case person
568
+ when Person
569
+ person_id = person.id.is_a?(Hash) ? person.id["record_id"] : person.id
570
+ {
571
+ target_object: "people",
572
+ target_record_id: person_id
573
+ }
574
+ when String
575
+ {
576
+ target_object: "people",
577
+ target_record_id: person
578
+ }
579
+ end
580
+
581
+ self[:team_members] = current + [person_ref]
582
+ end
583
+
584
+ # Computed properties
585
+ def duration_days
586
+ return nil unless start_date && end_date
587
+ (end_date - start_date).to_i
588
+ end
589
+
590
+ def active?
591
+ status == "active"
592
+ end
593
+
594
+ def completed?
595
+ status == "completed"
596
+ end
597
+
598
+ def overdue?
599
+ return false unless end_date
600
+ end_date < Date.today && !["completed", "cancelled"].include?(status)
601
+ end
602
+
603
+ private
604
+
605
+ def parse_date(date)
606
+ case date
607
+ when String
608
+ Date.parse(date).iso8601
609
+ when Date
610
+ date.iso8601
611
+ when Time, DateTime
612
+ date.to_date.iso8601
613
+ else
614
+ raise ArgumentError, "Date must be a String, Date, Time, or DateTime"
615
+ end
616
+ end
617
+
618
+ class << self
619
+ def create(name:, status: "planning", priority: "medium",
620
+ description: nil, start_date: nil, end_date: nil,
621
+ team_members: nil, values: {}, **opts)
622
+ values[:name] = name
623
+ values[:status] = status
624
+ values[:priority] = priority
625
+ values[:description] = description if description
626
+
627
+ if start_date
628
+ values[:start_date] = case start_date
629
+ when String then Date.parse(start_date).iso8601
630
+ when Date then start_date.iso8601
631
+ when Time, DateTime then start_date.to_date.iso8601
632
+ end
633
+ end
634
+
635
+ if end_date
636
+ values[:end_date] = case end_date
637
+ when String then Date.parse(end_date).iso8601
638
+ when Date then end_date.iso8601
639
+ when Time, DateTime then end_date.to_date.iso8601
640
+ end
641
+ end
642
+
643
+ if team_members
644
+ values[:team_members] = team_members.map do |person|
645
+ person_id = case person
646
+ when Person
647
+ person.id.is_a?(Hash) ? person.id["record_id"] : person.id
648
+ when String
649
+ person
650
+ end
651
+
652
+ {
653
+ target_object: "people",
654
+ target_record_id: person_id
655
+ }
656
+ end
657
+ end
658
+
659
+ super(values: values, **opts)
660
+ end
661
+
662
+ def active(**opts)
663
+ find_by_status("active", **opts)
664
+ end
665
+
666
+ def find_by_status(status, **opts)
667
+ list(**opts.merge(
668
+ filter: { status: { "$eq": status } }
669
+ ))
670
+ end
671
+
672
+ def find_by_priority(priority, **opts)
673
+ list(**opts.merge(
674
+ filter: { priority: { "$eq": priority } }
675
+ ))
676
+ end
677
+
678
+ def high_priority(**opts)
679
+ list(**opts.merge(
680
+ filter: {
681
+ priority: { "$in": ["high", "critical"] }
682
+ }
683
+ ))
684
+ end
685
+
686
+ def overdue(**opts)
687
+ list(**opts.merge(
688
+ filter: {
689
+ "$and": [
690
+ { end_date: { "$lt": Date.today.iso8601 } },
691
+ { status: { "$nin": ["completed", "cancelled"] } }
692
+ ]
693
+ }
694
+ ))
695
+ end
696
+
697
+ def starting_soon(days = 7, **opts)
698
+ today = Date.today
699
+ future_date = today + days
700
+
701
+ list(**opts.merge(
702
+ filter: {
703
+ "$and": [
704
+ { start_date: { "$gte": today.iso8601 } },
705
+ { start_date: { "$lte": future_date.iso8601 } }
706
+ ]
707
+ }
708
+ ))
709
+ end
710
+ end
711
+ end
712
+
713
+ Projects = Project
714
+ end
715
+ ```
716
+
717
+ ### Customer Support Tickets
718
+
719
+ ```ruby
720
+ module Attio
721
+ class Ticket < TypedRecord
722
+ object_type "support_tickets"
723
+
724
+ PRIORITIES = ["low", "normal", "high", "urgent"].freeze
725
+ STATUSES = ["new", "open", "pending", "resolved", "closed"].freeze
726
+ CATEGORIES = ["bug", "feature_request", "question", "complaint", "other"].freeze
727
+
728
+ # Basic attributes
729
+ def subject=(subject)
730
+ self[:subject] = subject
731
+ end
732
+
733
+ def subject
734
+ self[:subject]
735
+ end
736
+
737
+ def description=(desc)
738
+ self[:description] = desc
739
+ end
740
+
741
+ def priority=(priority)
742
+ unless PRIORITIES.include?(priority)
743
+ raise ArgumentError, "Invalid priority: #{priority}"
744
+ end
745
+ self[:priority] = priority
746
+ end
747
+
748
+ def status=(status)
749
+ unless STATUSES.include?(status)
750
+ raise ArgumentError, "Invalid status: #{status}"
751
+ end
752
+ self[:status] = status
753
+ end
754
+
755
+ def category=(category)
756
+ unless CATEGORIES.include?(category)
757
+ raise ArgumentError, "Invalid category: #{category}"
758
+ end
759
+ self[:category] = category
760
+ end
761
+
762
+ # Customer reference
763
+ def customer=(person)
764
+ if person.is_a?(Person)
765
+ person_id = person.id.is_a?(Hash) ? person.id["record_id"] : person.id
766
+ self[:customer] = [{
767
+ target_object: "people",
768
+ target_record_id: person_id
769
+ }]
770
+ elsif person.is_a?(String)
771
+ self[:customer] = [{
772
+ target_object: "people",
773
+ target_record_id: person
774
+ }]
775
+ else
776
+ raise ArgumentError, "Customer must be a Person instance or ID string"
777
+ end
778
+ end
779
+
780
+ # Assigned agent
781
+ def assigned_to=(person)
782
+ if person.is_a?(Person)
783
+ person_id = person.id.is_a?(Hash) ? person.id["record_id"] : person.id
784
+ self[:assigned_to] = [{
785
+ target_object: "people",
786
+ target_record_id: person_id
787
+ }]
788
+ elsif person.is_a?(String)
789
+ self[:assigned_to] = [{
790
+ target_object: "people",
791
+ target_record_id: person
792
+ }]
793
+ elsif person.nil?
794
+ self[:assigned_to] = nil
795
+ else
796
+ raise ArgumentError, "Assigned person must be a Person instance or ID string"
797
+ end
798
+ end
799
+
800
+ # SLA and timing
801
+ def created_at
802
+ Time.parse(self[:created_at]) if self[:created_at]
803
+ end
804
+
805
+ def resolved_at=(time)
806
+ self[:resolved_at] = time&.iso8601
807
+ end
808
+
809
+ def resolved_at
810
+ Time.parse(self[:resolved_at]) if self[:resolved_at]
811
+ end
812
+
813
+ def first_response_at=(time)
814
+ self[:first_response_at] = time&.iso8601
815
+ end
816
+
817
+ def first_response_at
818
+ Time.parse(self[:first_response_at]) if self[:first_response_at]
819
+ end
820
+
821
+ # Computed properties
822
+ def open?
823
+ ["new", "open", "pending"].include?(status)
824
+ end
825
+
826
+ def closed?
827
+ ["resolved", "closed"].include?(status)
828
+ end
829
+
830
+ def response_time_hours
831
+ return nil unless created_at && first_response_at
832
+ ((first_response_at - created_at) / 3600).round(2)
833
+ end
834
+
835
+ def resolution_time_hours
836
+ return nil unless created_at && resolved_at
837
+ ((resolved_at - created_at) / 3600).round(2)
838
+ end
839
+
840
+ def breached_sla?
841
+ return false unless self[:sla_hours]
842
+ return false unless created_at
843
+
844
+ if open?
845
+ hours_open = (Time.now - created_at) / 3600
846
+ hours_open > self[:sla_hours]
847
+ else
848
+ resolution_time_hours && resolution_time_hours > self[:sla_hours]
849
+ end
850
+ end
851
+
852
+ # Comments/notes handling
853
+ def add_comment(text, author: nil)
854
+ comments = self[:comments] || []
855
+ comment = {
856
+ text: text,
857
+ created_at: Time.now.iso8601
858
+ }
859
+
860
+ if author
861
+ author_id = case author
862
+ when Person
863
+ author.id.is_a?(Hash) ? author.id["record_id"] : author.id
864
+ when String
865
+ author
866
+ end
867
+
868
+ comment[:author] = {
869
+ target_object: "people",
870
+ target_record_id: author_id
871
+ }
872
+ end
873
+
874
+ self[:comments] = comments + [comment]
875
+ end
876
+
877
+ class << self
878
+ def create(subject:, description:, customer:, priority: "normal",
879
+ status: "new", category: "other", assigned_to: nil,
880
+ sla_hours: nil, values: {}, **opts)
881
+ values[:subject] = subject
882
+ values[:description] = description
883
+ values[:priority] = priority
884
+ values[:status] = status
885
+ values[:category] = category
886
+ values[:sla_hours] = sla_hours if sla_hours
887
+
888
+ # Handle customer reference
889
+ customer_ref = case customer
890
+ when Person
891
+ customer_id = customer.id.is_a?(Hash) ? customer.id["record_id"] : customer.id
892
+ {
893
+ target_object: "people",
894
+ target_record_id: customer_id
895
+ }
896
+ when String
897
+ {
898
+ target_object: "people",
899
+ target_record_id: customer
900
+ }
901
+ end
902
+ values[:customer] = [customer_ref]
903
+
904
+ # Handle assigned_to reference
905
+ if assigned_to
906
+ assigned_ref = case assigned_to
907
+ when Person
908
+ assigned_id = assigned_to.id.is_a?(Hash) ? assigned_to.id["record_id"] : assigned_to.id
909
+ {
910
+ target_object: "people",
911
+ target_record_id: assigned_id
912
+ }
913
+ when String
914
+ {
915
+ target_object: "people",
916
+ target_record_id: assigned_to
917
+ }
918
+ end
919
+ values[:assigned_to] = [assigned_ref]
920
+ end
921
+
922
+ super(values: values, **opts)
923
+ end
924
+
925
+ def open_tickets(**opts)
926
+ list(**opts.merge(
927
+ filter: {
928
+ status: { "$in": ["new", "open", "pending"] }
929
+ }
930
+ ))
931
+ end
932
+
933
+ def unassigned(**opts)
934
+ list(**opts.merge(
935
+ filter: {
936
+ assigned_to: { "$exists": false }
937
+ }
938
+ ))
939
+ end
940
+
941
+ def find_by_customer(customer, **opts)
942
+ customer_id = case customer
943
+ when Person
944
+ customer.id.is_a?(Hash) ? customer.id["record_id"] : customer.id
945
+ when String
946
+ customer
947
+ end
948
+
949
+ list(**opts.merge(
950
+ filter: {
951
+ customer: { "$references": customer_id }
952
+ }
953
+ ))
954
+ end
955
+
956
+ def find_by_status(status, **opts)
957
+ list(**opts.merge(
958
+ filter: {
959
+ status: { "$eq": status }
960
+ }
961
+ ))
962
+ end
963
+
964
+ def high_priority(**opts)
965
+ list(**opts.merge(
966
+ filter: {
967
+ priority: { "$in": ["high", "urgent"] }
968
+ }
969
+ ))
970
+ end
971
+
972
+ def breached_sla(**opts)
973
+ # This would need to be filtered client-side
974
+ # as Attio doesn't support computed field queries
975
+ tickets = open_tickets(**opts)
976
+ tickets.select(&:breached_sla?)
977
+ end
978
+
979
+ def search(query, **opts)
980
+ list(**opts.merge(
981
+ filter: {
982
+ "$or": [
983
+ { subject: { "$contains": query } },
984
+ { description: { "$contains": query } }
985
+ ]
986
+ }
987
+ ))
988
+ end
989
+ end
990
+ end
991
+
992
+ Tickets = Ticket
993
+ end
994
+ ```
995
+
996
+ ### Invoice Records
997
+
998
+ ```ruby
999
+ module Attio
1000
+ class Invoice < TypedRecord
1001
+ object_type "invoices"
1002
+
1003
+ STATUSES = ["draft", "sent", "paid", "overdue", "cancelled"].freeze
1004
+
1005
+ # Basic attributes
1006
+ def invoice_number=(number)
1007
+ self[:invoice_number] = number
1008
+ end
1009
+
1010
+ def invoice_number
1011
+ self[:invoice_number]
1012
+ end
1013
+
1014
+ def amount=(value)
1015
+ self[:amount] = value.to_f
1016
+ end
1017
+
1018
+ def amount
1019
+ self[:amount]
1020
+ end
1021
+
1022
+ def currency=(currency)
1023
+ self[:currency] = currency.upcase
1024
+ end
1025
+
1026
+ def currency
1027
+ self[:currency] || "USD"
1028
+ end
1029
+
1030
+ def status=(status)
1031
+ unless STATUSES.include?(status)
1032
+ raise ArgumentError, "Invalid status: #{status}"
1033
+ end
1034
+ self[:status] = status
1035
+ end
1036
+
1037
+ def status
1038
+ self[:status]
1039
+ end
1040
+
1041
+ # Date handling
1042
+ def issue_date=(date)
1043
+ self[:issue_date] = parse_date(date)
1044
+ end
1045
+
1046
+ def issue_date
1047
+ Date.parse(self[:issue_date]) if self[:issue_date]
1048
+ end
1049
+
1050
+ def due_date=(date)
1051
+ self[:due_date] = parse_date(date)
1052
+ end
1053
+
1054
+ def due_date
1055
+ Date.parse(self[:due_date]) if self[:due_date]
1056
+ end
1057
+
1058
+ def paid_date=(date)
1059
+ self[:paid_date] = date ? parse_date(date) : nil
1060
+ end
1061
+
1062
+ def paid_date
1063
+ Date.parse(self[:paid_date]) if self[:paid_date]
1064
+ end
1065
+
1066
+ # Customer reference
1067
+ def customer=(company)
1068
+ if company.is_a?(Company)
1069
+ company_id = company.id.is_a?(Hash) ? company.id["record_id"] : company.id
1070
+ self[:customer] = [{
1071
+ target_object: "companies",
1072
+ target_record_id: company_id
1073
+ }]
1074
+ elsif company.is_a?(String)
1075
+ self[:customer] = [{
1076
+ target_object: "companies",
1077
+ target_record_id: company
1078
+ }]
1079
+ else
1080
+ raise ArgumentError, "Customer must be a Company instance or ID string"
1081
+ end
1082
+ end
1083
+
1084
+ # Line items (array of hashes)
1085
+ def line_items=(items)
1086
+ self[:line_items] = items.map do |item|
1087
+ {
1088
+ description: item[:description],
1089
+ quantity: item[:quantity].to_f,
1090
+ unit_price: item[:unit_price].to_f,
1091
+ total: (item[:quantity].to_f * item[:unit_price].to_f)
1092
+ }
1093
+ end
1094
+ end
1095
+
1096
+ def add_line_item(description:, quantity:, unit_price:)
1097
+ items = self[:line_items] || []
1098
+ items << {
1099
+ description: description,
1100
+ quantity: quantity.to_f,
1101
+ unit_price: unit_price.to_f,
1102
+ total: (quantity.to_f * unit_price.to_f)
1103
+ }
1104
+ self[:line_items] = items
1105
+
1106
+ # Recalculate total
1107
+ self[:amount] = calculate_total
1108
+ end
1109
+
1110
+ # Computed properties
1111
+ def calculate_total
1112
+ return 0 unless self[:line_items]
1113
+
1114
+ self[:line_items].sum { |item| item[:total] || 0 }
1115
+ end
1116
+
1117
+ def days_overdue
1118
+ return nil unless due_date && !paid?
1119
+ return 0 unless Date.today > due_date
1120
+
1121
+ (Date.today - due_date).to_i
1122
+ end
1123
+
1124
+ def paid?
1125
+ status == "paid"
1126
+ end
1127
+
1128
+ def overdue?
1129
+ return false if paid?
1130
+ due_date && due_date < Date.today
1131
+ end
1132
+
1133
+ def mark_as_paid(payment_date = Date.today)
1134
+ self.status = "paid"
1135
+ self.paid_date = payment_date
1136
+ end
1137
+
1138
+ private
1139
+
1140
+ def parse_date(date)
1141
+ case date
1142
+ when String
1143
+ Date.parse(date).iso8601
1144
+ when Date
1145
+ date.iso8601
1146
+ when Time, DateTime
1147
+ date.to_date.iso8601
1148
+ else
1149
+ raise ArgumentError, "Date must be a String, Date, Time, or DateTime"
1150
+ end
1151
+ end
1152
+
1153
+ class << self
1154
+ def create(invoice_number:, customer:, amount:, due_date:,
1155
+ currency: "USD", status: "draft", issue_date: Date.today,
1156
+ line_items: nil, values: {}, **opts)
1157
+ values[:invoice_number] = invoice_number
1158
+ values[:amount] = amount.to_f
1159
+ values[:currency] = currency.upcase
1160
+ values[:status] = status
1161
+
1162
+ # Handle dates
1163
+ values[:issue_date] = case issue_date
1164
+ when String then Date.parse(issue_date).iso8601
1165
+ when Date then issue_date.iso8601
1166
+ when Time, DateTime then issue_date.to_date.iso8601
1167
+ end
1168
+
1169
+ values[:due_date] = case due_date
1170
+ when String then Date.parse(due_date).iso8601
1171
+ when Date then due_date.iso8601
1172
+ when Time, DateTime then due_date.to_date.iso8601
1173
+ end
1174
+
1175
+ # Handle customer reference
1176
+ customer_ref = case customer
1177
+ when Company
1178
+ customer_id = customer.id.is_a?(Hash) ? customer.id["record_id"] : customer.id
1179
+ {
1180
+ target_object: "companies",
1181
+ target_record_id: customer_id
1182
+ }
1183
+ when String
1184
+ {
1185
+ target_object: "companies",
1186
+ target_record_id: customer
1187
+ }
1188
+ end
1189
+ values[:customer] = [customer_ref]
1190
+
1191
+ # Handle line items
1192
+ if line_items
1193
+ values[:line_items] = line_items.map do |item|
1194
+ {
1195
+ description: item[:description],
1196
+ quantity: item[:quantity].to_f,
1197
+ unit_price: item[:unit_price].to_f,
1198
+ total: (item[:quantity].to_f * item[:unit_price].to_f)
1199
+ }
1200
+ end
1201
+ end
1202
+
1203
+ super(values: values, **opts)
1204
+ end
1205
+
1206
+ def find_by_invoice_number(number, **opts)
1207
+ list(**opts.merge(
1208
+ filter: {
1209
+ invoice_number: { "$eq": number }
1210
+ }
1211
+ )).first
1212
+ end
1213
+
1214
+ def find_by_customer(customer, **opts)
1215
+ customer_id = case customer
1216
+ when Company
1217
+ customer.id.is_a?(Hash) ? customer.id["record_id"] : customer.id
1218
+ when String
1219
+ customer
1220
+ end
1221
+
1222
+ list(**opts.merge(
1223
+ filter: {
1224
+ customer: { "$references": customer_id }
1225
+ }
1226
+ ))
1227
+ end
1228
+
1229
+ def unpaid(**opts)
1230
+ list(**opts.merge(
1231
+ filter: {
1232
+ status: { "$ne": "paid" }
1233
+ }
1234
+ ))
1235
+ end
1236
+
1237
+ def overdue(**opts)
1238
+ list(**opts.merge(
1239
+ filter: {
1240
+ "$and": [
1241
+ { status: { "$ne": "paid" } },
1242
+ { due_date: { "$lt": Date.today.iso8601 } }
1243
+ ]
1244
+ }
1245
+ ))
1246
+ end
1247
+
1248
+ def paid_between(start_date, end_date, **opts)
1249
+ list(**opts.merge(
1250
+ filter: {
1251
+ "$and": [
1252
+ { status: { "$eq": "paid" } },
1253
+ { paid_date: { "$gte": parse_date(start_date) } },
1254
+ { paid_date: { "$lte": parse_date(end_date) } }
1255
+ ]
1256
+ }
1257
+ ))
1258
+ end
1259
+
1260
+ def total_revenue(start_date = nil, end_date = nil, currency: "USD", **opts)
1261
+ filters = [
1262
+ { status: { "$eq": "paid" } },
1263
+ { currency: { "$eq": currency } }
1264
+ ]
1265
+
1266
+ if start_date
1267
+ filters << { paid_date: { "$gte": parse_date(start_date) } }
1268
+ end
1269
+
1270
+ if end_date
1271
+ filters << { paid_date: { "$lte": parse_date(end_date) } }
1272
+ end
1273
+
1274
+ invoices = list(**opts.merge(
1275
+ filter: { "$and": filters }
1276
+ ))
1277
+
1278
+ invoices.sum(&:amount)
1279
+ end
1280
+
1281
+ private
1282
+
1283
+ def parse_date(date)
1284
+ case date
1285
+ when String then Date.parse(date).iso8601
1286
+ when Date then date.iso8601
1287
+ when Time, DateTime then date.to_date.iso8601
1288
+ else date
1289
+ end
1290
+ end
1291
+ end
1292
+ end
1293
+
1294
+ Invoices = Invoice
1295
+ end
1296
+ ```
1297
+
1298
+ ## Best Practices
1299
+
1300
+ ### 1. Attribute Handling
1301
+
1302
+ Always provide both setter and getter methods for attributes:
1303
+
1304
+ ```ruby
1305
+ # Good
1306
+ def status=(value)
1307
+ self[:status] = value
1308
+ end
1309
+
1310
+ def status
1311
+ self[:status]
1312
+ end
1313
+
1314
+ # Better - with validation
1315
+ def status=(value)
1316
+ unless VALID_STATUSES.include?(value)
1317
+ raise ArgumentError, "Invalid status: #{value}"
1318
+ end
1319
+ self[:status] = value
1320
+ end
1321
+ ```
1322
+
1323
+ ### 2. Date Handling
1324
+
1325
+ Be flexible with date inputs:
1326
+
1327
+ ```ruby
1328
+ def due_date=(date)
1329
+ self[:due_date] = case date
1330
+ when String
1331
+ Date.parse(date).iso8601
1332
+ when Date
1333
+ date.iso8601
1334
+ when Time, DateTime
1335
+ date.to_date.iso8601
1336
+ when nil
1337
+ nil
1338
+ else
1339
+ raise ArgumentError, "Date must be a String, Date, Time, or DateTime"
1340
+ end
1341
+ end
1342
+
1343
+ def due_date
1344
+ Date.parse(self[:due_date]) if self[:due_date]
1345
+ end
1346
+ ```
1347
+
1348
+ ### 3. Reference Handling
1349
+
1350
+ Support both object instances and ID strings:
1351
+
1352
+ ```ruby
1353
+ def owner=(person)
1354
+ if person.is_a?(Person)
1355
+ person_id = person.id.is_a?(Hash) ? person.id["record_id"] : person.id
1356
+ self[:owner] = [{
1357
+ target_object: "people",
1358
+ target_record_id: person_id
1359
+ }]
1360
+ elsif person.is_a?(String)
1361
+ self[:owner] = [{
1362
+ target_object: "people",
1363
+ target_record_id: person
1364
+ }]
1365
+ elsif person.nil?
1366
+ self[:owner] = nil
1367
+ else
1368
+ raise ArgumentError, "Owner must be a Person instance or ID string"
1369
+ end
1370
+ end
1371
+ ```
1372
+
1373
+ ### 4. Array Attributes
1374
+
1375
+ Provide both bulk and individual manipulation methods:
1376
+
1377
+ ```ruby
1378
+ def tags=(tags_array)
1379
+ self[:tags] = Array(tags_array)
1380
+ end
1381
+
1382
+ def tags
1383
+ self[:tags] || []
1384
+ end
1385
+
1386
+ def add_tag(tag)
1387
+ self[:tags] = (tags || []) << tag
1388
+ end
1389
+
1390
+ def remove_tag(tag)
1391
+ self[:tags] = tags - [tag]
1392
+ end
1393
+
1394
+ def has_tag?(tag)
1395
+ tags.include?(tag)
1396
+ end
1397
+ ```
1398
+
1399
+ ### 5. Search and Filtering
1400
+
1401
+ Use Attio's filter syntax properly:
1402
+
1403
+ ```ruby
1404
+ # Text search
1405
+ def self.search(query, **opts)
1406
+ list(**opts.merge(
1407
+ filter: {
1408
+ "$or": [
1409
+ { name: { "$contains": query } },
1410
+ { description: { "$contains": query } }
1411
+ ]
1412
+ }
1413
+ ))
1414
+ end
1415
+
1416
+ # Reference filtering
1417
+ def self.find_by_owner(owner, **opts)
1418
+ owner_id = extract_id(owner)
1419
+ list(**opts.merge(
1420
+ filter: {
1421
+ owner: { "$references": owner_id }
1422
+ }
1423
+ ))
1424
+ end
1425
+
1426
+ # Date range filtering
1427
+ def self.created_between(start_date, end_date, **opts)
1428
+ list(**opts.merge(
1429
+ filter: {
1430
+ "$and": [
1431
+ { created_at: { "$gte": start_date.iso8601 } },
1432
+ { created_at: { "$lte": end_date.iso8601 } }
1433
+ ]
1434
+ }
1435
+ ))
1436
+ end
1437
+ ```
1438
+
1439
+ ### 6. Error Handling
1440
+
1441
+ Always validate inputs and provide clear error messages:
1442
+
1443
+ ```ruby
1444
+ def priority=(value)
1445
+ unless VALID_PRIORITIES.include?(value)
1446
+ raise ArgumentError, "Invalid priority: #{value}. Must be one of: #{VALID_PRIORITIES.join(', ')}"
1447
+ end
1448
+ self[:priority] = value
1449
+ end
1450
+ ```
1451
+
1452
+ ### 7. Computed Properties
1453
+
1454
+ Add methods that calculate values based on attributes:
1455
+
1456
+ ```ruby
1457
+ def days_until_due
1458
+ return nil unless due_date
1459
+ (due_date - Date.today).to_i
1460
+ end
1461
+
1462
+ def completion_percentage
1463
+ return 0 unless total_tasks && completed_tasks
1464
+ ((completed_tasks.to_f / total_tasks) * 100).round
1465
+ end
1466
+ ```
1467
+
1468
+ ## Integration with Other Records
1469
+
1470
+ Your custom records can reference and interact with other records:
1471
+
1472
+ ```ruby
1473
+ class Deal < TypedRecord
1474
+ object_type "deals"
1475
+
1476
+ # Reference to a Company
1477
+ def account=(company)
1478
+ # ... handle Company reference
1479
+ end
1480
+
1481
+ # Reference to a Person
1482
+ def owner=(person)
1483
+ # ... handle Person reference
1484
+ end
1485
+
1486
+ # Get all activities related to this deal
1487
+ def activities(**opts)
1488
+ Activity.find_by_deal(self, **opts)
1489
+ end
1490
+
1491
+ # Create a related activity
1492
+ def create_activity(type:, description:, **opts)
1493
+ Activity.create(
1494
+ deal: self,
1495
+ type: type,
1496
+ description: description,
1497
+ **opts
1498
+ )
1499
+ end
1500
+ end
1501
+
1502
+ class Activity < TypedRecord
1503
+ object_type "activities"
1504
+
1505
+ # Reference back to deal
1506
+ def deal=(deal)
1507
+ if deal.is_a?(Deal)
1508
+ deal_id = deal.id.is_a?(Hash) ? deal.id["record_id"] : deal.id
1509
+ self[:deal] = [{
1510
+ target_object: "deals",
1511
+ target_record_id: deal_id
1512
+ }]
1513
+ elsif deal.is_a?(String)
1514
+ self[:deal] = [{
1515
+ target_object: "deals",
1516
+ target_record_id: deal
1517
+ }]
1518
+ end
1519
+ end
1520
+
1521
+ class << self
1522
+ def find_by_deal(deal, **opts)
1523
+ deal_id = case deal
1524
+ when Deal
1525
+ deal.id.is_a?(Hash) ? deal.id["record_id"] : deal.id
1526
+ when String
1527
+ deal
1528
+ end
1529
+
1530
+ list(**opts.merge(
1531
+ filter: {
1532
+ deal: { "$references": deal_id }
1533
+ }
1534
+ ))
1535
+ end
1536
+ end
1537
+ end
1538
+ ```
1539
+
1540
+ ## Testing Your Custom Records
1541
+
1542
+ Here's an example of how to test your custom record class:
1543
+
1544
+ ```ruby
1545
+ # spec/attio/resources/deal_spec.rb
1546
+ require "spec_helper"
1547
+
1548
+ RSpec.describe Attio::Deal do
1549
+ describe "object_type" do
1550
+ it "returns the correct object type" do
1551
+ expect(described_class.object_type).to eq("deals")
1552
+ end
1553
+ end
1554
+
1555
+ describe ".create" do
1556
+ it "creates a deal with all attributes" do
1557
+ deal = described_class.create(
1558
+ name: "Big Sale",
1559
+ amount: 100000,
1560
+ stage: "negotiation",
1561
+ close_date: "2024-12-31"
1562
+ )
1563
+
1564
+ expect(deal.name).to eq("Big Sale")
1565
+ expect(deal.amount).to eq(100000.0)
1566
+ expect(deal.stage).to eq("negotiation")
1567
+ expect(deal.close_date).to eq(Date.parse("2024-12-31"))
1568
+ end
1569
+
1570
+ it "validates stage values" do
1571
+ expect {
1572
+ described_class.new.stage = "invalid_stage"
1573
+ }.to raise_error(ArgumentError, /Invalid stage/)
1574
+ end
1575
+ end
1576
+
1577
+ describe "#weighted_value" do
1578
+ it "calculates weighted pipeline value" do
1579
+ deal = described_class.new
1580
+ deal.amount = 100000
1581
+ deal.probability = 75
1582
+
1583
+ expect(deal.weighted_value).to eq(75000.0)
1584
+ end
1585
+ end
1586
+
1587
+ describe ".closing_soon" do
1588
+ it "finds deals closing in the next N days" do
1589
+ VCR.use_cassette("deals_closing_soon") do
1590
+ deals = described_class.closing_soon(30)
1591
+
1592
+ expect(deals).to all(be_a(Attio::Deal))
1593
+ expect(deals).to all(satisfy { |d|
1594
+ d.close_date && d.close_date <= Date.today + 30
1595
+ })
1596
+ end
1597
+ end
1598
+ end
1599
+ end
1600
+ ```
1601
+
1602
+ ## Summary
1603
+
1604
+ Creating custom TypedRecord classes allows you to:
1605
+
1606
+ 1. Work with your custom Attio objects using clean Ruby syntax
1607
+ 2. Add business logic and computed properties
1608
+ 3. Validate data before sending to the API
1609
+ 4. Create intuitive query methods
1610
+ 5. Handle complex relationships between objects
1611
+ 6. Maintain consistency with the gem's built-in classes
1612
+
1613
+ The key is to follow the patterns established by the Person and Company classes while adapting them to your specific business needs.