woltapi 0.0.1__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.
- woltapi/__init__.py +90 -0
- woltapi/client.py +565 -0
- woltapi/credentials.py +67 -0
- woltapi/errors.py +101 -0
- woltapi/models.py +47 -0
- woltapi/purchase.py +1103 -0
- woltapi/selection.py +863 -0
- woltapi/services.py +24 -0
- woltapi/transport.py +198 -0
- woltapi-0.0.1.dist-info/METADATA +215 -0
- woltapi-0.0.1.dist-info/RECORD +14 -0
- woltapi-0.0.1.dist-info/WHEEL +5 -0
- woltapi-0.0.1.dist-info/licenses/LICENSE +661 -0
- woltapi-0.0.1.dist-info/top_level.txt +1 -0
woltapi/__init__.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Synchronous selection and explicit purchase client for observed Wolt hosts."""
|
|
2
|
+
|
|
3
|
+
from .client import WoltClient
|
|
4
|
+
from .credentials import SessionCredentials
|
|
5
|
+
from .errors import (
|
|
6
|
+
DuplicatePurchaseAttempt,
|
|
7
|
+
HTTPStatusError,
|
|
8
|
+
OrderOutcomeUnknown,
|
|
9
|
+
PurchaseAttemptStoreError,
|
|
10
|
+
PurchaseAuthorizationError,
|
|
11
|
+
PurchasePreparationError,
|
|
12
|
+
RequestFailedError,
|
|
13
|
+
RequestTimeoutError,
|
|
14
|
+
ResponseDecodeError,
|
|
15
|
+
ResponseShapeError,
|
|
16
|
+
SelectionError,
|
|
17
|
+
UnsupportedSelectionError,
|
|
18
|
+
WoltApiError,
|
|
19
|
+
WoltTransportError,
|
|
20
|
+
)
|
|
21
|
+
from .models import DeliveryTarget, OrderStatus, PaymentMethod, Venue
|
|
22
|
+
from .purchase import (
|
|
23
|
+
PurchaseAttemptStore,
|
|
24
|
+
PurchaseAuthorization,
|
|
25
|
+
PurchaseConfirmationSummary,
|
|
26
|
+
PurchaseContext,
|
|
27
|
+
PurchaseLineSummary,
|
|
28
|
+
PurchaseNameSummary,
|
|
29
|
+
PurchaseOptionSummary,
|
|
30
|
+
PurchaseOptionValueSummary,
|
|
31
|
+
PurchaseResult,
|
|
32
|
+
PreparedOrder,
|
|
33
|
+
authorize_prepared_order,
|
|
34
|
+
)
|
|
35
|
+
from .selection import (
|
|
36
|
+
DeliverySelection,
|
|
37
|
+
ItemSelection,
|
|
38
|
+
OptionSelection,
|
|
39
|
+
OptionValueSelection,
|
|
40
|
+
OrderSelection,
|
|
41
|
+
PostCheckoutConfig,
|
|
42
|
+
QuoteSnapshot,
|
|
43
|
+
SavedBasket,
|
|
44
|
+
VenueCheckoutContext,
|
|
45
|
+
)
|
|
46
|
+
from .transport import WoltTransport
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"DeliveryTarget",
|
|
50
|
+
"DeliverySelection",
|
|
51
|
+
"DuplicatePurchaseAttempt",
|
|
52
|
+
"HTTPStatusError",
|
|
53
|
+
"ItemSelection",
|
|
54
|
+
"OptionSelection",
|
|
55
|
+
"OptionValueSelection",
|
|
56
|
+
"OrderOutcomeUnknown",
|
|
57
|
+
"OrderStatus",
|
|
58
|
+
"OrderSelection",
|
|
59
|
+
"PaymentMethod",
|
|
60
|
+
"PostCheckoutConfig",
|
|
61
|
+
"PreparedOrder",
|
|
62
|
+
"PurchaseAttemptStore",
|
|
63
|
+
"PurchaseAttemptStoreError",
|
|
64
|
+
"PurchaseAuthorization",
|
|
65
|
+
"PurchaseAuthorizationError",
|
|
66
|
+
"PurchaseConfirmationSummary",
|
|
67
|
+
"PurchaseContext",
|
|
68
|
+
"PurchaseLineSummary",
|
|
69
|
+
"PurchaseNameSummary",
|
|
70
|
+
"PurchaseOptionSummary",
|
|
71
|
+
"PurchaseOptionValueSummary",
|
|
72
|
+
"PurchasePreparationError",
|
|
73
|
+
"PurchaseResult",
|
|
74
|
+
"QuoteSnapshot",
|
|
75
|
+
"RequestFailedError",
|
|
76
|
+
"RequestTimeoutError",
|
|
77
|
+
"ResponseDecodeError",
|
|
78
|
+
"ResponseShapeError",
|
|
79
|
+
"SavedBasket",
|
|
80
|
+
"SelectionError",
|
|
81
|
+
"SessionCredentials",
|
|
82
|
+
"UnsupportedSelectionError",
|
|
83
|
+
"Venue",
|
|
84
|
+
"VenueCheckoutContext",
|
|
85
|
+
"WoltApiError",
|
|
86
|
+
"WoltClient",
|
|
87
|
+
"WoltTransport",
|
|
88
|
+
"WoltTransportError",
|
|
89
|
+
"authorize_prepared_order",
|
|
90
|
+
]
|
woltapi/client.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""Synchronous Wolt discovery, selection, quote, and purchase client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from .credentials import SessionCredentials
|
|
13
|
+
from .errors import ResponseShapeError, SelectionError
|
|
14
|
+
from .models import DeliveryTarget, OrderStatus, PaymentMethod, Venue
|
|
15
|
+
from .purchase import (
|
|
16
|
+
PurchaseAttemptStore,
|
|
17
|
+
PurchaseAuthorization,
|
|
18
|
+
PurchaseContext,
|
|
19
|
+
PurchaseResult,
|
|
20
|
+
PreparedOrder,
|
|
21
|
+
_prepare_purchase_order,
|
|
22
|
+
_submit_prepared_order,
|
|
23
|
+
)
|
|
24
|
+
from .selection import (
|
|
25
|
+
DeliverySelection,
|
|
26
|
+
ItemSelection,
|
|
27
|
+
OrderSelection,
|
|
28
|
+
PostCheckoutConfig,
|
|
29
|
+
QuoteSnapshot,
|
|
30
|
+
SavedBasket,
|
|
31
|
+
VenueCheckoutContext,
|
|
32
|
+
)
|
|
33
|
+
from .services import ServiceHost
|
|
34
|
+
from .transport import DEFAULT_TIMEOUT_SECONDS, WoltTransport
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WoltClient:
|
|
38
|
+
"""Read discovery data and make explicit selection and purchase operations.
|
|
39
|
+
|
|
40
|
+
Purchase submission requires a caller-created authorization and an explicit,
|
|
41
|
+
durable attempt store. The class has no login, refresh, asynchronous,
|
|
42
|
+
WebSocket, card-enrollment, challenge, cancellation, refund, or autonomous
|
|
43
|
+
ordering methods. Local selections are immutable; a changed selection must
|
|
44
|
+
be rebuilt and cannot silently reuse an earlier quote snapshot.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
credentials: SessionCredentials,
|
|
50
|
+
*,
|
|
51
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
52
|
+
_transport: WoltTransport | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
if not isinstance(credentials, SessionCredentials):
|
|
55
|
+
raise TypeError("credentials must be a SessionCredentials instance.")
|
|
56
|
+
if _transport is not None and not isinstance(_transport, WoltTransport):
|
|
57
|
+
raise TypeError("_transport must be a WoltTransport instance.")
|
|
58
|
+
self._transport = _transport or WoltTransport(credentials, timeout=timeout)
|
|
59
|
+
self._delivery_target_ids: set[str] = set()
|
|
60
|
+
self._payment_eligibility_by_id: dict[str, str] = {}
|
|
61
|
+
|
|
62
|
+
def search_venues(
|
|
63
|
+
self, query: str, latitude: int | float, longitude: int | float
|
|
64
|
+
) -> tuple[Venue, ...]:
|
|
65
|
+
"""Search venues and extract results with a usable ID and slug."""
|
|
66
|
+
|
|
67
|
+
query = _required_text(query, "query")
|
|
68
|
+
latitude = _coordinate(latitude, "latitude")
|
|
69
|
+
longitude = _coordinate(longitude, "longitude")
|
|
70
|
+
response = self._transport.request(
|
|
71
|
+
ServiceHost.RESTAURANT,
|
|
72
|
+
"POST",
|
|
73
|
+
"/v1/pages/search",
|
|
74
|
+
json_body={"q": query, "target": None, "lat": latitude, "lon": longitude},
|
|
75
|
+
)
|
|
76
|
+
sections = _required_list(response, "sections", ServiceHost.RESTAURANT)
|
|
77
|
+
|
|
78
|
+
venues: list[Venue] = []
|
|
79
|
+
for section in sections:
|
|
80
|
+
if not isinstance(section, Mapping):
|
|
81
|
+
continue
|
|
82
|
+
items = section.get("items")
|
|
83
|
+
if not isinstance(items, Sequence) or isinstance(items, (str, bytes)):
|
|
84
|
+
continue
|
|
85
|
+
for item in items:
|
|
86
|
+
if not isinstance(item, Mapping):
|
|
87
|
+
continue
|
|
88
|
+
venue = item.get("venue")
|
|
89
|
+
if not isinstance(venue, Mapping):
|
|
90
|
+
continue
|
|
91
|
+
venue_id = venue.get("id")
|
|
92
|
+
venue_slug = venue.get("slug")
|
|
93
|
+
if not _is_nonempty_string(venue_id) or not _is_nonempty_string(
|
|
94
|
+
venue_slug
|
|
95
|
+
):
|
|
96
|
+
continue
|
|
97
|
+
venues.append(
|
|
98
|
+
Venue(
|
|
99
|
+
id=venue_id,
|
|
100
|
+
slug=venue_slug,
|
|
101
|
+
title=_optional_string(item.get("title")),
|
|
102
|
+
currency=_optional_string(venue.get("currency")),
|
|
103
|
+
delivers=_optional_bool(venue.get("delivers")),
|
|
104
|
+
online=_optional_bool(venue.get("online")),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return tuple(venues)
|
|
108
|
+
|
|
109
|
+
def get_orders_page(self) -> dict[str, Any]:
|
|
110
|
+
"""Read the current order-history page in server-provided order.
|
|
111
|
+
|
|
112
|
+
The detached page can contain private order data. Do not log it raw.
|
|
113
|
+
"""
|
|
114
|
+
return self._transport.request(
|
|
115
|
+
ServiceHost.CONSUMER,
|
|
116
|
+
"GET",
|
|
117
|
+
"/order-xp/web/v1/pages/orders",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def get_venue_static(self, venue_slug: str) -> dict[str, Any]:
|
|
121
|
+
"""Read the static venue page for a slug."""
|
|
122
|
+
|
|
123
|
+
slug = _path_segment(venue_slug, "venue_slug")
|
|
124
|
+
return self._transport.request(
|
|
125
|
+
ServiceHost.CONSUMER,
|
|
126
|
+
"GET",
|
|
127
|
+
f"/order-xp/web/v1/pages/venue/slug/{slug}/static",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def get_venue_dynamic(
|
|
131
|
+
self,
|
|
132
|
+
venue_slug: str,
|
|
133
|
+
latitude: int | float,
|
|
134
|
+
longitude: int | float,
|
|
135
|
+
*,
|
|
136
|
+
selected_delivery_method: str = "homedelivery",
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
"""Read the location-aware venue page, preserving its trailing slash."""
|
|
139
|
+
|
|
140
|
+
slug = _path_segment(venue_slug, "venue_slug")
|
|
141
|
+
latitude = _coordinate(latitude, "latitude")
|
|
142
|
+
longitude = _coordinate(longitude, "longitude")
|
|
143
|
+
selected_delivery_method = _required_text(
|
|
144
|
+
selected_delivery_method, "selected_delivery_method"
|
|
145
|
+
)
|
|
146
|
+
return self._transport.request(
|
|
147
|
+
ServiceHost.CONSUMER,
|
|
148
|
+
"GET",
|
|
149
|
+
f"/order-xp/web/v1/venue/slug/{slug}/dynamic/",
|
|
150
|
+
query={
|
|
151
|
+
"lat": latitude,
|
|
152
|
+
"lon": longitude,
|
|
153
|
+
"selected_delivery_method": selected_delivery_method,
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def get_venue_content(self, venue_slug: str) -> dict[str, Any]:
|
|
158
|
+
"""Read the server-driven venue-content document for a slug."""
|
|
159
|
+
|
|
160
|
+
slug = _path_segment(venue_slug, "venue_slug")
|
|
161
|
+
return self._transport.request(
|
|
162
|
+
ServiceHost.CONSUMER,
|
|
163
|
+
"GET",
|
|
164
|
+
f"/consumer-api/venue-content-api/v3/web/venue-content/slug/{slug}",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def get_assortment(self, venue_slug: str) -> dict[str, Any]:
|
|
168
|
+
"""Read the normalized assortment without dropping catalog fields."""
|
|
169
|
+
|
|
170
|
+
slug = _path_segment(venue_slug, "venue_slug")
|
|
171
|
+
return self._transport.request(
|
|
172
|
+
ServiceHost.CONSUMER,
|
|
173
|
+
"GET",
|
|
174
|
+
f"/consumer-api/consumer-assortment/v1/venues/slug/{slug}/assortment",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def get_item(
|
|
178
|
+
self, venue_id: str, menu_item_id: str, *, language: str
|
|
179
|
+
) -> dict[str, Any]:
|
|
180
|
+
"""Read an item-detail page without transforming its catalog data."""
|
|
181
|
+
|
|
182
|
+
venue_id = _path_segment(venue_id, "venue_id")
|
|
183
|
+
menu_item_id = _path_segment(menu_item_id, "menu_item_id")
|
|
184
|
+
language = _required_text(language, "language")
|
|
185
|
+
return self._transport.request(
|
|
186
|
+
ServiceHost.CONSUMER,
|
|
187
|
+
"GET",
|
|
188
|
+
f"/order-xp/web/v1/pages/venue/{venue_id}/item/{menu_item_id}",
|
|
189
|
+
query={"language": language},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def list_delivery_targets(self) -> tuple[DeliveryTarget, ...]:
|
|
193
|
+
"""List opaque saved delivery IDs without exposing address details."""
|
|
194
|
+
|
|
195
|
+
self._delivery_target_ids = set()
|
|
196
|
+
response = self._transport.request(
|
|
197
|
+
ServiceHost.RESTAURANT,
|
|
198
|
+
"GET",
|
|
199
|
+
"/v2/delivery/info",
|
|
200
|
+
)
|
|
201
|
+
results = _required_list(response, "results", ServiceHost.RESTAURANT)
|
|
202
|
+
|
|
203
|
+
targets: list[DeliveryTarget] = []
|
|
204
|
+
target_ids: set[str] = set()
|
|
205
|
+
for result in results:
|
|
206
|
+
if not isinstance(result, Mapping):
|
|
207
|
+
continue
|
|
208
|
+
target_id = result.get("id")
|
|
209
|
+
if not _is_nonempty_string(target_id):
|
|
210
|
+
continue
|
|
211
|
+
targets.append(DeliveryTarget(id=target_id))
|
|
212
|
+
target_ids.add(target_id)
|
|
213
|
+
self._delivery_target_ids = target_ids
|
|
214
|
+
return tuple(targets)
|
|
215
|
+
|
|
216
|
+
def get_payment_methods(
|
|
217
|
+
self, context: Mapping[str, Any]
|
|
218
|
+
) -> tuple[PaymentMethod, ...]:
|
|
219
|
+
"""Return enabled saved-card references from a payment element tree."""
|
|
220
|
+
|
|
221
|
+
if not isinstance(context, Mapping):
|
|
222
|
+
raise TypeError("context must be a mapping.")
|
|
223
|
+
eligibility_binding = _payment_eligibility_binding(context)
|
|
224
|
+
self._payment_eligibility_by_id = {}
|
|
225
|
+
response = self._transport.request(
|
|
226
|
+
ServiceHost.PAYMENT,
|
|
227
|
+
"POST",
|
|
228
|
+
"/v1/payment-methods/checkout",
|
|
229
|
+
json_body=dict(context),
|
|
230
|
+
)
|
|
231
|
+
root = response.get("root_element")
|
|
232
|
+
if not isinstance(root, Mapping):
|
|
233
|
+
raise ResponseShapeError(ServiceHost.PAYMENT.value)
|
|
234
|
+
|
|
235
|
+
methods: list[PaymentMethod] = []
|
|
236
|
+
eligibility_by_id: dict[str, str] = {}
|
|
237
|
+
stack: list[Mapping[str, Any]] = [root]
|
|
238
|
+
while stack:
|
|
239
|
+
node = stack.pop()
|
|
240
|
+
children = node.get("children")
|
|
241
|
+
if isinstance(children, Sequence) and not isinstance(
|
|
242
|
+
children, (str, bytes)
|
|
243
|
+
):
|
|
244
|
+
stack.extend(
|
|
245
|
+
child for child in reversed(children) if isinstance(child, Mapping)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if (
|
|
249
|
+
node.get("element_type") != "payment-method"
|
|
250
|
+
or node.get("is_enabled") is not True
|
|
251
|
+
):
|
|
252
|
+
continue
|
|
253
|
+
method = node.get("method")
|
|
254
|
+
if not isinstance(method, Mapping):
|
|
255
|
+
continue
|
|
256
|
+
method_id = method.get("id")
|
|
257
|
+
method_type = method.get("type")
|
|
258
|
+
if not _is_nonempty_string(method_id) or method_type != "card":
|
|
259
|
+
continue
|
|
260
|
+
methods.append(
|
|
261
|
+
PaymentMethod(
|
|
262
|
+
id=method_id,
|
|
263
|
+
type=method_type,
|
|
264
|
+
is_selected=node.get("is_selected") is True,
|
|
265
|
+
is_default=node.get("is_default") is True,
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
eligibility_by_id[method_id] = eligibility_binding
|
|
269
|
+
self._payment_eligibility_by_id = eligibility_by_id
|
|
270
|
+
return tuple(methods)
|
|
271
|
+
|
|
272
|
+
def create_selection(
|
|
273
|
+
self,
|
|
274
|
+
assortment: Mapping[str, Any],
|
|
275
|
+
*,
|
|
276
|
+
venue: VenueCheckoutContext,
|
|
277
|
+
delivery: DeliverySelection,
|
|
278
|
+
payment_method: Mapping[str, Any],
|
|
279
|
+
courier_tip: int,
|
|
280
|
+
items: Sequence[ItemSelection],
|
|
281
|
+
) -> OrderSelection:
|
|
282
|
+
"""Build an immutable local selection from current catalog data.
|
|
283
|
+
|
|
284
|
+
A caller must first discover the saved delivery target and enabled card
|
|
285
|
+
through this client. The supplied payment mapping is copied exactly;
|
|
286
|
+
this client does not derive checkout-card fields from payment UI data.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
if not isinstance(delivery, DeliverySelection):
|
|
290
|
+
raise TypeError("delivery must be a DeliverySelection instance.")
|
|
291
|
+
if delivery.delivery_info_id not in self._delivery_target_ids:
|
|
292
|
+
raise SelectionError("The saved delivery reference is not current.")
|
|
293
|
+
if not isinstance(payment_method, Mapping):
|
|
294
|
+
raise TypeError("payment_method must be a mapping.")
|
|
295
|
+
payment_method_id = payment_method.get("id")
|
|
296
|
+
if (
|
|
297
|
+
not _is_nonempty_string(payment_method_id)
|
|
298
|
+
or payment_method_id not in self._payment_eligibility_by_id
|
|
299
|
+
):
|
|
300
|
+
raise SelectionError("The saved card reference is not current.")
|
|
301
|
+
selection = OrderSelection._from_assortment(
|
|
302
|
+
assortment,
|
|
303
|
+
venue=venue,
|
|
304
|
+
delivery=delivery,
|
|
305
|
+
payment_method=payment_method,
|
|
306
|
+
courier_tip=courier_tip,
|
|
307
|
+
items=items,
|
|
308
|
+
)
|
|
309
|
+
if self._payment_eligibility_by_id[
|
|
310
|
+
payment_method_id
|
|
311
|
+
] != _payment_eligibility_binding(selection._payment_eligibility_context()):
|
|
312
|
+
raise SelectionError(
|
|
313
|
+
"The saved card eligibility does not match this selection."
|
|
314
|
+
)
|
|
315
|
+
return selection
|
|
316
|
+
|
|
317
|
+
def save_basket(self, selection: OrderSelection) -> SavedBasket:
|
|
318
|
+
"""Persist a basket explicitly; this mutation does not place an order."""
|
|
319
|
+
|
|
320
|
+
selection = _order_selection(selection)
|
|
321
|
+
response = self._transport.request(
|
|
322
|
+
ServiceHost.CONSUMER,
|
|
323
|
+
"POST",
|
|
324
|
+
"/order-xp/v1/baskets",
|
|
325
|
+
json_body=selection.to_basket_payload(),
|
|
326
|
+
)
|
|
327
|
+
basket_id = response.get("id")
|
|
328
|
+
venue_id = response.get("venue_id")
|
|
329
|
+
if not _is_nonempty_string(basket_id) or not _is_nonempty_string(venue_id):
|
|
330
|
+
raise ResponseShapeError(ServiceHost.CONSUMER.value)
|
|
331
|
+
return SavedBasket(id=basket_id, venue_id=venue_id)
|
|
332
|
+
|
|
333
|
+
def quote_checkout(self, selection: OrderSelection) -> QuoteSnapshot:
|
|
334
|
+
"""Request one checkout quote and capture independent plan/response snapshots."""
|
|
335
|
+
|
|
336
|
+
selection = _order_selection(selection)
|
|
337
|
+
payload = selection.to_checkout_payload()
|
|
338
|
+
response = self._transport.request(
|
|
339
|
+
ServiceHost.CONSUMER,
|
|
340
|
+
"POST",
|
|
341
|
+
"/order-xp/web/v2/pages/checkout",
|
|
342
|
+
json_body=payload,
|
|
343
|
+
)
|
|
344
|
+
return QuoteSnapshot._capture(
|
|
345
|
+
selection,
|
|
346
|
+
payload["purchase_plan"],
|
|
347
|
+
response,
|
|
348
|
+
ServiceHost.CONSUMER.value,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def get_post_checkout_config(self, selection: OrderSelection) -> PostCheckoutConfig:
|
|
352
|
+
"""Discover required consents using the distinct post-checkout payload."""
|
|
353
|
+
|
|
354
|
+
selection = _order_selection(selection)
|
|
355
|
+
response = self._transport.request(
|
|
356
|
+
ServiceHost.RESTAURANT,
|
|
357
|
+
"POST",
|
|
358
|
+
"/v1/post-checkout-config",
|
|
359
|
+
json_body=selection.to_post_checkout_payload(),
|
|
360
|
+
)
|
|
361
|
+
return PostCheckoutConfig._from_response(
|
|
362
|
+
response,
|
|
363
|
+
ServiceHost.RESTAURANT.value,
|
|
364
|
+
selection,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def prepare_purchase(
|
|
368
|
+
self,
|
|
369
|
+
selection: OrderSelection,
|
|
370
|
+
quote: QuoteSnapshot,
|
|
371
|
+
consents: PostCheckoutConfig,
|
|
372
|
+
context: PurchaseContext,
|
|
373
|
+
) -> PreparedOrder:
|
|
374
|
+
"""Prepare a one-card, home-delivery purchase without a network effect.
|
|
375
|
+
|
|
376
|
+
The selected delivery target and saved card must still be present in this
|
|
377
|
+
client's most recently discovered eligible references. This does not
|
|
378
|
+
establish a server quote expiry or replace the caller confirmation step.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
selection = _order_selection(selection)
|
|
382
|
+
selection_eligibility = _payment_eligibility_binding(
|
|
383
|
+
selection._payment_eligibility_context()
|
|
384
|
+
)
|
|
385
|
+
eligible_payment_ids = {
|
|
386
|
+
payment_id
|
|
387
|
+
for payment_id, eligibility in self._payment_eligibility_by_id.items()
|
|
388
|
+
if eligibility == selection_eligibility
|
|
389
|
+
}
|
|
390
|
+
return _prepare_purchase_order(
|
|
391
|
+
selection,
|
|
392
|
+
quote,
|
|
393
|
+
consents,
|
|
394
|
+
context,
|
|
395
|
+
enabled_payment_ids=eligible_payment_ids,
|
|
396
|
+
delivery_target_ids=set(self._delivery_target_ids),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def submit_prepared_order(
|
|
400
|
+
self,
|
|
401
|
+
store: PurchaseAttemptStore,
|
|
402
|
+
prepared_order: PreparedOrder,
|
|
403
|
+
authorization: PurchaseAuthorization,
|
|
404
|
+
) -> PurchaseResult:
|
|
405
|
+
"""Send one explicit purchase attempt through this client's transport."""
|
|
406
|
+
|
|
407
|
+
return _submit_prepared_order(
|
|
408
|
+
self._transport,
|
|
409
|
+
store,
|
|
410
|
+
prepared_order,
|
|
411
|
+
authorization,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
def get_order_status(self, purchase_id: str) -> OrderStatus:
|
|
415
|
+
"""Read the dedicated tracking state without constraining status values."""
|
|
416
|
+
|
|
417
|
+
purchase_id = _required_text(purchase_id, "purchase_id")
|
|
418
|
+
encoded_purchase_id = quote(purchase_id, safe="")
|
|
419
|
+
response = self._transport.request(
|
|
420
|
+
ServiceHost.RESTAURANT,
|
|
421
|
+
"GET",
|
|
422
|
+
f"/v2/order_details/purchase_tracking/{encoded_purchase_id}",
|
|
423
|
+
)
|
|
424
|
+
details = response.get("order_details")
|
|
425
|
+
if not isinstance(details, Mapping):
|
|
426
|
+
raise ResponseShapeError(ServiceHost.RESTAURANT.value)
|
|
427
|
+
order_id = details.get("order_id")
|
|
428
|
+
status = details.get("status")
|
|
429
|
+
if (
|
|
430
|
+
not _is_nonempty_string(order_id)
|
|
431
|
+
or order_id != purchase_id
|
|
432
|
+
or not isinstance(status, str)
|
|
433
|
+
):
|
|
434
|
+
raise ResponseShapeError(ServiceHost.RESTAURANT.value)
|
|
435
|
+
|
|
436
|
+
return OrderStatus(
|
|
437
|
+
purchase_id=order_id,
|
|
438
|
+
status=status,
|
|
439
|
+
currency=_optional_string(details.get("currency")),
|
|
440
|
+
payment_amount=_optional_number(details.get("payment_amount")),
|
|
441
|
+
total_price=_optional_number(details.get("total_price")),
|
|
442
|
+
delivery_price=_optional_number(details.get("delivery_price")),
|
|
443
|
+
delivery_method=_optional_string(details.get("delivery_method")),
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _required_text(value: object, name: str) -> str:
|
|
448
|
+
if not _is_nonempty_string(value):
|
|
449
|
+
raise ValueError(f"{name} must be a non-empty string.")
|
|
450
|
+
return value
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _path_segment(value: object, name: str) -> str:
|
|
454
|
+
return quote(_required_text(value, name), safe="")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _coordinate(value: object, name: str) -> int | float:
|
|
458
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
459
|
+
raise TypeError(f"{name} must be a finite number.")
|
|
460
|
+
try:
|
|
461
|
+
is_finite = math.isfinite(value)
|
|
462
|
+
except OverflowError:
|
|
463
|
+
is_finite = False
|
|
464
|
+
if not is_finite:
|
|
465
|
+
raise ValueError(f"{name} must be a finite number.")
|
|
466
|
+
return value
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _required_list(
|
|
470
|
+
response: Mapping[str, Any], field: str, service: ServiceHost
|
|
471
|
+
) -> Sequence[Any]:
|
|
472
|
+
value = response.get(field)
|
|
473
|
+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
|
474
|
+
raise ResponseShapeError(service.value)
|
|
475
|
+
return value
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _is_nonempty_string(value: object) -> bool:
|
|
479
|
+
return isinstance(value, str) and bool(value.strip())
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _optional_string(value: object) -> str | None:
|
|
483
|
+
return value if isinstance(value, str) else None
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _optional_bool(value: object) -> bool | None:
|
|
487
|
+
return value if isinstance(value, bool) else None
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _optional_number(value: object) -> int | float | None:
|
|
491
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
492
|
+
return None
|
|
493
|
+
return value
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _order_selection(value: object) -> OrderSelection:
|
|
497
|
+
if not isinstance(value, OrderSelection):
|
|
498
|
+
raise TypeError("selection must be an OrderSelection instance.")
|
|
499
|
+
return value
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _payment_eligibility_binding(context: Mapping[str, Any]) -> str:
|
|
503
|
+
"""Hash the minimum observed card-eligibility context without retaining it."""
|
|
504
|
+
|
|
505
|
+
venue_id = context.get("venue_id")
|
|
506
|
+
delivery_method = context.get("delivery_method")
|
|
507
|
+
items = context.get("items")
|
|
508
|
+
if (
|
|
509
|
+
not _is_nonempty_string(venue_id)
|
|
510
|
+
or not _is_nonempty_string(delivery_method)
|
|
511
|
+
or not isinstance(items, Sequence)
|
|
512
|
+
or isinstance(items, (str, bytes))
|
|
513
|
+
or not items
|
|
514
|
+
):
|
|
515
|
+
raise SelectionError("The payment eligibility context is incomplete.")
|
|
516
|
+
|
|
517
|
+
normalized_items: list[dict[str, Any]] = []
|
|
518
|
+
for item in items:
|
|
519
|
+
if not isinstance(item, Mapping):
|
|
520
|
+
raise SelectionError("The payment eligibility context is incomplete.")
|
|
521
|
+
item_id = item.get("id")
|
|
522
|
+
alcohol_permille = item.get("alcohol_permille")
|
|
523
|
+
tags = item.get("product_hierarchy_tags")
|
|
524
|
+
vat_percentage = item.get("vat_percentage")
|
|
525
|
+
vat_percentage_decimal = item.get("vat_percentage_decimal")
|
|
526
|
+
if (
|
|
527
|
+
not _is_nonempty_string(item_id)
|
|
528
|
+
or not _is_number(alcohol_permille)
|
|
529
|
+
or not isinstance(tags, Sequence)
|
|
530
|
+
or isinstance(tags, (str, bytes))
|
|
531
|
+
or not _is_number(vat_percentage)
|
|
532
|
+
or not _is_nonempty_string(vat_percentage_decimal)
|
|
533
|
+
):
|
|
534
|
+
raise SelectionError("The payment eligibility context is incomplete.")
|
|
535
|
+
normalized_items.append(
|
|
536
|
+
{
|
|
537
|
+
"id": item_id,
|
|
538
|
+
"alcohol_permille": alcohol_permille,
|
|
539
|
+
"product_hierarchy_tags": list(tags),
|
|
540
|
+
"vat_percentage": vat_percentage,
|
|
541
|
+
"vat_percentage_decimal": vat_percentage_decimal,
|
|
542
|
+
}
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
try:
|
|
546
|
+
encoded = json.dumps(
|
|
547
|
+
{
|
|
548
|
+
"venue_id": venue_id,
|
|
549
|
+
"delivery_method": delivery_method,
|
|
550
|
+
"items": normalized_items,
|
|
551
|
+
},
|
|
552
|
+
allow_nan=False,
|
|
553
|
+
ensure_ascii=False,
|
|
554
|
+
separators=(",", ":"),
|
|
555
|
+
sort_keys=True,
|
|
556
|
+
).encode("utf-8")
|
|
557
|
+
except (TypeError, ValueError, UnicodeEncodeError):
|
|
558
|
+
raise SelectionError(
|
|
559
|
+
"The payment eligibility context is unsupported."
|
|
560
|
+
) from None
|
|
561
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _is_number(value: object) -> bool:
|
|
565
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|