contackd-sync 0.3.0__tar.gz → 0.4.0__tar.gz

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,16 +1,25 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contackd-sync
3
- Version: 0.3.0
4
- Summary: Python SDK for Contackd Sync
3
+ Version: 0.4.0
4
+ Summary: Sync CRM records and capture immutable person and business events with the Contackd API
5
5
  Project-URL: Homepage, https://example.com/contackd-sync
6
- Requires-Python: >=3.10
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3.9
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Python: >=3.9
7
14
  Description-Content-Type: text/markdown
8
15
  Requires-Dist: requests>=2.31.0
9
16
  Requires-Dist: urllib3>=2.0.0
10
17
 
11
18
  # contackd-sync (Python)
12
19
 
13
- Python SDK for pushing and syncing customer data (`contacts`, `leads`, `prospects`, `businesses`).
20
+ Python SDK for syncing CRM records (`contacts`, `leads`, `prospects`, and `businesses`), managing deals, and capturing immutable person and business events.
21
+
22
+ Requires Python 3.9 or later.
14
23
 
15
24
  ## Install
16
25
 
@@ -47,6 +56,53 @@ customer = client.contacts.upsert(
47
56
  print(customer)
48
57
  ```
49
58
 
59
+ ## Capture Custom Events
60
+
61
+ ```python
62
+ from contackd_sync import PersonEvent
63
+
64
+ event = client.person_events.track(
65
+ PersonEvent(
66
+ unified_id=customer.id,
67
+ event_name="financing",
68
+ status="applied",
69
+ correlation_id="application-10042",
70
+ amount="10000.00",
71
+ currency="NGN",
72
+ )
73
+ )
74
+
75
+ current_states = client.person_events.latest(customer.id)
76
+ ```
77
+
78
+ Use `BusinessEvent` with `client.business_events` for B2B actions. Both event resources expose `track`, `track_batch`, `list`, and `latest`; a retry-safe `event_id` is generated when omitted.
79
+
80
+ ## Manage Deals
81
+
82
+ ```python
83
+ from contackd_sync import Deal, DealStage, DealTransaction
84
+
85
+ deal = client.deals.create(
86
+ Deal(
87
+ contact_id=customer.id,
88
+ title="Annual facility management plan",
89
+ amount="150000.00",
90
+ currency="NGN",
91
+ )
92
+ )
93
+
94
+ client.deals.update(deal.id, assigned_to_user_id="sales-agent-id")
95
+ won = client.deals.update_stage(
96
+ deal.id,
97
+ DealStage.WON,
98
+ transaction=DealTransaction(external_transaction_id="payment-10042"),
99
+ )
100
+ ```
101
+
102
+ `client.deals` also provides `get` and `list`. List filters include
103
+ `contact_id`, `lead_id`, `conversation_id`, `stage`, `q`, and
104
+ `assigned_to_user_id`.
105
+
50
106
  ## Common Customer Push Patterns
51
107
 
52
108
  ### 1) Push one new customer
@@ -149,3 +205,12 @@ Given `base_url = https://api.yourapp.com/api/v1`:
149
205
  - `POST /contacts|leads|prospects|businesses/bulk`
150
206
  - `POST /contacts|leads|prospects|businesses/bulk/upsert`
151
207
  - `POST /contacts|leads|prospects|businesses/bulk/delete`
208
+ - `POST /events/person|business`
209
+ - `POST /events/person|business/batch`
210
+ - `GET /events/person|business/{id}`
211
+ - `GET /events/person|business/{id}/latest`
212
+ - `POST /deals`
213
+ - `GET /deals`
214
+ - `GET /deals/{id}`
215
+ - `PATCH /deals/{id}`
216
+ - `PATCH /deals/{id}/stage`
@@ -1,16 +1,8 @@
1
- Metadata-Version: 2.4
2
- Name: contackd-sync
3
- Version: 0.3.0
4
- Summary: Python SDK for Contackd Sync
5
- Project-URL: Homepage, https://example.com/contackd-sync
6
- Requires-Python: >=3.10
7
- Description-Content-Type: text/markdown
8
- Requires-Dist: requests>=2.31.0
9
- Requires-Dist: urllib3>=2.0.0
10
-
11
1
  # contackd-sync (Python)
12
2
 
13
- Python SDK for pushing and syncing customer data (`contacts`, `leads`, `prospects`, `businesses`).
3
+ Python SDK for syncing CRM records (`contacts`, `leads`, `prospects`, and `businesses`), managing deals, and capturing immutable person and business events.
4
+
5
+ Requires Python 3.9 or later.
14
6
 
15
7
  ## Install
16
8
 
@@ -47,6 +39,53 @@ customer = client.contacts.upsert(
47
39
  print(customer)
48
40
  ```
49
41
 
42
+ ## Capture Custom Events
43
+
44
+ ```python
45
+ from contackd_sync import PersonEvent
46
+
47
+ event = client.person_events.track(
48
+ PersonEvent(
49
+ unified_id=customer.id,
50
+ event_name="financing",
51
+ status="applied",
52
+ correlation_id="application-10042",
53
+ amount="10000.00",
54
+ currency="NGN",
55
+ )
56
+ )
57
+
58
+ current_states = client.person_events.latest(customer.id)
59
+ ```
60
+
61
+ Use `BusinessEvent` with `client.business_events` for B2B actions. Both event resources expose `track`, `track_batch`, `list`, and `latest`; a retry-safe `event_id` is generated when omitted.
62
+
63
+ ## Manage Deals
64
+
65
+ ```python
66
+ from contackd_sync import Deal, DealStage, DealTransaction
67
+
68
+ deal = client.deals.create(
69
+ Deal(
70
+ contact_id=customer.id,
71
+ title="Annual facility management plan",
72
+ amount="150000.00",
73
+ currency="NGN",
74
+ )
75
+ )
76
+
77
+ client.deals.update(deal.id, assigned_to_user_id="sales-agent-id")
78
+ won = client.deals.update_stage(
79
+ deal.id,
80
+ DealStage.WON,
81
+ transaction=DealTransaction(external_transaction_id="payment-10042"),
82
+ )
83
+ ```
84
+
85
+ `client.deals` also provides `get` and `list`. List filters include
86
+ `contact_id`, `lead_id`, `conversation_id`, `stage`, `q`, and
87
+ `assigned_to_user_id`.
88
+
50
89
  ## Common Customer Push Patterns
51
90
 
52
91
  ### 1) Push one new customer
@@ -149,3 +188,12 @@ Given `base_url = https://api.yourapp.com/api/v1`:
149
188
  - `POST /contacts|leads|prospects|businesses/bulk`
150
189
  - `POST /contacts|leads|prospects|businesses/bulk/upsert`
151
190
  - `POST /contacts|leads|prospects|businesses/bulk/delete`
191
+ - `POST /events/person|business`
192
+ - `POST /events/person|business/batch`
193
+ - `GET /events/person|business/{id}`
194
+ - `GET /events/person|business/{id}/latest`
195
+ - `POST /deals`
196
+ - `GET /deals`
197
+ - `GET /deals/{id}`
198
+ - `PATCH /deals/{id}`
199
+ - `PATCH /deals/{id}/stage`
@@ -6,16 +6,24 @@ from .client import (
6
6
  Business,
7
7
  BusinessesResource,
8
8
  BulkResult,
9
+ BusinessEvent,
10
+ BusinessEventsResource,
9
11
  ConflictStrategy,
10
12
  Contact,
11
13
  ContactsResource,
12
14
  ContackdClient,
13
15
  ContackdError,
16
+ Deal,
17
+ DealsResource,
18
+ DealStage,
19
+ DealTransaction,
14
20
  Lead,
15
21
  LeadStatus,
16
22
  LeadsResource,
17
23
  NotFoundError,
18
24
  PipelineStage,
25
+ PersonEvent,
26
+ PersonEventsResource,
19
27
  Prospect,
20
28
  ProspectsResource,
21
29
  RateLimitError,
@@ -23,6 +31,9 @@ from .client import (
23
31
  SyncConflictError,
24
32
  SyncEngine,
25
33
  SyncResult,
34
+ EventBatchError,
35
+ EventBatchResult,
36
+ EventList,
26
37
  )
27
38
 
28
39
  __all__ = [
@@ -31,16 +42,24 @@ __all__ = [
31
42
  "Business",
32
43
  "BusinessesResource",
33
44
  "BulkResult",
45
+ "BusinessEvent",
46
+ "BusinessEventsResource",
34
47
  "ConflictStrategy",
35
48
  "Contact",
36
49
  "ContactsResource",
37
50
  "ContackdClient",
38
51
  "ContackdError",
52
+ "Deal",
53
+ "DealsResource",
54
+ "DealStage",
55
+ "DealTransaction",
39
56
  "Lead",
40
57
  "LeadStatus",
41
58
  "LeadsResource",
42
59
  "NotFoundError",
43
60
  "PipelineStage",
61
+ "PersonEvent",
62
+ "PersonEventsResource",
44
63
  "Prospect",
45
64
  "ProspectsResource",
46
65
  "RateLimitError",
@@ -48,4 +67,7 @@ __all__ = [
48
67
  "SyncConflictError",
49
68
  "SyncEngine",
50
69
  "SyncResult",
70
+ "EventBatchError",
71
+ "EventBatchResult",
72
+ "EventList",
51
73
  ]
@@ -4,8 +4,9 @@ import hashlib
4
4
  import json
5
5
  from dataclasses import asdict, dataclass, field
6
6
  from enum import Enum
7
- from typing import Any, Callable, Dict, Generic, List, Optional, Protocol, TypeVar, Literal
8
- from urllib.parse import urljoin
7
+ from typing import Any, Callable, Dict, Generic, List, Literal, Optional, Protocol, TypeVar, Union
8
+ from urllib.parse import quote, urljoin
9
+ from uuid import uuid4
9
10
 
10
11
  import requests
11
12
  from requests.adapters import HTTPAdapter
@@ -66,6 +67,18 @@ class PipelineStage(str, Enum):
66
67
  PURCHASE = "purchase"
67
68
 
68
69
 
70
+ class DealStage(str, Enum):
71
+ """Supported first-class deal stages."""
72
+
73
+ NEW = "new"
74
+ QUALIFIED = "qualified"
75
+ PROPOSAL = "proposal"
76
+ NEGOTIATION = "negotiation"
77
+ FOLLOW_UP = "follow_up"
78
+ WON = "won"
79
+ LOST = "lost"
80
+
81
+
69
82
  @dataclass
70
83
  class Contact:
71
84
  """Contact record model used by the Contacts API."""
@@ -222,7 +235,170 @@ class Business:
222
235
  return hashlib.sha256(payload.encode()).hexdigest()
223
236
 
224
237
 
225
- RecordType = Contact | Lead | Prospect | Business
238
+ @dataclass
239
+ class DealTransaction:
240
+ """Purchase transaction attached to a won deal."""
241
+
242
+ external_transaction_id: Optional[str] = None
243
+ order_id: Optional[str] = None
244
+ status: str = "completed"
245
+ amount: Optional[str] = None
246
+ currency: Optional[str] = None
247
+ metadata: Dict[str, Any] = field(default_factory=dict)
248
+ id: Optional[str] = None
249
+ tenant_id: Optional[str] = None
250
+ deal_id: Optional[str] = None
251
+ created_at: Optional[str] = None
252
+ updated_at: Optional[str] = None
253
+
254
+ def to_dict(self) -> dict:
255
+ """Serialize fields accepted by a deal stage update."""
256
+ data = {
257
+ "external_transaction_id": self.external_transaction_id,
258
+ "order_id": self.order_id,
259
+ "status": self.status,
260
+ "amount": self.amount,
261
+ "currency": self.currency,
262
+ "metadata": self.metadata or None,
263
+ }
264
+ return {key: value for key, value in data.items() if value is not None}
265
+
266
+ @classmethod
267
+ def from_dict(cls, data: dict) -> "DealTransaction":
268
+ """Build a transaction from API response data."""
269
+ return cls(**{key: value for key, value in data.items() if key in cls.__dataclass_fields__})
270
+
271
+
272
+ @dataclass
273
+ class Deal:
274
+ """First-class sales opportunity linked to one contact or lead."""
275
+
276
+ title: str
277
+ contact_id: Optional[str] = None
278
+ lead_id: Optional[str] = None
279
+ conversation_id: Optional[str] = None
280
+ assigned_to_user_id: Optional[str] = None
281
+ description: Optional[str] = None
282
+ product_or_service: Optional[str] = None
283
+ amount: Optional[str] = None
284
+ currency: Optional[str] = None
285
+ expected_close_at: Optional[str] = None
286
+ metadata: Dict[str, Any] = field(default_factory=dict)
287
+ id: Optional[str] = None
288
+ tenant_id: Optional[str] = None
289
+ stage: DealStage = DealStage.NEW
290
+ won_at: Optional[str] = None
291
+ lost_at: Optional[str] = None
292
+ loss_reason: Optional[str] = None
293
+ transaction: Optional[DealTransaction] = None
294
+ created_at: Optional[str] = None
295
+ updated_at: Optional[str] = None
296
+
297
+ def to_dict(self) -> dict:
298
+ """Serialize fields accepted when creating a deal."""
299
+ data = {
300
+ "contact_id": self.contact_id,
301
+ "lead_id": self.lead_id,
302
+ "conversation_id": self.conversation_id,
303
+ "assigned_to_user_id": self.assigned_to_user_id,
304
+ "title": self.title,
305
+ "description": self.description,
306
+ "product_or_service": self.product_or_service,
307
+ "amount": self.amount,
308
+ "currency": self.currency,
309
+ "expected_close_at": self.expected_close_at,
310
+ "metadata": self.metadata or None,
311
+ }
312
+ return {key: value for key, value in data.items() if value is not None}
313
+
314
+ @classmethod
315
+ def from_dict(cls, data: dict) -> "Deal":
316
+ """Build a deal from API response data."""
317
+ payload = dict(data)
318
+ if "stage" in payload:
319
+ payload["stage"] = DealStage(payload["stage"])
320
+ if isinstance(payload.get("transaction"), dict):
321
+ payload["transaction"] = DealTransaction.from_dict(payload["transaction"])
322
+ return cls(**{key: value for key, value in payload.items() if key in cls.__dataclass_fields__})
323
+
324
+
325
+ @dataclass
326
+ class PersonEvent:
327
+ """Immutable custom event attributed to a person CRM ID."""
328
+
329
+ unified_id: str
330
+ event_name: str
331
+ status: str
332
+ event_id: Optional[str] = None
333
+ correlation_id: Optional[str] = None
334
+ amount: Optional[str] = None
335
+ currency: Optional[str] = None
336
+ properties: Dict[str, Any] = field(default_factory=dict)
337
+ occurred_at: Optional[str] = None
338
+ id: Optional[str] = None
339
+ actor: Dict[str, Any] = field(default_factory=dict)
340
+ source: Optional[str] = None
341
+ sdk_version: Optional[str] = None
342
+ received_at: Optional[str] = None
343
+
344
+ def to_dict(self) -> dict:
345
+ data = {
346
+ "event_id": self.event_id,
347
+ "unified_id": self.unified_id,
348
+ "event_name": self.event_name,
349
+ "status": self.status,
350
+ "correlation_id": self.correlation_id,
351
+ "amount": self.amount,
352
+ "currency": self.currency,
353
+ "properties": self.properties,
354
+ "occurred_at": self.occurred_at,
355
+ }
356
+ return {key: value for key, value in data.items() if value is not None}
357
+
358
+ @classmethod
359
+ def from_dict(cls, data: dict) -> "PersonEvent":
360
+ return cls(**{key: value for key, value in data.items() if key in cls.__dataclass_fields__})
361
+
362
+
363
+ @dataclass
364
+ class BusinessEvent:
365
+ """Immutable custom event attributed to a business CRM ID."""
366
+
367
+ business_id: str
368
+ event_name: str
369
+ status: str
370
+ event_id: Optional[str] = None
371
+ correlation_id: Optional[str] = None
372
+ amount: Optional[str] = None
373
+ currency: Optional[str] = None
374
+ properties: Dict[str, Any] = field(default_factory=dict)
375
+ occurred_at: Optional[str] = None
376
+ id: Optional[str] = None
377
+ actor: Dict[str, Any] = field(default_factory=dict)
378
+ source: Optional[str] = None
379
+ sdk_version: Optional[str] = None
380
+ received_at: Optional[str] = None
381
+
382
+ def to_dict(self) -> dict:
383
+ data = {
384
+ "event_id": self.event_id,
385
+ "business_id": self.business_id,
386
+ "event_name": self.event_name,
387
+ "status": self.status,
388
+ "correlation_id": self.correlation_id,
389
+ "amount": self.amount,
390
+ "currency": self.currency,
391
+ "properties": self.properties,
392
+ "occurred_at": self.occurred_at,
393
+ }
394
+ return {key: value for key, value in data.items() if value is not None}
395
+
396
+ @classmethod
397
+ def from_dict(cls, data: dict) -> "BusinessEvent":
398
+ return cls(**{key: value for key, value in data.items() if key in cls.__dataclass_fields__})
399
+
400
+
401
+ RecordType = Union[Contact, Lead, Prospect, Business]
226
402
  ConflictStrategy = Literal["local", "remote", "raise"]
227
403
  RecordT = TypeVar("RecordT", Contact, Lead, Prospect, Business)
228
404
 
@@ -321,11 +497,120 @@ class _HttpClient:
321
497
  self._raise(response)
322
498
  return response.json()
323
499
 
500
+ def patch(self, path: str, body: dict) -> dict:
501
+ response = self.session.patch(self._url(path), json=body, timeout=self.timeout)
502
+ self._raise(response)
503
+ return response.json()
504
+
324
505
  def delete(self, path: str) -> None:
325
506
  response = self.session.delete(self._url(path), timeout=self.timeout)
326
507
  self._raise(response)
327
508
 
328
509
 
510
+ EventT = TypeVar("EventT", PersonEvent, BusinessEvent)
511
+
512
+
513
+ @dataclass
514
+ class EventBatchError:
515
+ index: int
516
+ status_code: int
517
+ detail: Any
518
+ event_id: Optional[str] = None
519
+
520
+
521
+ @dataclass
522
+ class EventBatchResult(Generic[EventT]):
523
+ created: List[EventT] = field(default_factory=list)
524
+ duplicates: List[EventT] = field(default_factory=list)
525
+ errors: List[EventBatchError] = field(default_factory=list)
526
+ total: int = 0
527
+
528
+
529
+ @dataclass
530
+ class EventList(Generic[EventT]):
531
+ results: List[EventT] = field(default_factory=list)
532
+ page: int = 1
533
+ per_page: int = 50
534
+ total: int = 0
535
+
536
+
537
+ class _EventsResource(Generic[EventT]):
538
+ def __init__(self, http: _HttpClient, kind: str, model: type[EventT]):
539
+ self._http = http
540
+ self._kind = kind
541
+ self._model = model
542
+
543
+ @staticmethod
544
+ def _event_body(event: EventT) -> dict:
545
+ body = event.to_dict()
546
+ if not body.get("event_id"):
547
+ body["event_id"] = f"evt_{uuid4()}"
548
+ return body
549
+
550
+ def track(self, event: EventT) -> EventT:
551
+ data = self._http.post(f"events/{self._kind}", self._event_body(event))
552
+ return self._model.from_dict(data)
553
+
554
+ def track_batch(self, events: List[EventT]) -> EventBatchResult[EventT]:
555
+ data = self._http.post(
556
+ f"events/{self._kind}/batch",
557
+ {"events": [self._event_body(event) for event in events]},
558
+ )
559
+ return EventBatchResult(
560
+ created=[self._model.from_dict(item) for item in data.get("created", [])],
561
+ duplicates=[self._model.from_dict(item) for item in data.get("duplicates", [])],
562
+ errors=[
563
+ EventBatchError(
564
+ index=item["index"],
565
+ event_id=item.get("event_id"),
566
+ status_code=item["status_code"],
567
+ detail=item.get("detail"),
568
+ )
569
+ for item in data.get("errors", [])
570
+ ],
571
+ total=data.get("total", 0),
572
+ )
573
+
574
+ def list(self, subject_id: str, page: int = 1, per_page: int = 50, **filters: Any) -> EventList[EventT]:
575
+ return self._read(subject_id, page=page, per_page=per_page, latest=False, filters=filters)
576
+
577
+ def latest(self, subject_id: str, page: int = 1, per_page: int = 50, **filters: Any) -> EventList[EventT]:
578
+ return self._read(subject_id, page=page, per_page=per_page, latest=True, filters=filters)
579
+
580
+ def _read(
581
+ self,
582
+ subject_id: str,
583
+ *,
584
+ page: int,
585
+ per_page: int,
586
+ latest: bool,
587
+ filters: Dict[str, Any],
588
+ ) -> EventList[EventT]:
589
+ suffix = "/latest" if latest else ""
590
+ data = self._http.get(
591
+ f"events/{self._kind}/{quote(subject_id, safe='')}{suffix}",
592
+ page=page,
593
+ per_page=per_page,
594
+ **filters,
595
+ )
596
+ return EventList(
597
+ results=[self._model.from_dict(item) for item in data.get("results", [])],
598
+ page=data.get("page", page),
599
+ per_page=data.get("per_page", per_page),
600
+ total=data.get("total", 0),
601
+ )
602
+
603
+
604
+ class PersonEventsResource(_EventsResource[PersonEvent]):
605
+ def __init__(self, http: _HttpClient):
606
+ super().__init__(http, "person", PersonEvent)
607
+
608
+
609
+ class BusinessEventsResource(_EventsResource[BusinessEvent]):
610
+ def __init__(self, http: _HttpClient):
611
+ super().__init__(http, "business", BusinessEvent)
612
+
613
+
329
614
  class _Resource(Generic[RecordT]):
330
615
  """Base CRUD and bulk helpers for a typed API resource."""
331
616
 
@@ -437,6 +722,55 @@ class BusinessesResource(_Resource[Business]):
437
722
  _model = Business
438
723
 
439
724
 
725
+ class DealsResource:
726
+ """Typed resource wrapper for first-class deals."""
727
+
728
+ def __init__(self, http: _HttpClient):
729
+ self._http = http
730
+
731
+ def create(self, deal: Deal) -> Deal:
732
+ """Create a deal linked to one contact or lead."""
733
+ return Deal.from_dict(self._http.post("deals", deal.to_dict()))
734
+
735
+ def get(self, deal_id: str) -> Deal:
736
+ """Fetch a deal by ID."""
737
+ data = self._http.get(f"deals/{quote(deal_id, safe='')}")
738
+ return Deal.from_dict(data)
739
+
740
+ def list(self, limit: int = 50, offset: int = 0, **filters: Any) -> List[Deal]:
741
+ """List deals using the API's filters and offset pagination."""
742
+ data = self._http.get("deals", limit=limit, offset=offset, **filters)
743
+ items = data.get("results", data) if isinstance(data, dict) else data
744
+ return [Deal.from_dict(item) for item in items]
745
+
746
+ def update(self, deal_id: str, **changes: Any) -> Deal:
747
+ """Patch assignment or editable deal details."""
748
+ data = self._http.patch(f"deals/{quote(deal_id, safe='')}", changes)
749
+ return Deal.from_dict(data)
750
+
751
+ def update_stage(
752
+ self,
753
+ deal_id: str,
754
+ stage: Union[DealStage, str],
755
+ *,
756
+ loss_reason: Optional[str] = None,
757
+ transaction: Optional[DealTransaction] = None,
758
+ ) -> Deal:
759
+ """Move a deal to another stage and optionally attach a transaction."""
760
+ body: Dict[str, Any] = {
761
+ "stage": stage.value if isinstance(stage, DealStage) else stage,
762
+ }
763
+ if loss_reason is not None:
764
+ body["loss_reason"] = loss_reason
765
+ if transaction is not None:
766
+ body["transaction"] = transaction.to_dict()
767
+ data = self._http.patch(
768
+ f"deals/{quote(deal_id, safe='')}/stage",
769
+ body,
770
+ )
771
+ return Deal.from_dict(data)
772
+
773
+
440
774
  @dataclass
441
775
  class SyncResult:
442
776
  """Outcome summary for a bidirectional sync operation."""
@@ -566,6 +900,9 @@ class ContackdClient:
566
900
  self.leads = LeadsResource(self._http)
567
901
  self.prospects = ProspectsResource(self._http)
568
902
  self.businesses = BusinessesResource(self._http)
903
+ self.deals = DealsResource(self._http)
904
+ self.person_events = PersonEventsResource(self._http)
905
+ self.business_events = BusinessEventsResource(self._http)
569
906
 
570
907
  def sync_contacts(
571
908
  self,
@@ -641,16 +978,24 @@ __all__ = [
641
978
  "Business",
642
979
  "BusinessesResource",
643
980
  "BulkResult",
981
+ "BusinessEvent",
982
+ "BusinessEventsResource",
644
983
  "ConflictStrategy",
645
984
  "Contact",
646
985
  "ContactsResource",
647
986
  "ContackdClient",
648
987
  "ContackdError",
988
+ "Deal",
989
+ "DealsResource",
990
+ "DealStage",
991
+ "DealTransaction",
649
992
  "Lead",
650
993
  "LeadStatus",
651
994
  "LeadsResource",
652
995
  "NotFoundError",
653
996
  "PipelineStage",
997
+ "PersonEvent",
998
+ "PersonEventsResource",
654
999
  "Prospect",
655
1000
  "ProspectsResource",
656
1001
  "RateLimitError",
@@ -658,4 +1003,7 @@ __all__ = [
658
1003
  "SyncConflictError",
659
1004
  "SyncEngine",
660
1005
  "SyncResult",
1006
+ "EventBatchError",
1007
+ "EventBatchResult",
1008
+ "EventList",
661
1009
  ]
@@ -1,6 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: contackd-sync
3
+ Version: 0.4.0
4
+ Summary: Sync CRM records and capture immutable person and business events with the Contackd API
5
+ Project-URL: Homepage, https://example.com/contackd-sync
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3.9
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: requests>=2.31.0
16
+ Requires-Dist: urllib3>=2.0.0
17
+
1
18
  # contackd-sync (Python)
2
19
 
3
- Python SDK for pushing and syncing customer data (`contacts`, `leads`, `prospects`, `businesses`).
20
+ Python SDK for syncing CRM records (`contacts`, `leads`, `prospects`, and `businesses`), managing deals, and capturing immutable person and business events.
21
+
22
+ Requires Python 3.9 or later.
4
23
 
5
24
  ## Install
6
25
 
@@ -37,6 +56,53 @@ customer = client.contacts.upsert(
37
56
  print(customer)
38
57
  ```
39
58
 
59
+ ## Capture Custom Events
60
+
61
+ ```python
62
+ from contackd_sync import PersonEvent
63
+
64
+ event = client.person_events.track(
65
+ PersonEvent(
66
+ unified_id=customer.id,
67
+ event_name="financing",
68
+ status="applied",
69
+ correlation_id="application-10042",
70
+ amount="10000.00",
71
+ currency="NGN",
72
+ )
73
+ )
74
+
75
+ current_states = client.person_events.latest(customer.id)
76
+ ```
77
+
78
+ Use `BusinessEvent` with `client.business_events` for B2B actions. Both event resources expose `track`, `track_batch`, `list`, and `latest`; a retry-safe `event_id` is generated when omitted.
79
+
80
+ ## Manage Deals
81
+
82
+ ```python
83
+ from contackd_sync import Deal, DealStage, DealTransaction
84
+
85
+ deal = client.deals.create(
86
+ Deal(
87
+ contact_id=customer.id,
88
+ title="Annual facility management plan",
89
+ amount="150000.00",
90
+ currency="NGN",
91
+ )
92
+ )
93
+
94
+ client.deals.update(deal.id, assigned_to_user_id="sales-agent-id")
95
+ won = client.deals.update_stage(
96
+ deal.id,
97
+ DealStage.WON,
98
+ transaction=DealTransaction(external_transaction_id="payment-10042"),
99
+ )
100
+ ```
101
+
102
+ `client.deals` also provides `get` and `list`. List filters include
103
+ `contact_id`, `lead_id`, `conversation_id`, `stage`, `q`, and
104
+ `assigned_to_user_id`.
105
+
40
106
  ## Common Customer Push Patterns
41
107
 
42
108
  ### 1) Push one new customer
@@ -139,3 +205,12 @@ Given `base_url = https://api.yourapp.com/api/v1`:
139
205
  - `POST /contacts|leads|prospects|businesses/bulk`
140
206
  - `POST /contacts|leads|prospects|businesses/bulk/upsert`
141
207
  - `POST /contacts|leads|prospects|businesses/bulk/delete`
208
+ - `POST /events/person|business`
209
+ - `POST /events/person|business/batch`
210
+ - `GET /events/person|business/{id}`
211
+ - `GET /events/person|business/{id}/latest`
212
+ - `POST /deals`
213
+ - `GET /deals`
214
+ - `GET /deals/{id}`
215
+ - `PATCH /deals/{id}`
216
+ - `PATCH /deals/{id}/stage`
@@ -9,4 +9,6 @@ contackd_sync.egg-info/dependency_links.txt
9
9
  contackd_sync.egg-info/requires.txt
10
10
  contackd_sync.egg-info/top_level.txt
11
11
  tests/test_business_sync.py
12
- tests/test_client.py
12
+ tests/test_client.py
13
+ tests/test_deals.py
14
+ tests/test_python39_compatibility.py
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "contackd-sync"
7
+ version = "0.4.0"
8
+ description = "Sync CRM records and capture immutable person and business events with the Contackd API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = [
12
+ "requests>=2.31.0",
13
+ "urllib3>=2.0.0",
14
+ ]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.9",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://example.com/contackd-sync"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["."]
30
+ include = ["contackd_sync*"]
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import version
4
+ import json
5
+ from contextlib import contextmanager
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+ from threading import Thread
8
+ from urllib.parse import parse_qs, urlparse
9
+
10
+ import contackd_sync
11
+ from contackd_sync import ContackdClient, Lead, LeadStatus, SyncEngine
12
+
13
+
14
+ def test_cold_lead_without_email_parses_conversation_fields() -> None:
15
+ lead = Lead.from_dict(
16
+ {
17
+ "id": "lead-1",
18
+ "status": "cold",
19
+ "title": "WhatsApp inquiry",
20
+ "display_name": "Ada",
21
+ "conversation_id": "conversation-1",
22
+ "external_thread_id": "+2348012345678",
23
+ "phone": "+2348012345678",
24
+ }
25
+ )
26
+
27
+ assert lead.email is None
28
+ assert lead.status is LeadStatus.COLD
29
+ assert lead.title == "WhatsApp inquiry"
30
+ assert lead.display_name == "Ada"
31
+ assert lead.conversation_id == "conversation-1"
32
+ assert lead.external_thread_id == "+2348012345678"
33
+ assert lead.phone == "+2348012345678"
34
+
35
+
36
+ def test_sdk_version_header_matches_installed_package_version() -> None:
37
+ client = ContackdClient(api_key="test-key", base_url="https://api.example.com")
38
+
39
+ assert client._http.session.headers["X-SDK-Version"] == version("contackd-sync")
40
+
41
+
42
+ class _InMemoryLeadResource:
43
+ def __init__(self, records: list[Lead]):
44
+ self.records = records
45
+
46
+ def list(self, page: int = 1, per_page: int = 100, **filters: object) -> list[Lead]:
47
+ del per_page, filters
48
+ return self.records if page == 1 else []
49
+
50
+ def bulk_upsert(self, records: list[Lead], chunk_size: int = 100) -> None:
51
+ raise AssertionError(f"unexpected push of {len(records)} records in chunks of {chunk_size}")
52
+
53
+
54
+ def test_sync_matches_email_less_leads_by_external_thread_id() -> None:
55
+ remote_a = Lead(
56
+ status=LeadStatus.COLD,
57
+ display_name="Remote Ada",
58
+ external_thread_id="thread-a",
59
+ )
60
+ remote_b = Lead(
61
+ status=LeadStatus.COLD,
62
+ display_name="Remote Grace",
63
+ external_thread_id="thread-b",
64
+ )
65
+ local_a = Lead(
66
+ status=LeadStatus.COLD,
67
+ display_name="Local Ada",
68
+ external_thread_id="thread-a",
69
+ )
70
+ engine = SyncEngine(_InMemoryLeadResource([remote_a, remote_b]), conflict_strategy="remote")
71
+
72
+ result = engine.sync([local_a])
73
+
74
+ assert result.pushed == []
75
+ assert result.remote_only == [remote_b]
76
+
77
+
78
+ @contextmanager
79
+ def _event_api_server():
80
+ requests: list[dict] = []
81
+
82
+ class Handler(BaseHTTPRequestHandler):
83
+ def log_message(self, format: str, *args: object) -> None:
84
+ del format, args
85
+
86
+ def do_POST(self) -> None:
87
+ length = int(self.headers.get("Content-Length", "0"))
88
+ body = json.loads(self.rfile.read(length) or b"{}")
89
+ requests.append({"method": "POST", "path": self.path, "body": body})
90
+ if self.path.endswith("/batch"):
91
+ response = {"created": [], "duplicates": [], "errors": [], "total": len(body["events"])}
92
+ else:
93
+ response = {
94
+ "id": "internal-1",
95
+ **body,
96
+ "actor": {"type": "api_key", "id": "user-1", "email": "apikey:mobile"},
97
+ "source": "sdk",
98
+ "sdk_version": "0.3.0",
99
+ "received_at": "2026-08-28T10:31:00Z",
100
+ }
101
+ encoded = json.dumps(response).encode()
102
+ self.send_response(201)
103
+ self.send_header("Content-Type", "application/json")
104
+ self.send_header("Content-Length", str(len(encoded)))
105
+ self.end_headers()
106
+ self.wfile.write(encoded)
107
+
108
+ def do_GET(self) -> None:
109
+ parsed = urlparse(self.path)
110
+ requests.append({"method": "GET", "path": parsed.path, "query": parse_qs(parsed.query)})
111
+ encoded = json.dumps({"results": [], "page": 2, "per_page": 25, "total": 0}).encode()
112
+ self.send_response(200)
113
+ self.send_header("Content-Type", "application/json")
114
+ self.send_header("Content-Length", str(len(encoded)))
115
+ self.end_headers()
116
+ self.wfile.write(encoded)
117
+
118
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
119
+ thread = Thread(target=server.serve_forever, daemon=True)
120
+ thread.start()
121
+ try:
122
+ yield f"http://127.0.0.1:{server.server_port}", requests
123
+ finally:
124
+ server.shutdown()
125
+ thread.join()
126
+ server.server_close()
127
+
128
+
129
+ def test_python_person_events_generate_ids_and_serialize_exact_contract() -> None:
130
+ assert hasattr(contackd_sync, "PersonEvent")
131
+ with _event_api_server() as (base_url, requests):
132
+ client = ContackdClient(api_key="test-key", base_url=base_url)
133
+ event = client.person_events.track(
134
+ contackd_sync.PersonEvent(
135
+ unified_id="contact-1",
136
+ event_name="financing",
137
+ status="applied",
138
+ correlation_id="application-1",
139
+ amount="10000.00",
140
+ currency="NGN",
141
+ properties={"product": "vehicle"},
142
+ occurred_at="2026-08-28T10:30:00Z",
143
+ )
144
+ )
145
+
146
+ assert requests[0]["body"]["event_id"].startswith("evt_")
147
+ assert requests[0]["body"]["unified_id"] == "contact-1"
148
+ assert event.event_id == requests[0]["body"]["event_id"]
149
+ assert event.received_at == "2026-08-28T10:31:00Z"
150
+
151
+
152
+ def test_python_business_events_batch_and_latest_filters() -> None:
153
+ assert hasattr(contackd_sync, "BusinessEvent")
154
+ with _event_api_server() as (base_url, requests):
155
+ client = ContackdClient(api_key="test-key", base_url=base_url)
156
+ batch = client.business_events.track_batch(
157
+ [
158
+ contackd_sync.BusinessEvent(
159
+ event_id="evt-order-1",
160
+ business_id="business-1",
161
+ event_name="purchase",
162
+ status="completed",
163
+ )
164
+ ]
165
+ )
166
+ latest = client.business_events.latest(
167
+ "business-1",
168
+ page=2,
169
+ per_page=25,
170
+ event_name="purchase",
171
+ correlation_id="order-1",
172
+ )
173
+
174
+ assert batch.total == 1
175
+ assert latest.page == 2
176
+ assert requests[0]["body"]["events"][0]["event_id"] == "evt-order-1"
177
+ assert requests[1]["path"] == "/events/business/business-1/latest"
178
+ assert requests[1]["query"]["event_name"] == ["purchase"]
179
+ assert requests[1]["query"]["correlation_id"] == ["order-1"]
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import contackd_sync
4
+
5
+
6
+ class RecordingHttpClient:
7
+ def __init__(self, responses: list[object]) -> None:
8
+ self.responses = list(responses)
9
+ self.requests: list[tuple[str, str, object]] = []
10
+
11
+ def get(self, path: str, **params: object) -> object:
12
+ self.requests.append(("GET", path, params))
13
+ return self.responses.pop(0)
14
+
15
+ def post(self, path: str, body: dict[str, object]) -> object:
16
+ self.requests.append(("POST", path, body))
17
+ return self.responses.pop(0)
18
+
19
+ def patch(self, path: str, body: dict[str, object]) -> object:
20
+ self.requests.append(("PATCH", path, body))
21
+ return self.responses.pop(0)
22
+
23
+
24
+ def test_deal_models_serialize_requests_and_parse_responses() -> None:
25
+ assert hasattr(contackd_sync, "Deal")
26
+ assert hasattr(contackd_sync, "DealStage")
27
+ assert hasattr(contackd_sync, "DealTransaction")
28
+
29
+ deal = contackd_sync.Deal(
30
+ contact_id="contact-1",
31
+ title="Annual plan",
32
+ amount="150000.00",
33
+ currency="NGN",
34
+ metadata={"source": "website"},
35
+ )
36
+
37
+ assert deal.to_dict() == {
38
+ "contact_id": "contact-1",
39
+ "title": "Annual plan",
40
+ "amount": "150000.00",
41
+ "currency": "NGN",
42
+ "metadata": {"source": "website"},
43
+ }
44
+
45
+ parsed = contackd_sync.Deal.from_dict(
46
+ {
47
+ **deal.to_dict(),
48
+ "id": "deal-1",
49
+ "tenant_id": "tenant-1",
50
+ "stage": "won",
51
+ "transaction": {
52
+ "id": "transaction-1",
53
+ "deal_id": "deal-1",
54
+ "tenant_id": "tenant-1",
55
+ "status": "completed",
56
+ "amount": "150000.00",
57
+ "currency": "NGN",
58
+ "metadata": {},
59
+ },
60
+ }
61
+ )
62
+
63
+ assert parsed.stage is contackd_sync.DealStage.WON
64
+ assert isinstance(parsed.transaction, contackd_sync.DealTransaction)
65
+ assert parsed.transaction.id == "transaction-1"
66
+
67
+
68
+ def test_deals_resource_creates_gets_and_lists_typed_deals() -> None:
69
+ assert hasattr(contackd_sync, "DealsResource")
70
+ response = {
71
+ "id": "deal-1",
72
+ "tenant_id": "tenant-1",
73
+ "contact_id": "contact-1",
74
+ "title": "Annual plan",
75
+ "stage": "new",
76
+ "metadata": {},
77
+ }
78
+ http = RecordingHttpClient([response, response, [response]])
79
+ resource = contackd_sync.DealsResource(http)
80
+
81
+ created = resource.create(
82
+ contackd_sync.Deal(contact_id="contact-1", title="Annual plan")
83
+ )
84
+ fetched = resource.get("deal-1")
85
+ listed = resource.list(limit=25, offset=10, stage="new")
86
+
87
+ assert created.id == "deal-1"
88
+ assert fetched.id == "deal-1"
89
+ assert [deal.id for deal in listed] == ["deal-1"]
90
+ assert http.requests == [
91
+ (
92
+ "POST",
93
+ "deals",
94
+ {"contact_id": "contact-1", "title": "Annual plan"},
95
+ ),
96
+ ("GET", "deals/deal-1", {}),
97
+ ("GET", "deals", {"limit": 25, "offset": 10, "stage": "new"}),
98
+ ]
99
+
100
+
101
+ def test_deals_resource_updates_details_and_stage_with_patch() -> None:
102
+ response = {
103
+ "id": "deal-1",
104
+ "tenant_id": "tenant-1",
105
+ "contact_id": "contact-1",
106
+ "title": "Renewed plan",
107
+ "stage": "won",
108
+ "metadata": {},
109
+ }
110
+ http = RecordingHttpClient([response, response])
111
+ resource = contackd_sync.DealsResource(http)
112
+
113
+ updated = resource.update(
114
+ "deal-1",
115
+ title="Renewed plan",
116
+ assigned_to_user_id=None,
117
+ )
118
+ won = resource.update_stage(
119
+ "deal-1",
120
+ contackd_sync.DealStage.WON,
121
+ transaction=contackd_sync.DealTransaction(
122
+ external_transaction_id="payment-1",
123
+ amount="150000.00",
124
+ currency="NGN",
125
+ ),
126
+ )
127
+
128
+ assert updated.title == "Renewed plan"
129
+ assert won.stage is contackd_sync.DealStage.WON
130
+ assert http.requests == [
131
+ (
132
+ "PATCH",
133
+ "deals/deal-1",
134
+ {"title": "Renewed plan", "assigned_to_user_id": None},
135
+ ),
136
+ (
137
+ "PATCH",
138
+ "deals/deal-1/stage",
139
+ {
140
+ "stage": "won",
141
+ "transaction": {
142
+ "external_transaction_id": "payment-1",
143
+ "status": "completed",
144
+ "amount": "150000.00",
145
+ "currency": "NGN",
146
+ },
147
+ },
148
+ ),
149
+ ]
150
+
151
+
152
+ def test_client_exposes_deals_resource() -> None:
153
+ client = contackd_sync.ContackdClient(
154
+ api_key="test-key",
155
+ base_url="https://api.example.com/api/v1",
156
+ )
157
+
158
+ assert isinstance(client.deals, contackd_sync.DealsResource)
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ import contackd_sync
4
+
5
+
6
+ def test_public_record_type_imports_on_python39() -> None:
7
+ assert contackd_sync.RecordType is not None
@@ -1,21 +0,0 @@
1
- [build-system]
2
- requires = ["setuptools>=68", "wheel"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "contackd-sync"
7
- version = "0.3.0"
8
- description = "Python SDK for Contackd Sync"
9
- readme = "README.md"
10
- requires-python = ">=3.10"
11
- dependencies = [
12
- "requests>=2.31.0",
13
- "urllib3>=2.0.0",
14
- ]
15
-
16
- [project.urls]
17
- Homepage = "https://example.com/contackd-sync"
18
-
19
- [tool.setuptools.packages.find]
20
- where = ["."]
21
- include = ["contackd_sync*"]
@@ -1,67 +0,0 @@
1
- from importlib.metadata import version
2
-
3
- from contackd_sync import ContackdClient, Lead, LeadStatus, SyncEngine
4
-
5
-
6
- def test_cold_lead_without_email_parses_conversation_fields() -> None:
7
- lead = Lead.from_dict(
8
- {
9
- "id": "lead-1",
10
- "status": "cold",
11
- "title": "WhatsApp inquiry",
12
- "display_name": "Ada",
13
- "conversation_id": "conversation-1",
14
- "external_thread_id": "+2348012345678",
15
- "phone": "+2348012345678",
16
- }
17
- )
18
-
19
- assert lead.email is None
20
- assert lead.status is LeadStatus.COLD
21
- assert lead.title == "WhatsApp inquiry"
22
- assert lead.display_name == "Ada"
23
- assert lead.conversation_id == "conversation-1"
24
- assert lead.external_thread_id == "+2348012345678"
25
- assert lead.phone == "+2348012345678"
26
-
27
-
28
- def test_sdk_version_header_matches_installed_package_version() -> None:
29
- client = ContackdClient(api_key="test-key", base_url="https://api.example.com")
30
-
31
- assert client._http.session.headers["X-SDK-Version"] == version("contackd-sync")
32
-
33
-
34
- class _InMemoryLeadResource:
35
- def __init__(self, records: list[Lead]):
36
- self.records = records
37
-
38
- def list(self, page: int = 1, per_page: int = 100, **filters: object) -> list[Lead]:
39
- del per_page, filters
40
- return self.records if page == 1 else []
41
-
42
- def bulk_upsert(self, records: list[Lead], chunk_size: int = 100) -> None:
43
- raise AssertionError(f"unexpected push of {len(records)} records in chunks of {chunk_size}")
44
-
45
-
46
- def test_sync_matches_email_less_leads_by_external_thread_id() -> None:
47
- remote_a = Lead(
48
- status=LeadStatus.COLD,
49
- display_name="Remote Ada",
50
- external_thread_id="thread-a",
51
- )
52
- remote_b = Lead(
53
- status=LeadStatus.COLD,
54
- display_name="Remote Grace",
55
- external_thread_id="thread-b",
56
- )
57
- local_a = Lead(
58
- status=LeadStatus.COLD,
59
- display_name="Local Ada",
60
- external_thread_id="thread-a",
61
- )
62
- engine = SyncEngine(_InMemoryLeadResource([remote_a, remote_b]), conflict_strategy="remote")
63
-
64
- result = engine.sync([local_a])
65
-
66
- assert result.pushed == []
67
- assert result.remote_only == [remote_b]
File without changes