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.
- booking_agent/__init__.py +6 -0
- booking_agent/authorization.py +450 -0
- booking_agent/backend/__init__.py +217 -0
- booking_agent/backend/live.py +494 -0
- booking_agent/calendar/__init__.py +55 -0
- booking_agent/calendar/filter.py +41 -0
- booking_agent/calendar/google.py +582 -0
- booking_agent/calendar/oauth_listener.py +287 -0
- booking_agent/calendar/scope.py +97 -0
- booking_agent/circuit_breaker.py +116 -0
- booking_agent/config.py +51 -0
- booking_agent/defaults.env +50 -0
- booking_agent/discovery.py +188 -0
- booking_agent/headless.py +463 -0
- booking_agent/http_deadlines.py +43 -0
- booking_agent/http_retry.py +178 -0
- booking_agent/http_trace.py +428 -0
- booking_agent/merchant_pacing.py +73 -0
- booking_agent/models/__init__.py +14 -0
- booking_agent/models/platform.py +200 -0
- booking_agent/models/wire.py +868 -0
- booking_agent/namespace.py +53 -0
- booking_agent/observability.py +143 -0
- booking_agent/platform_profile.py +120 -0
- booking_agent/pop.py +347 -0
- booking_agent/profile_cache.py +130 -0
- booking_agent/registry_url.py +35 -0
- booking_agent/rest_client.py +1493 -0
- booking_agent/secure_file.py +52 -0
- booking_agent/settings.py +110 -0
- booking_agent/signals.py +130 -0
- booking_agent/step_timing.py +168 -0
- booking_agent/ucp_capabilities.py +148 -0
- booking_agent/ucp_checkout.py +1363 -0
- booking_agent/ucp_negotiation.py +184 -0
- booking_agent/ucp_profile.py +410 -0
- booking_agent/usp_methods.py +110 -0
- booking_agent_cli/__init__.py +3 -0
- booking_agent_cli/activity_log.py +106 -0
- booking_agent_cli/booking_poll.py +141 -0
- booking_agent_cli/booking_summary.py +151 -0
- booking_agent_cli/calendar_connection.py +196 -0
- booking_agent_cli/calendar_event.py +611 -0
- booking_agent_cli/calendar_preference.py +176 -0
- booking_agent_cli/catalog.py +864 -0
- booking_agent_cli/checkout_prereq.py +67 -0
- booking_agent_cli/commands/__init__.py +0 -0
- booking_agent_cli/commands/config.py +106 -0
- booking_agent_cli/commands/flow.py +6988 -0
- booking_agent_cli/commands/lowlevel.py +163 -0
- booking_agent_cli/commands/runtime.py +46 -0
- booking_agent_cli/config_loader.py +108 -0
- booking_agent_cli/engine.py +168 -0
- booking_agent_cli/flow_timings.py +34 -0
- booking_agent_cli/geo_eligibility.py +183 -0
- booking_agent_cli/geocode.py +233 -0
- booking_agent_cli/identity.py +104 -0
- booking_agent_cli/link_cli.py +620 -0
- booking_agent_cli/main.py +75 -0
- booking_agent_cli/output.py +199 -0
- booking_agent_cli/payment_helpers.py +884 -0
- booking_agent_cli/payment_token.py +23 -0
- booking_agent_cli/reschedule.py +137 -0
- booking_agent_cli/runtime.py +55 -0
- booking_agent_cli/runtime_prepare.py +129 -0
- booking_agent_cli/search_area.py +111 -0
- booking_agent_cli/session.py +837 -0
- booking_agent_cli/support_bundle_schema.py +557 -0
- booking_agent_cli/support_report.py +1210 -0
- booking_agent_cli/timezone_context.py +314 -0
- booking_agent_cli-0.1.0.dist-info/METADATA +75 -0
- booking_agent_cli-0.1.0.dist-info/RECORD +74 -0
- booking_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- 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
|