booking-agent-cli 0.1.0__py3-none-any.whl

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.
Files changed (74) hide show
  1. booking_agent/__init__.py +6 -0
  2. booking_agent/authorization.py +450 -0
  3. booking_agent/backend/__init__.py +217 -0
  4. booking_agent/backend/live.py +494 -0
  5. booking_agent/calendar/__init__.py +55 -0
  6. booking_agent/calendar/filter.py +41 -0
  7. booking_agent/calendar/google.py +582 -0
  8. booking_agent/calendar/oauth_listener.py +287 -0
  9. booking_agent/calendar/scope.py +97 -0
  10. booking_agent/circuit_breaker.py +116 -0
  11. booking_agent/config.py +51 -0
  12. booking_agent/defaults.env +50 -0
  13. booking_agent/discovery.py +188 -0
  14. booking_agent/headless.py +463 -0
  15. booking_agent/http_deadlines.py +43 -0
  16. booking_agent/http_retry.py +178 -0
  17. booking_agent/http_trace.py +428 -0
  18. booking_agent/merchant_pacing.py +73 -0
  19. booking_agent/models/__init__.py +14 -0
  20. booking_agent/models/platform.py +200 -0
  21. booking_agent/models/wire.py +868 -0
  22. booking_agent/namespace.py +53 -0
  23. booking_agent/observability.py +143 -0
  24. booking_agent/platform_profile.py +120 -0
  25. booking_agent/pop.py +347 -0
  26. booking_agent/profile_cache.py +130 -0
  27. booking_agent/registry_url.py +35 -0
  28. booking_agent/rest_client.py +1493 -0
  29. booking_agent/secure_file.py +52 -0
  30. booking_agent/settings.py +110 -0
  31. booking_agent/signals.py +130 -0
  32. booking_agent/step_timing.py +168 -0
  33. booking_agent/ucp_capabilities.py +148 -0
  34. booking_agent/ucp_checkout.py +1363 -0
  35. booking_agent/ucp_negotiation.py +184 -0
  36. booking_agent/ucp_profile.py +410 -0
  37. booking_agent/usp_methods.py +110 -0
  38. booking_agent_cli/__init__.py +3 -0
  39. booking_agent_cli/activity_log.py +106 -0
  40. booking_agent_cli/booking_poll.py +141 -0
  41. booking_agent_cli/booking_summary.py +151 -0
  42. booking_agent_cli/calendar_connection.py +196 -0
  43. booking_agent_cli/calendar_event.py +611 -0
  44. booking_agent_cli/calendar_preference.py +176 -0
  45. booking_agent_cli/catalog.py +864 -0
  46. booking_agent_cli/checkout_prereq.py +67 -0
  47. booking_agent_cli/commands/__init__.py +0 -0
  48. booking_agent_cli/commands/config.py +106 -0
  49. booking_agent_cli/commands/flow.py +6988 -0
  50. booking_agent_cli/commands/lowlevel.py +163 -0
  51. booking_agent_cli/commands/runtime.py +46 -0
  52. booking_agent_cli/config_loader.py +108 -0
  53. booking_agent_cli/engine.py +168 -0
  54. booking_agent_cli/flow_timings.py +34 -0
  55. booking_agent_cli/geo_eligibility.py +183 -0
  56. booking_agent_cli/geocode.py +233 -0
  57. booking_agent_cli/identity.py +104 -0
  58. booking_agent_cli/link_cli.py +620 -0
  59. booking_agent_cli/main.py +75 -0
  60. booking_agent_cli/output.py +199 -0
  61. booking_agent_cli/payment_helpers.py +884 -0
  62. booking_agent_cli/payment_token.py +23 -0
  63. booking_agent_cli/reschedule.py +137 -0
  64. booking_agent_cli/runtime.py +55 -0
  65. booking_agent_cli/runtime_prepare.py +129 -0
  66. booking_agent_cli/search_area.py +111 -0
  67. booking_agent_cli/session.py +837 -0
  68. booking_agent_cli/support_bundle_schema.py +557 -0
  69. booking_agent_cli/support_report.py +1210 -0
  70. booking_agent_cli/timezone_context.py +314 -0
  71. booking_agent_cli-0.1.0.dist-info/METADATA +75 -0
  72. booking_agent_cli-0.1.0.dist-info/RECORD +74 -0
  73. booking_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  74. booking_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,868 @@
1
+ """Shared USP protocol domain models.
2
+
3
+ These Pydantic models mirror the USP JSON schemas in universal-scheduling-protocol
4
+ (`schemas/catalog.json`, `schemas/availability.json`, `schemas/booking.json`) and
5
+ represent the wire format for the Universal Scheduling Protocol.
6
+ """
7
+ from enum import Enum
8
+
9
+ from pydantic.alias_generators import to_camel
10
+ from typing import Optional, List, Dict, Any, Literal, Union
11
+
12
+ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Shared enums (booking + catalog payment timing)
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ class ConfirmationMode(str, Enum):
21
+ """booking.json / catalog ServicePolicies.confirmation_mode."""
22
+
23
+ AUTO = "auto"
24
+ MANUAL = "manual"
25
+
26
+
27
+ class BookingPaymentStatus(str, Enum):
28
+ NOT_REQUIRED = "not_required"
29
+ PENDING = "pending"
30
+ DEPOSIT_PAID = "deposit_paid"
31
+ PAID = "paid"
32
+ REFUNDED = "refunded"
33
+ PARTIALLY_REFUNDED = "partially_refunded"
34
+
35
+
36
+ class BookingPaymentTiming(str, Enum):
37
+ AT_BOOKING = "at_booking"
38
+ AT_SERVICE = "at_service"
39
+ DEPOSIT_REQUIRED = "deposit_required"
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Catalog models (mirrors schemas/catalog.json)
44
+ # ---------------------------------------------------------------------------
45
+
46
+ class Coordinates(BaseModel):
47
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
48
+
49
+ lat: float
50
+ lng: float
51
+
52
+
53
+ class Location(BaseModel):
54
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
55
+
56
+ id: Optional[str] = None
57
+ name: Optional[str] = None
58
+ address: Optional[str] = None
59
+ coordinates: Optional[Coordinates] = None
60
+
61
+
62
+ class DurationRange(BaseModel):
63
+ """catalog.json Duration.range — min/max/step ISO 8601 durations."""
64
+
65
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
66
+
67
+ min: Optional[str] = None
68
+ max: Optional[str] = None
69
+ step: Optional[str] = None
70
+
71
+
72
+ class Duration(BaseModel):
73
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
74
+
75
+ fixed: Optional[str] = None
76
+ range: Optional[DurationRange] = None
77
+ undetermined: Optional[bool] = None
78
+ buffer_before: Optional[str] = None
79
+ buffer_after: Optional[str] = None
80
+
81
+
82
+ class PricingModel(str, Enum):
83
+ FIXED = "fixed"
84
+ HOURLY = "hourly"
85
+ PER_PERSON = "per_person"
86
+ VARIABLE = "variable"
87
+ FREE = "free"
88
+
89
+
90
+ class PricingPriceRange(BaseModel):
91
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
92
+
93
+ min: int
94
+ max: int
95
+
96
+
97
+ class PricingDeposit(BaseModel):
98
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
99
+
100
+ type: str
101
+ value: Union[int, float]
102
+ refundable: Optional[bool] = None
103
+
104
+
105
+ class Pricing(BaseModel):
106
+ """Pricing per catalog.json $defs/Pricing.
107
+
108
+ Spec invariants:
109
+ - ``amount`` is REQUIRED when ``model`` is ``fixed``, ``hourly`` or ``per_person``,
110
+ and MUST NOT be present when ``model`` is ``free``.
111
+ - ``price_range`` MUST NOT be present when ``model`` is ``free``.
112
+ - ``deposit`` is REQUIRED when payment_timing is ``deposit_required``.
113
+ """
114
+
115
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
116
+
117
+ model: Union[PricingModel, str]
118
+ amount: Optional[int] = None
119
+ currency: str
120
+ price_range: Optional[PricingPriceRange] = None
121
+ deposit: Optional[PricingDeposit] = None
122
+
123
+
124
+ class ChannelType(str, Enum):
125
+ """Catalog channel.type (schemas/catalog.json).
126
+
127
+ ``in_person`` was renamed to ``at_business_location`` in USP #40; treat the
128
+ old value as unrecognized, not as a synonym.
129
+ """
130
+
131
+ AT_BUSINESS_LOCATION = "at_business_location"
132
+ AT_BUYER_LOCATION = "at_buyer_location"
133
+ VIRTUAL = "virtual"
134
+ PHONE = "phone"
135
+ HYBRID = "hybrid"
136
+
137
+
138
+ class ResourceKind(str, Enum):
139
+ STAFF = "staff"
140
+ ROOM = "room"
141
+ EQUIPMENT = "equipment"
142
+ OTHER = "other"
143
+
144
+
145
+ class ResourceOption(BaseModel):
146
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
147
+
148
+ id: str
149
+ name: Optional[str] = None
150
+ description: Optional[str] = None
151
+ image_url: Optional[str] = None
152
+
153
+
154
+ class ResourceRequirement(BaseModel):
155
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
156
+
157
+ type: Union[ResourceKind, str]
158
+ name: Optional[str] = None
159
+ selectable: bool = False
160
+ options: Optional[List[ResourceOption]] = None
161
+
162
+
163
+ class Channel(BaseModel):
164
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
165
+
166
+ type: Union[ChannelType, str]
167
+ virtual_provider: Optional[str] = None
168
+ instructions: Optional[str] = None
169
+ service_area: Optional[str] = None
170
+
171
+
172
+ class CancellationPolicy(BaseModel):
173
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
174
+
175
+ allowed: bool
176
+ free_cancellation_until: Optional[str] = None
177
+ late_cancellation_fee: Optional[int] = None
178
+ no_cancellation_after: Optional[str] = None
179
+
180
+
181
+ class ReschedulingPolicy(BaseModel):
182
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
183
+
184
+ allowed: bool
185
+ free_reschedule_until: Optional[str] = None
186
+ max_reschedules: Optional[int] = None
187
+ fee: Optional[int] = None
188
+
189
+
190
+ class NoShowPolicy(BaseModel):
191
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
192
+
193
+ fee: Optional[int] = None
194
+ fee_percentage: Optional[int] = None
195
+ grace_period: Optional[str] = None
196
+
197
+
198
+ class BookingWindow(BaseModel):
199
+ """schemas/catalog.json#/$defs/ServicePolicies.booking_window.
200
+
201
+ Spec REQUIRES all three of (min_advance, max_advance, slot_interval). The
202
+ client accepts a missing ``slot_interval``, a deliberate permissive
203
+ divergence from the spec.
204
+ """
205
+
206
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
207
+
208
+ min_advance: str
209
+ max_advance: str
210
+ slot_interval: Optional[str] = None
211
+
212
+
213
+ class ServicePolicies(BaseModel):
214
+ """schemas/catalog.json#/$defs/ServicePolicies.
215
+
216
+ Spec REQUIRES (cancellation, rescheduling, booking_window, confirmation_mode,
217
+ requires_payment). The client accepts a missing ``booking_window``, a
218
+ deliberate permissive divergence (see BookingWindow docstring).
219
+ """
220
+
221
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
222
+
223
+ cancellation: CancellationPolicy
224
+ rescheduling: ReschedulingPolicy
225
+ no_show: Optional[NoShowPolicy] = None
226
+ booking_window: Optional[BookingWindow] = None
227
+ confirmation_mode: Union[ConfirmationMode, str]
228
+ requires_payment: bool
229
+ payment_timing: Optional[Union[BookingPaymentTiming, str]] = None
230
+
231
+
232
+ class ServiceImage(BaseModel):
233
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
234
+
235
+ url: str
236
+ alt: Optional[str] = None
237
+ type: Optional[str] = None
238
+
239
+
240
+ class AvailabilitySlotBitmap(BaseModel):
241
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
242
+
243
+ duration: str
244
+ starts_at: str
245
+ start_interval: str
246
+ slot_count: int = Field(ge=1, le=4294967296)
247
+ encoding: Literal["roaring32-portable-base64"]
248
+ bitmap: str
249
+
250
+
251
+ class AvailabilityHint(BaseModel):
252
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
253
+
254
+ summary: str
255
+ generated_at: str
256
+ valid_until: Optional[str] = None
257
+ next_available_date: Optional[str] = None
258
+ slot_bitmaps: Optional[List[AvailabilitySlotBitmap]] = Field(
259
+ default=None, min_length=1
260
+ )
261
+
262
+
263
+ class ServiceCapacity(BaseModel):
264
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
265
+
266
+ min: Optional[int] = None
267
+ max: Optional[int] = None
268
+ waitlist: Optional[bool] = None
269
+
270
+
271
+ class ServiceCategory(BaseModel):
272
+ """A category label from a taxonomy system (catalog.json ServiceCategory).
273
+
274
+ ``taxonomy`` defaults to ``merchant`` for the common single-category case.
275
+ Each entry MUST carry at least one of ``id``, ``name``, or ``value``.
276
+ External (non-``merchant``) taxonomies MUST carry ``value``. Exactly one
277
+ entry in ``categories`` is primary (see :func:`primary_service_category`).
278
+ """
279
+
280
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
281
+
282
+ taxonomy: str = "merchant"
283
+ id: Optional[str] = None
284
+ name: Optional[str] = None
285
+ parent_id: Optional[str] = None
286
+ value: Optional[str] = None
287
+ primary: Optional[bool] = None
288
+
289
+ @model_validator(mode="after")
290
+ def _require_identity_fields(self) -> "ServiceCategory":
291
+ if not (self.id or self.name or self.value):
292
+ raise ValueError(
293
+ "ServiceCategory MUST carry at least one of id, name, or value"
294
+ )
295
+ if self.taxonomy != "merchant" and not self.value:
296
+ raise ValueError(
297
+ "External (non-merchant) ServiceCategory taxonomies MUST carry value"
298
+ )
299
+ return self
300
+
301
+
302
+ # Backward-compatible alias for the pre-#54 thin {value, taxonomy} entry type.
303
+ ServiceCategoryEntry = ServiceCategory
304
+
305
+
306
+ def primary_service_category(
307
+ categories: Optional[List["ServiceCategory"]],
308
+ ) -> Optional["ServiceCategory"]:
309
+ """Return the canonical primary entry from ``categories[]`` (USP category rules).
310
+
311
+ 1. Exactly one ``primary: true`` wins.
312
+ 2. Else if no entry sets ``primary`` and exactly one has ``taxonomy: merchant``,
313
+ that merchant entry is primary.
314
+ 3. Else the first entry in array order is primary.
315
+ """
316
+ if not categories:
317
+ return None
318
+ flagged = [c for c in categories if c.primary is True]
319
+ if len(flagged) == 1:
320
+ return flagged[0]
321
+ unset = all(c.primary is not True for c in categories)
322
+ if unset:
323
+ merchants = [c for c in categories if c.taxonomy == "merchant"]
324
+ if len(merchants) == 1:
325
+ return merchants[0]
326
+ return categories[0]
327
+
328
+
329
+ def project_category_string(
330
+ *,
331
+ categories: Optional[List["ServiceCategory"]] = None,
332
+ service_type: Optional[str] = None,
333
+ ) -> str:
334
+ """Project ``categories[]`` to the flat registry ``ServiceSearchResult.category`` string.
335
+
336
+ Pick order: primary ``name``, else primary ``value``, else primary ``id``,
337
+ else first entry ``value``, else ``service_type``, else ``\"\"``.
338
+ """
339
+ primary = primary_service_category(categories)
340
+ if primary is not None:
341
+ if primary.name:
342
+ return primary.name
343
+ if primary.value:
344
+ return primary.value
345
+ if primary.id:
346
+ return primary.id
347
+ if categories:
348
+ first = categories[0]
349
+ if first.value:
350
+ return first.value
351
+ return service_type or ""
352
+
353
+
354
+ class RegistryBusinessInfo(BaseModel):
355
+ """Business info embedded in registry search results.
356
+
357
+ Aligns with schemas/registry.json#/$defs/RegistryBusinessRef which requires
358
+ (id, profile_url, deployment_mode, name). The client also accepts the legacy
359
+ field name ``usp_profile_url`` (via AliasChoices) and a missing
360
+ ``deployment_mode``, defaulting it to the spec-recommended ``"standalone"``.
361
+ """
362
+ model_config = ConfigDict(
363
+ alias_generator=to_camel, populate_by_name=True, extra="ignore"
364
+ )
365
+
366
+ id: str
367
+ name: str
368
+ profile_url: str = Field(
369
+ validation_alias=AliasChoices("profile_url", "profileUrl", "usp_profile_url", "uspProfileUrl"),
370
+ serialization_alias="profileUrl",
371
+ )
372
+ deployment_mode: Optional[str] = "standalone"
373
+
374
+ @property
375
+ def usp_profile_url(self) -> str:
376
+ """Backwards-compatible accessor for the pre-rename field name."""
377
+ return self.profile_url
378
+
379
+
380
+ class Link(BaseModel):
381
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
382
+
383
+ type: str
384
+ url: str
385
+ title: Optional[str] = None
386
+
387
+
388
+ class Provider(BaseModel):
389
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
390
+
391
+ name: str
392
+ url: Optional[str] = None
393
+ links: Optional[List[Link]] = None
394
+
395
+
396
+ class Media(BaseModel):
397
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
398
+
399
+ type: str
400
+ url: str
401
+ alt_text: Optional[str] = None
402
+ role: Optional[str] = None
403
+ width: Optional[int] = None
404
+ height: Optional[int] = None
405
+
406
+
407
+ class Rating(BaseModel):
408
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
409
+
410
+ value: float
411
+ scale_max: float
412
+ scale_min: float = 1
413
+ count: Optional[int] = None
414
+
415
+
416
+ class DescriptionContent(BaseModel):
417
+ """Structured service description (catalog.json Description)."""
418
+
419
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
420
+
421
+ plain: str
422
+ markdown: Optional[str] = None
423
+ html: Optional[str] = None
424
+
425
+
426
+ class ServiceLifecycleStatus(str, Enum):
427
+ ACTIVE = "active"
428
+ SUSPENDED = "suspended"
429
+ ARCHIVED = "archived"
430
+
431
+
432
+ class LocalizedFields(BaseModel):
433
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
434
+
435
+ name: Optional[Dict[str, str]] = None
436
+ description: Optional[Dict[str, str]] = None
437
+ category_name: Optional[Dict[str, str]] = None
438
+ channel_instructions: Optional[Dict[str, str]] = None
439
+
440
+
441
+ def _normalize_category_entry(raw: Any) -> Any:
442
+ """Normalize a category value for ServiceCategory (default taxonomy=merchant)."""
443
+ if isinstance(raw, ServiceCategory):
444
+ return raw
445
+ if not isinstance(raw, dict):
446
+ return raw
447
+ entry = dict(raw)
448
+ if not entry.get("taxonomy"):
449
+ entry["taxonomy"] = "merchant"
450
+ return entry
451
+
452
+
453
+ class USPService(BaseModel):
454
+ """A bookable offering from a business (mirrors catalog.json Service).
455
+
456
+ Post issue #54 the singular ``category`` field is removed from the wire
457
+ schema; enriched ``categories[]`` is canonical. Legacy responses that still
458
+ send ``category`` are coerced into a one-element ``categories`` array.
459
+ """
460
+
461
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
462
+
463
+ id: str
464
+ business_id: str
465
+ provider: Optional[Provider] = None
466
+ name: str
467
+ description: Optional[Union[str, DescriptionContent]] = None
468
+ type: str
469
+ categories: Optional[List[ServiceCategory]] = None
470
+ duration: Duration
471
+ pricing: Pricing
472
+ locations: Optional[List[Location]] = None
473
+ resources: Optional[List[ResourceRequirement]] = None
474
+ channel: Channel
475
+ policies: ServicePolicies
476
+ capacity: Optional[ServiceCapacity] = None
477
+ media: Optional[List[Media]] = None
478
+ images: Optional[List[ServiceImage]] = None
479
+ rating: Optional[Rating] = None
480
+ status: Optional[Union[ServiceLifecycleStatus, str]] = None
481
+ handle: Optional[str] = None
482
+ url: Optional[str] = None
483
+ tags: Optional[List[str]] = None
484
+ metadata: Optional[Dict[str, Any]] = None
485
+ availability_hint: Optional[AvailabilityHint] = None
486
+ links: Optional[List[Link]] = None
487
+ localized: Optional[LocalizedFields] = None
488
+ business: Optional[RegistryBusinessInfo] = None
489
+
490
+ @model_validator(mode="before")
491
+ @classmethod
492
+ def _coerce_legacy_category(cls, data: Any) -> Any:
493
+ if not isinstance(data, dict):
494
+ return data
495
+ payload = dict(data)
496
+ categories = payload.get("categories")
497
+ legacy = payload.pop("category", None)
498
+ if categories is None and legacy is not None:
499
+ if isinstance(legacy, list):
500
+ payload["categories"] = [
501
+ _normalize_category_entry(item) for item in legacy
502
+ ]
503
+ else:
504
+ payload["categories"] = [_normalize_category_entry(legacy)]
505
+ elif isinstance(categories, list):
506
+ payload["categories"] = [
507
+ _normalize_category_entry(item) for item in categories
508
+ ]
509
+ return payload
510
+
511
+ @property
512
+ def category(self) -> Optional[ServiceCategory]:
513
+ """Primary category entry (compat for pre-#54 callers)."""
514
+ return primary_service_category(self.categories)
515
+
516
+
517
+ # ---------------------------------------------------------------------------
518
+ # Availability models (mirrors schemas/availability.json)
519
+ # ---------------------------------------------------------------------------
520
+
521
+ class SlotCapacity(BaseModel):
522
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
523
+
524
+ total: Optional[int] = None
525
+ remaining: Optional[int] = None
526
+ held: Optional[int] = None
527
+
528
+
529
+ class SlotResource(BaseModel):
530
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
531
+
532
+ id: str
533
+ type: Optional[str] = None
534
+ name: Optional[str] = None
535
+
536
+
537
+ class SlotLocation(BaseModel):
538
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
539
+
540
+ id: Optional[str] = None
541
+ name: Optional[str] = None
542
+
543
+
544
+ class SlotPricing(BaseModel):
545
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
546
+
547
+ amount: Optional[int] = None
548
+ currency: Optional[str] = None
549
+ label: Optional[str] = None
550
+
551
+
552
+ class SlotState(str, Enum):
553
+ AVAILABLE = "available"
554
+ LIMITED = "limited"
555
+ WAITLIST = "waitlist"
556
+
557
+
558
+ class TimeSlot(BaseModel):
559
+ """A specific, bookable time window for a service (mirrors availability.json TimeSlot)."""
560
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
561
+
562
+ id: str
563
+ service_id: str
564
+ start: str
565
+ end: str
566
+ duration: str
567
+ state: Union[SlotState, str]
568
+ capacity: Optional[SlotCapacity] = None
569
+ resources: Optional[List[SlotResource]] = None
570
+ location: Optional[SlotLocation] = None
571
+ pricing: Optional[SlotPricing] = None
572
+
573
+
574
+ class AvailabilityResult(List[TimeSlot]):
575
+ """Slots from ``POST /availability/query`` plus the response's zone fields (USP 4.3.1).
576
+
577
+ A plain list of slots, so callers that only iterate keep working.
578
+ ``business_timezone`` names the business's clock. ``applied_date_bounds_timezone``
579
+ only says how date-only bounds were read and must not name that clock.
580
+ """
581
+
582
+ def __init__(
583
+ self,
584
+ slots: Any = (),
585
+ *,
586
+ business_timezone: Optional[str] = None,
587
+ applied_date_bounds_timezone: Optional[str] = None,
588
+ ) -> None:
589
+ super().__init__(slots)
590
+ self.business_timezone = business_timezone
591
+ self.applied_date_bounds_timezone = applied_date_bounds_timezone
592
+
593
+
594
+ class HoldStatus(str, Enum):
595
+ ACTIVE = "active"
596
+ EXPIRED = "expired"
597
+ RELEASED = "released"
598
+ CONVERTED = "converted"
599
+
600
+
601
+ class Hold(BaseModel):
602
+ """A temporary reservation of a time slot (mirrors availability.json Hold)."""
603
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
604
+
605
+ id: str
606
+ slot_id: str
607
+ service_id: str
608
+ spots: int = 1
609
+ expires_at: str
610
+ status: HoldStatus
611
+
612
+
613
+ # ---------------------------------------------------------------------------
614
+ # Booking models (mirrors schemas/booking.json)
615
+ # ---------------------------------------------------------------------------
616
+
617
+ class SlotReference(BaseModel):
618
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
619
+
620
+ id: str
621
+ start: str
622
+ end: str
623
+ duration: str
624
+
625
+
626
+ class BuyerConsent(BaseModel):
627
+ """Declarative consent signals (booking.json $defs/BuyerConsent).
628
+
629
+ Nested inside the Buyer object per UCP convention. Boolean values are the
630
+ common consent categories; vendor-namespaced keys (e.g. ``x-acme-research``)
631
+ are accepted via Pydantic's extra-field handling on the parent Buyer.
632
+ """
633
+
634
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra="allow")
635
+
636
+ analytics: Optional[bool] = None
637
+ marketing: Optional[bool] = None
638
+ preferences: Optional[bool] = None
639
+ sale_of_data: Optional[bool] = None
640
+ health_data: Optional[bool] = None
641
+
642
+
643
+ class Buyer(BaseModel):
644
+ """Per booking.json $defs/Buyer."""
645
+
646
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
647
+
648
+ first_name: str
649
+ last_name: str
650
+ email: str
651
+ phone_number: Optional[str] = None
652
+ consent: Optional[BuyerConsent] = None
653
+
654
+
655
+ class BookingPayment(BaseModel):
656
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
657
+
658
+ status: BookingPaymentStatus
659
+ timing: BookingPaymentTiming
660
+ amount: Optional[int] = None
661
+ currency: Optional[str] = None
662
+ amount_due: Optional[int] = None
663
+ deposit_amount: Optional[int] = None
664
+ tax_amount: Optional[int] = None
665
+ transaction_id: Optional[str] = None
666
+ order_reference: Optional[str] = None
667
+
668
+
669
+ class LineItem(BaseModel):
670
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
671
+
672
+ label: str
673
+ amount: int
674
+ quantity: int
675
+ item_id: str
676
+
677
+
678
+ class PaymentContextMetadata(BaseModel):
679
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
680
+
681
+ booking_id: str
682
+ service_id: str
683
+ service_type: str
684
+ slot_start: Optional[str] = None
685
+
686
+
687
+ class USPPaymentContext(BaseModel):
688
+ """Universal payment handoff object from the business to the platform."""
689
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
690
+
691
+ amount_due: int
692
+ currency: str
693
+ description: str
694
+ line_items: List[LineItem]
695
+ tax_amount: Optional[int] = None
696
+ metadata: PaymentContextMetadata
697
+
698
+
699
+ class PostPaymentReturnRequest(BaseModel):
700
+ """Return redirect specification sent in POST /bookings for the redirect checkout path.
701
+
702
+ The business MUST redirect the buyer to `url` with all `params` entries appended
703
+ verbatim as query parameters — both when payment completes successfully AND when
704
+ the buyer cancels or abandons payment. The platform uses `params` as opaque
705
+ correlation state (analogous to OAuth 2.0 `redirect_uri` + `state`) to identify
706
+ the pending session without knowing the booking_id in advance.
707
+ """
708
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
709
+
710
+ url: str
711
+ params: Optional[Dict[str, str]] = None
712
+
713
+
714
+ class USPPaymentResult(BaseModel):
715
+ """Payment result sent from the platform back to the business."""
716
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
717
+
718
+ status: str
719
+ provider: Optional[str] = None
720
+ transaction_id: str
721
+ amount_paid: int
722
+ currency: str
723
+ order_reference: Optional[str] = None
724
+
725
+
726
+ class MessageType(str, Enum):
727
+ ERROR = "error"
728
+ WARNING = "warning"
729
+ INFO = "info"
730
+
731
+
732
+ class MessageSeverity(str, Enum):
733
+ REQUIRES_BUYER_INPUT = "requires_buyer_input"
734
+ RECOVERABLE = "recoverable"
735
+ REQUIRES_BUYER_REVIEW = "requires_buyer_review"
736
+
737
+
738
+ class USPMessage(BaseModel):
739
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
740
+
741
+ type: MessageType
742
+ code: str
743
+ content: str
744
+ severity: Optional[MessageSeverity] = None
745
+ path: Optional[str] = None
746
+
747
+
748
+ class ActionStatus(str, Enum):
749
+ PENDING = "pending"
750
+ COMPLETED = "completed"
751
+ EXPIRED = "expired"
752
+
753
+
754
+ class Action(BaseModel):
755
+ """A pending task the buyer must complete before a booking can be confirmed."""
756
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
757
+
758
+ type: str
759
+ status: ActionStatus
760
+ continue_url: str
761
+ expires_at: str
762
+ message: Optional[USPMessage] = None
763
+ payment_context: Optional[USPPaymentContext] = None
764
+
765
+
766
+ class Cancellation(BaseModel):
767
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
768
+
769
+ reason: Optional[str] = None
770
+ canceled_by: Optional[str] = None
771
+ fee: Optional[int] = None
772
+ refund_amount: Optional[int] = None
773
+ canceled_at: Optional[str] = None
774
+
775
+
776
+ class BookingStatus(str, Enum):
777
+ PENDING = "pending"
778
+ REQUIRES_ACTION = "requires_action"
779
+ CONFIRMED = "confirmed"
780
+ IN_PROGRESS = "in_progress"
781
+ COMPLETED = "completed"
782
+ NO_SHOW = "no_show"
783
+ CANCELED = "canceled"
784
+
785
+
786
+ class BookingResource(BaseModel):
787
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
788
+
789
+ id: str
790
+ type: Optional[str] = None
791
+ name: Optional[str] = None
792
+
793
+
794
+ class BookingLocation(BaseModel):
795
+ """Business location reference for at_business_location / hybrid bookings."""
796
+
797
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
798
+
799
+ id: Optional[str] = None
800
+ name: Optional[str] = None
801
+
802
+
803
+ class DeliveryAddress(BaseModel):
804
+ """Buyer's service delivery address (booking.json $defs/DeliveryAddress).
805
+
806
+ REQUIRED on create-booking when the service's channel.type is
807
+ ``at_buyer_location``; echoed back on the Booking object.
808
+ """
809
+
810
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
811
+
812
+ line1: str
813
+ city: str
814
+ country: str
815
+ line2: Optional[str] = None
816
+ region: Optional[str] = None
817
+ postal_code: Optional[str] = None
818
+ coordinates: Optional[Coordinates] = None
819
+
820
+
821
+ class DisputeStatus(str, Enum):
822
+ OPENED = "opened"
823
+ UNDER_REVIEW = "under_review"
824
+ RESOLVED_BUYER = "resolved_buyer"
825
+ RESOLVED_BUSINESS = "resolved_business"
826
+
827
+
828
+ class Dispute(BaseModel):
829
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
830
+
831
+ status: DisputeStatus
832
+ reason: str
833
+ opened_at: str
834
+ resolved_at: Optional[str] = None
835
+
836
+
837
+ class Booking(BaseModel):
838
+ """A scheduled service instance (mirrors booking.json Booking)."""
839
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
840
+
841
+ id: str
842
+ service_id: str
843
+ service_name: str
844
+ slot: SlotReference
845
+ buyer: Buyer
846
+ recipient: Optional[Buyer] = None
847
+ party_size: int
848
+ resources: Optional[List[BookingResource]] = None
849
+ location: Optional[BookingLocation] = None
850
+ delivery_address: Optional[DeliveryAddress] = None
851
+ status: BookingStatus
852
+ confirmation_mode: Union[ConfirmationMode, str]
853
+ payment: Optional[BookingPayment] = None
854
+ actions: Optional[List[Action]] = None
855
+ notes: Optional[str] = None
856
+ booking_url: Optional[str] = None
857
+ dispute: Optional[Dispute] = None
858
+ messages: Optional[List[USPMessage]] = None
859
+ cancellation: Optional[Cancellation] = None
860
+ created_at: str
861
+ updated_at: str
862
+ expires_at: Optional[str] = None
863
+ #: Opaque concurrency token (specification.md Section 5.6). Its *presence* is the
864
+ #: advertisement that the business supports conditional writes: echo it back
865
+ #: as ``If-Match`` on mutating calls and the business rejects a stale one
866
+ #: with 412 rather than clobbering a concurrent change. Absent means
867
+ #: unconditional writes, exactly like a missing HTTP ETag.
868
+ revision: Optional[str] = None