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/purchase.py
ADDED
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
"""Explicit purchase preparation, authorization, and one-attempt submission."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import sqlite3
|
|
12
|
+
from collections.abc import Collection, Mapping, Sequence
|
|
13
|
+
from typing import Any
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
from .errors import (
|
|
17
|
+
DuplicatePurchaseAttempt,
|
|
18
|
+
OrderOutcomeUnknown,
|
|
19
|
+
PurchaseAttemptStoreError,
|
|
20
|
+
PurchaseAuthorizationError,
|
|
21
|
+
PurchasePreparationError,
|
|
22
|
+
)
|
|
23
|
+
from .selection import OrderSelection, PostCheckoutConfig, QuoteSnapshot
|
|
24
|
+
from .services import ServiceHost
|
|
25
|
+
from .transport import WoltTransport
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class PurchaseContext:
|
|
30
|
+
"""Caller-supplied fields whose provenance is unresolved by the evidence.
|
|
31
|
+
|
|
32
|
+
The values are copied as supplied. This class deliberately does not create
|
|
33
|
+
a client nonce, device identifier, timestamp, signature, browser context,
|
|
34
|
+
return URL, purchase-item names, or purchase-only item fields.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
client_nonce: str = field(repr=False)
|
|
38
|
+
ravelin_device_id: str = field(repr=False)
|
|
39
|
+
signature_datetime: Mapping[str, Any] = field(repr=False)
|
|
40
|
+
language: str
|
|
41
|
+
client_pre_estimate: str = field(repr=False)
|
|
42
|
+
signature: str = field(repr=False)
|
|
43
|
+
pricing_model_version: int
|
|
44
|
+
device_channel: str
|
|
45
|
+
browser_info: Mapping[str, Any] = field(repr=False)
|
|
46
|
+
payment_links: Mapping[str, Any] = field(repr=False)
|
|
47
|
+
delivery_info_options: Mapping[str, Any] = field(repr=False)
|
|
48
|
+
additional_checkout_options: Mapping[str, Any] = field(repr=False)
|
|
49
|
+
menu_items_source: str
|
|
50
|
+
use_self_service_cancellation: bool
|
|
51
|
+
purchase_items: Sequence[Mapping[str, Any]] = field(repr=False)
|
|
52
|
+
optional_fields: Mapping[str, Any] = field(default_factory=dict, repr=False)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class PurchaseNameSummary:
|
|
57
|
+
"""One readable localized name from the exact purchase representation."""
|
|
58
|
+
|
|
59
|
+
value: str
|
|
60
|
+
language: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class PurchaseOptionValueSummary:
|
|
65
|
+
"""One exact selected purchase-option value for caller confirmation."""
|
|
66
|
+
|
|
67
|
+
value_id: str
|
|
68
|
+
count: int
|
|
69
|
+
names: tuple[PurchaseNameSummary, ...]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class PurchaseOptionSummary:
|
|
74
|
+
"""One exact selected option and its confirmed values."""
|
|
75
|
+
|
|
76
|
+
option_id: str
|
|
77
|
+
names: tuple[PurchaseNameSummary, ...]
|
|
78
|
+
values: tuple[PurchaseOptionValueSummary, ...]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class PurchaseLineSummary:
|
|
83
|
+
"""One exact item, readable names, and selected options for confirmation."""
|
|
84
|
+
|
|
85
|
+
item_id: str
|
|
86
|
+
count: int
|
|
87
|
+
names: tuple[PurchaseNameSummary, ...]
|
|
88
|
+
options: tuple[PurchaseOptionSummary, ...]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class PurchaseConfirmationSummary:
|
|
93
|
+
"""Amounts and references that must be shown before caller authorization."""
|
|
94
|
+
|
|
95
|
+
checkout_id: str
|
|
96
|
+
venue_id: str
|
|
97
|
+
delivery_info_id: str
|
|
98
|
+
payment_method_id: str
|
|
99
|
+
courier_tip: int
|
|
100
|
+
payable_amount: int | float
|
|
101
|
+
end_amount: int
|
|
102
|
+
items: tuple[PurchaseLineSummary, ...]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class PurchaseResult:
|
|
107
|
+
"""Minimal successful-purchase response without raw operational details."""
|
|
108
|
+
|
|
109
|
+
purchase_id: str
|
|
110
|
+
status: str
|
|
111
|
+
currency: str
|
|
112
|
+
amount: int | float
|
|
113
|
+
delivery_method: str
|
|
114
|
+
delivery_price: int | float
|
|
115
|
+
payment_method_type: str
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class _AttemptRecord:
|
|
120
|
+
attempt_id: str
|
|
121
|
+
state: str
|
|
122
|
+
purchase_id: str | None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class PurchaseAttemptStore:
|
|
126
|
+
"""SQLite-backed minimal state that prevents re-sending one prepared order.
|
|
127
|
+
|
|
128
|
+
Only a hash-derived attempt key, a local attempt ID, state, and an available
|
|
129
|
+
purchase ID are stored. Credentials, authorization text, prepared payloads,
|
|
130
|
+
delivery details, and payment metadata are never written to this store.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
__slots__ = ("_path",)
|
|
134
|
+
|
|
135
|
+
def __init__(self, path: str | os.PathLike[str]) -> None:
|
|
136
|
+
if not isinstance(path, (str, os.PathLike)) or str(path) == ":memory:":
|
|
137
|
+
raise ValueError("A durable filesystem path is required for attempt state.")
|
|
138
|
+
self._path = Path(path).expanduser()
|
|
139
|
+
try:
|
|
140
|
+
self._path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
141
|
+
except OSError:
|
|
142
|
+
raise PurchaseAttemptStoreError(
|
|
143
|
+
"Durable purchase-attempt state could not be initialized."
|
|
144
|
+
) from None
|
|
145
|
+
self._ensure_private_file()
|
|
146
|
+
self._initialize()
|
|
147
|
+
|
|
148
|
+
def begin(self, attempt_key: str) -> tuple[bool, _AttemptRecord]:
|
|
149
|
+
"""Atomically persist a started attempt before a network submission."""
|
|
150
|
+
|
|
151
|
+
attempt_id = uuid4().hex
|
|
152
|
+
connection = self._connect()
|
|
153
|
+
try:
|
|
154
|
+
with connection:
|
|
155
|
+
inserted = connection.execute(
|
|
156
|
+
"""
|
|
157
|
+
INSERT OR IGNORE INTO purchase_attempts
|
|
158
|
+
(attempt_key, attempt_id, state, purchase_id)
|
|
159
|
+
VALUES (?, ?, 'started', NULL)
|
|
160
|
+
""",
|
|
161
|
+
(attempt_key, attempt_id),
|
|
162
|
+
).rowcount
|
|
163
|
+
if inserted:
|
|
164
|
+
return True, _AttemptRecord(attempt_id, "started", None)
|
|
165
|
+
row = connection.execute(
|
|
166
|
+
"""
|
|
167
|
+
SELECT attempt_id, state, purchase_id
|
|
168
|
+
FROM purchase_attempts
|
|
169
|
+
WHERE attempt_key = ?
|
|
170
|
+
""",
|
|
171
|
+
(attempt_key,),
|
|
172
|
+
).fetchone()
|
|
173
|
+
except sqlite3.Error:
|
|
174
|
+
raise PurchaseAttemptStoreError(
|
|
175
|
+
"Durable purchase-attempt state could not be recorded."
|
|
176
|
+
) from None
|
|
177
|
+
finally:
|
|
178
|
+
connection.close()
|
|
179
|
+
|
|
180
|
+
if row is None:
|
|
181
|
+
raise PurchaseAttemptStoreError(
|
|
182
|
+
"Durable purchase-attempt state could not be read."
|
|
183
|
+
)
|
|
184
|
+
return False, _AttemptRecord(row[0], row[1], row[2])
|
|
185
|
+
|
|
186
|
+
def mark_succeeded(self, attempt_key: str, purchase_id: str) -> None:
|
|
187
|
+
self._update(attempt_key, "succeeded", purchase_id)
|
|
188
|
+
|
|
189
|
+
def mark_unknown(self, attempt_key: str, purchase_id: str | None = None) -> None:
|
|
190
|
+
"""Record an ambiguous result without making the attempt sendable again."""
|
|
191
|
+
|
|
192
|
+
if purchase_id is not None and not _is_text(purchase_id):
|
|
193
|
+
raise PurchaseAttemptStoreError("A returned purchase ID was invalid.")
|
|
194
|
+
connection = self._connect()
|
|
195
|
+
try:
|
|
196
|
+
with connection:
|
|
197
|
+
connection.execute(
|
|
198
|
+
"""
|
|
199
|
+
UPDATE purchase_attempts
|
|
200
|
+
SET state = 'unknown', purchase_id = COALESCE(?, purchase_id)
|
|
201
|
+
WHERE attempt_key = ? AND state = 'started'
|
|
202
|
+
""",
|
|
203
|
+
(purchase_id, attempt_key),
|
|
204
|
+
)
|
|
205
|
+
except sqlite3.Error:
|
|
206
|
+
raise PurchaseAttemptStoreError(
|
|
207
|
+
"Durable purchase-attempt state could not be updated."
|
|
208
|
+
) from None
|
|
209
|
+
finally:
|
|
210
|
+
connection.close()
|
|
211
|
+
|
|
212
|
+
def _update(self, attempt_key: str, state: str, purchase_id: str) -> None:
|
|
213
|
+
connection = self._connect()
|
|
214
|
+
try:
|
|
215
|
+
with connection:
|
|
216
|
+
updated = connection.execute(
|
|
217
|
+
"""
|
|
218
|
+
UPDATE purchase_attempts
|
|
219
|
+
SET state = ?, purchase_id = ?
|
|
220
|
+
WHERE attempt_key = ? AND state = 'started'
|
|
221
|
+
""",
|
|
222
|
+
(state, purchase_id, attempt_key),
|
|
223
|
+
).rowcount
|
|
224
|
+
except sqlite3.Error:
|
|
225
|
+
raise PurchaseAttemptStoreError(
|
|
226
|
+
"Durable purchase-attempt state could not be updated."
|
|
227
|
+
) from None
|
|
228
|
+
finally:
|
|
229
|
+
connection.close()
|
|
230
|
+
if updated != 1:
|
|
231
|
+
raise PurchaseAttemptStoreError(
|
|
232
|
+
"Durable purchase-attempt state was not in a sendable state."
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def _ensure_private_file(self) -> None:
|
|
236
|
+
try:
|
|
237
|
+
descriptor = os.open(self._path, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o600)
|
|
238
|
+
except FileExistsError:
|
|
239
|
+
pass
|
|
240
|
+
except OSError:
|
|
241
|
+
raise PurchaseAttemptStoreError(
|
|
242
|
+
"Durable purchase-attempt state could not be initialized."
|
|
243
|
+
) from None
|
|
244
|
+
else:
|
|
245
|
+
os.close(descriptor)
|
|
246
|
+
try:
|
|
247
|
+
if not self._path.is_file() or self._path.is_symlink():
|
|
248
|
+
raise OSError
|
|
249
|
+
os.chmod(self._path, 0o600)
|
|
250
|
+
except OSError:
|
|
251
|
+
raise PurchaseAttemptStoreError(
|
|
252
|
+
"Durable purchase-attempt state could not be initialized."
|
|
253
|
+
) from None
|
|
254
|
+
|
|
255
|
+
def _initialize(self) -> None:
|
|
256
|
+
connection = self._connect()
|
|
257
|
+
try:
|
|
258
|
+
with connection:
|
|
259
|
+
connection.execute(
|
|
260
|
+
"""
|
|
261
|
+
CREATE TABLE IF NOT EXISTS purchase_attempts (
|
|
262
|
+
attempt_key TEXT PRIMARY KEY,
|
|
263
|
+
attempt_id TEXT NOT NULL UNIQUE,
|
|
264
|
+
state TEXT NOT NULL,
|
|
265
|
+
purchase_id TEXT
|
|
266
|
+
)
|
|
267
|
+
"""
|
|
268
|
+
)
|
|
269
|
+
except sqlite3.Error:
|
|
270
|
+
raise PurchaseAttemptStoreError(
|
|
271
|
+
"Durable purchase-attempt state could not be initialized."
|
|
272
|
+
) from None
|
|
273
|
+
finally:
|
|
274
|
+
connection.close()
|
|
275
|
+
|
|
276
|
+
def _connect(self) -> sqlite3.Connection:
|
|
277
|
+
try:
|
|
278
|
+
connection = sqlite3.connect(self._path, timeout=5.0)
|
|
279
|
+
connection.execute("PRAGMA busy_timeout = 5000")
|
|
280
|
+
return connection
|
|
281
|
+
except sqlite3.Error:
|
|
282
|
+
raise PurchaseAttemptStoreError(
|
|
283
|
+
"Durable purchase-attempt state could not be opened."
|
|
284
|
+
) from None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
_PURCHASE_AUTHORIZATION_TOKEN = object()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class PurchaseAuthorization:
|
|
291
|
+
"""An explicit caller confirmation hash-bound to one prepared order."""
|
|
292
|
+
|
|
293
|
+
__slots__ = ("_order_binding",)
|
|
294
|
+
|
|
295
|
+
def __init__(
|
|
296
|
+
self,
|
|
297
|
+
private_token: object,
|
|
298
|
+
order_binding: str,
|
|
299
|
+
) -> None:
|
|
300
|
+
if private_token is not _PURCHASE_AUTHORIZATION_TOKEN:
|
|
301
|
+
raise TypeError(
|
|
302
|
+
"Use authorize_prepared_order() to create an authorization."
|
|
303
|
+
)
|
|
304
|
+
self._order_binding = order_binding
|
|
305
|
+
|
|
306
|
+
def __repr__(self) -> str:
|
|
307
|
+
return "PurchaseAuthorization(<redacted>)"
|
|
308
|
+
|
|
309
|
+
@classmethod
|
|
310
|
+
def _for_order(
|
|
311
|
+
cls, prepared_order: PreparedOrder, confirmation_id: str
|
|
312
|
+
) -> PurchaseAuthorization:
|
|
313
|
+
if not _is_text(confirmation_id):
|
|
314
|
+
raise PurchaseAuthorizationError(
|
|
315
|
+
"An explicit caller confirmation identifier is required."
|
|
316
|
+
)
|
|
317
|
+
return cls(
|
|
318
|
+
_PURCHASE_AUTHORIZATION_TOKEN,
|
|
319
|
+
prepared_order._binding(),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def _matches(self, prepared_order: PreparedOrder) -> bool:
|
|
323
|
+
return self._order_binding == prepared_order._binding()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class PreparedOrder:
|
|
327
|
+
"""An immutable purchase payload bound to its exact retained quote."""
|
|
328
|
+
|
|
329
|
+
__slots__ = (
|
|
330
|
+
"_attempt_key_value",
|
|
331
|
+
"_payload",
|
|
332
|
+
"_payload_binding",
|
|
333
|
+
"_binding_value",
|
|
334
|
+
"_summary",
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def __init__(
|
|
338
|
+
self,
|
|
339
|
+
payload: Mapping[str, Any],
|
|
340
|
+
summary: PurchaseConfirmationSummary,
|
|
341
|
+
quote: Mapping[str, Any],
|
|
342
|
+
) -> None:
|
|
343
|
+
if not isinstance(payload, Mapping):
|
|
344
|
+
raise TypeError("payload must be a mapping.")
|
|
345
|
+
if not isinstance(summary, PurchaseConfirmationSummary):
|
|
346
|
+
raise TypeError("summary must be a PurchaseConfirmationSummary instance.")
|
|
347
|
+
if not isinstance(quote, Mapping):
|
|
348
|
+
raise TypeError("quote must be a mapping.")
|
|
349
|
+
payload_snapshot = deepcopy(dict(payload))
|
|
350
|
+
quote_snapshot = deepcopy(dict(quote))
|
|
351
|
+
payload_checkout_id = payload_snapshot.get("checkout_id")
|
|
352
|
+
quote_checkout_id = quote_snapshot.get("id")
|
|
353
|
+
summary_checkout_id = summary.checkout_id
|
|
354
|
+
if (
|
|
355
|
+
not _is_text(payload_checkout_id)
|
|
356
|
+
or not _is_text(quote_checkout_id)
|
|
357
|
+
or not _is_text(summary_checkout_id)
|
|
358
|
+
or payload_checkout_id != quote_checkout_id
|
|
359
|
+
or quote_checkout_id != summary_checkout_id
|
|
360
|
+
):
|
|
361
|
+
raise PurchasePreparationError(
|
|
362
|
+
"The prepared order checkout identities must match."
|
|
363
|
+
)
|
|
364
|
+
checkout_id = quote_checkout_id
|
|
365
|
+
self._payload = payload_snapshot
|
|
366
|
+
self._payload_binding = _payload_digest(self._payload)
|
|
367
|
+
self._binding_value = _payload_digest(
|
|
368
|
+
{
|
|
369
|
+
"purchase_payload": self._payload,
|
|
370
|
+
"quote": quote_snapshot,
|
|
371
|
+
"summary": _summary_binding_data(summary),
|
|
372
|
+
}
|
|
373
|
+
)
|
|
374
|
+
self._summary = summary
|
|
375
|
+
self._attempt_key_value = _digest_text(f"woltapi-attempt-v3:{checkout_id}")
|
|
376
|
+
|
|
377
|
+
@property
|
|
378
|
+
def confirmation_summary(self) -> PurchaseConfirmationSummary:
|
|
379
|
+
return self._summary
|
|
380
|
+
|
|
381
|
+
def __repr__(self) -> str:
|
|
382
|
+
return f"PreparedOrder(checkout_id={self._summary.checkout_id!r})"
|
|
383
|
+
|
|
384
|
+
def _payload_snapshot(self) -> dict[str, Any]:
|
|
385
|
+
return deepcopy(self._payload)
|
|
386
|
+
|
|
387
|
+
def _binding(self) -> str:
|
|
388
|
+
if _payload_digest(self._payload) != self._payload_binding:
|
|
389
|
+
raise PurchaseAuthorizationError(
|
|
390
|
+
"The prepared order changed after preparation."
|
|
391
|
+
)
|
|
392
|
+
return self._binding_value
|
|
393
|
+
|
|
394
|
+
def _attempt_key(self) -> str:
|
|
395
|
+
"""Identify the retained checkout independently of mutable purchase context."""
|
|
396
|
+
|
|
397
|
+
return self._attempt_key_value
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _prepare_purchase_order(
|
|
401
|
+
selection: OrderSelection,
|
|
402
|
+
quote: QuoteSnapshot,
|
|
403
|
+
consents: PostCheckoutConfig,
|
|
404
|
+
context: PurchaseContext,
|
|
405
|
+
*,
|
|
406
|
+
enabled_payment_ids: set[str],
|
|
407
|
+
delivery_target_ids: set[str],
|
|
408
|
+
) -> PreparedOrder:
|
|
409
|
+
"""Prepare one exact payload after conservative local safety checks."""
|
|
410
|
+
|
|
411
|
+
if not isinstance(selection, OrderSelection):
|
|
412
|
+
raise TypeError("selection must be an OrderSelection instance.")
|
|
413
|
+
if not isinstance(quote, QuoteSnapshot):
|
|
414
|
+
raise TypeError("quote must be a QuoteSnapshot instance.")
|
|
415
|
+
if not isinstance(consents, PostCheckoutConfig):
|
|
416
|
+
raise TypeError("consents must be a PostCheckoutConfig instance.")
|
|
417
|
+
if not isinstance(context, PurchaseContext):
|
|
418
|
+
raise TypeError("context must be a PurchaseContext instance.")
|
|
419
|
+
current_payment_ids = _normalize_reference_ids(
|
|
420
|
+
enabled_payment_ids, "enabled payment references"
|
|
421
|
+
)
|
|
422
|
+
current_delivery_ids = _normalize_reference_ids(
|
|
423
|
+
delivery_target_ids, "saved delivery references"
|
|
424
|
+
)
|
|
425
|
+
if not quote.is_current_for(selection) or not consents.is_current_for(selection):
|
|
426
|
+
raise PurchasePreparationError(
|
|
427
|
+
"The selection, quote, and consent snapshot must match exactly."
|
|
428
|
+
)
|
|
429
|
+
if consents.required_consents:
|
|
430
|
+
raise PurchasePreparationError(
|
|
431
|
+
"Outstanding post-checkout consents block purchase."
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
plan = quote._plan_snapshot()
|
|
435
|
+
quote_response = quote._quote_snapshot()
|
|
436
|
+
selection_data = quote._selection_snapshot()
|
|
437
|
+
if plan != selection.to_checkout_payload()["purchase_plan"]:
|
|
438
|
+
raise PurchasePreparationError(
|
|
439
|
+
"The quote plan no longer matches the selection."
|
|
440
|
+
)
|
|
441
|
+
payment_method, delivery_info_id, venue = _validate_plan_scope(
|
|
442
|
+
plan,
|
|
443
|
+
enabled_payment_ids=current_payment_ids,
|
|
444
|
+
delivery_target_ids=current_delivery_ids,
|
|
445
|
+
)
|
|
446
|
+
validation, payable_amount = _validate_quote_scope(quote_response, payment_method)
|
|
447
|
+
normalized_context = _normalize_context(context)
|
|
448
|
+
purchase_items = _validate_purchase_items(
|
|
449
|
+
normalized_context["purchase_items"],
|
|
450
|
+
plan["menu_items"],
|
|
451
|
+
selection_data["items"],
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
payload = deepcopy(normalized_context["optional_fields"])
|
|
455
|
+
payload.update(
|
|
456
|
+
{
|
|
457
|
+
"client_nonce": normalized_context["client_nonce"],
|
|
458
|
+
"ravelin_device_id": normalized_context["ravelin_device_id"],
|
|
459
|
+
"signature_datetime": normalized_context["signature_datetime"],
|
|
460
|
+
"language": normalized_context["language"],
|
|
461
|
+
"currency": venue["currency"],
|
|
462
|
+
"client_pre_estimate": normalized_context["client_pre_estimate"],
|
|
463
|
+
"delivery_method": "homedelivery",
|
|
464
|
+
"items": purchase_items,
|
|
465
|
+
"type": "purchase",
|
|
466
|
+
"signature": normalized_context["signature"],
|
|
467
|
+
"pricing_model_version": normalized_context["pricing_model_version"],
|
|
468
|
+
"payment_method_type": payment_method["type"],
|
|
469
|
+
"payment_method_id": payment_method["id"],
|
|
470
|
+
"end_amount": validation["end_amount"],
|
|
471
|
+
"tip_amount": plan["courier_tip"],
|
|
472
|
+
"no_credits_or_tokens": True,
|
|
473
|
+
"device_channel": normalized_context["device_channel"],
|
|
474
|
+
"to_type": "venue",
|
|
475
|
+
"venue_id": venue["id"],
|
|
476
|
+
"browser_info": normalized_context["browser_info"],
|
|
477
|
+
"payment_links": normalized_context["payment_links"],
|
|
478
|
+
"delivery_info": {
|
|
479
|
+
"id": {"$oid": delivery_info_id},
|
|
480
|
+
**normalized_context["delivery_info_options"],
|
|
481
|
+
},
|
|
482
|
+
"delivery_price": validation["delivery_price"],
|
|
483
|
+
"additional_checkout_options": normalized_context[
|
|
484
|
+
"additional_checkout_options"
|
|
485
|
+
],
|
|
486
|
+
"discounts": [],
|
|
487
|
+
"offers": [],
|
|
488
|
+
"surcharges": [],
|
|
489
|
+
"use_token": False,
|
|
490
|
+
"price_shadowing": deepcopy(validation),
|
|
491
|
+
"menu_items_source": normalized_context["menu_items_source"],
|
|
492
|
+
"checkout_id": quote.checkout_id,
|
|
493
|
+
"use_self_service_cancellation": normalized_context[
|
|
494
|
+
"use_self_service_cancellation"
|
|
495
|
+
],
|
|
496
|
+
}
|
|
497
|
+
)
|
|
498
|
+
summary = PurchaseConfirmationSummary(
|
|
499
|
+
checkout_id=quote.checkout_id,
|
|
500
|
+
venue_id=venue["id"],
|
|
501
|
+
delivery_info_id=delivery_info_id,
|
|
502
|
+
payment_method_id=payment_method["id"],
|
|
503
|
+
courier_tip=plan["courier_tip"],
|
|
504
|
+
payable_amount=payable_amount,
|
|
505
|
+
end_amount=validation["end_amount"],
|
|
506
|
+
items=tuple(_purchase_line_summary(item) for item in purchase_items),
|
|
507
|
+
)
|
|
508
|
+
return PreparedOrder(payload, summary, quote_response)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def authorize_prepared_order(
|
|
512
|
+
prepared_order: PreparedOrder, *, confirmation_id: str
|
|
513
|
+
) -> PurchaseAuthorization:
|
|
514
|
+
"""Bind an explicit caller confirmation identifier to one prepared payload."""
|
|
515
|
+
|
|
516
|
+
if not isinstance(prepared_order, PreparedOrder):
|
|
517
|
+
raise TypeError("prepared_order must be a PreparedOrder instance.")
|
|
518
|
+
return PurchaseAuthorization._for_order(prepared_order, confirmation_id)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _submit_prepared_order(
|
|
522
|
+
transport: WoltTransport,
|
|
523
|
+
store: PurchaseAttemptStore,
|
|
524
|
+
prepared_order: PreparedOrder,
|
|
525
|
+
authorization: PurchaseAuthorization,
|
|
526
|
+
) -> PurchaseResult:
|
|
527
|
+
"""Send exactly one purchase request for a durable authorized attempt."""
|
|
528
|
+
|
|
529
|
+
if not isinstance(transport, WoltTransport):
|
|
530
|
+
raise TypeError("transport must be a WoltTransport instance.")
|
|
531
|
+
if not isinstance(store, PurchaseAttemptStore):
|
|
532
|
+
raise PurchaseAttemptStoreError(
|
|
533
|
+
"A durable PurchaseAttemptStore is required for submission."
|
|
534
|
+
)
|
|
535
|
+
if not isinstance(prepared_order, PreparedOrder):
|
|
536
|
+
raise TypeError("prepared_order must be a PreparedOrder instance.")
|
|
537
|
+
if not isinstance(
|
|
538
|
+
authorization, PurchaseAuthorization
|
|
539
|
+
) or not authorization._matches(prepared_order):
|
|
540
|
+
raise PurchaseAuthorizationError(
|
|
541
|
+
"Caller authorization does not match the prepared order."
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
attempt_key = prepared_order._attempt_key()
|
|
545
|
+
is_new, record = store.begin(attempt_key)
|
|
546
|
+
if not is_new:
|
|
547
|
+
raise DuplicatePurchaseAttempt(
|
|
548
|
+
record.attempt_id,
|
|
549
|
+
record.state,
|
|
550
|
+
record.purchase_id,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
purchase_id: str | None = None
|
|
554
|
+
try:
|
|
555
|
+
response = transport.request(
|
|
556
|
+
ServiceHost.RESTAURANT,
|
|
557
|
+
"POST",
|
|
558
|
+
"/v2/purchases",
|
|
559
|
+
json_body=prepared_order._payload_snapshot(),
|
|
560
|
+
)
|
|
561
|
+
purchase_id = _extract_purchase_id(response)
|
|
562
|
+
result = _parse_purchase_result(response)
|
|
563
|
+
store.mark_succeeded(attempt_key, result.purchase_id)
|
|
564
|
+
except Exception:
|
|
565
|
+
_mark_unknown_without_masking(store, attempt_key, purchase_id)
|
|
566
|
+
raise OrderOutcomeUnknown(record.attempt_id, purchase_id) from None
|
|
567
|
+
return result
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _validate_plan_scope(
|
|
571
|
+
plan: Mapping[str, Any],
|
|
572
|
+
*,
|
|
573
|
+
enabled_payment_ids: Collection[str],
|
|
574
|
+
delivery_target_ids: Collection[str],
|
|
575
|
+
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
|
576
|
+
if not isinstance(plan, Mapping):
|
|
577
|
+
raise PurchasePreparationError("The retained checkout plan is unavailable.")
|
|
578
|
+
venue = _copy_mapping(plan.get("venue"), "checkout venue")
|
|
579
|
+
delivery = _copy_mapping(plan.get("delivery"), "checkout delivery")
|
|
580
|
+
delivery_config = _copy_mapping(plan.get("delivery_config"), "delivery config")
|
|
581
|
+
payment_methods = plan.get("payment_methods")
|
|
582
|
+
if (
|
|
583
|
+
plan.get("delivery_method") != "homedelivery"
|
|
584
|
+
or delivery_config.get("method") != "homedelivery"
|
|
585
|
+
or delivery_config.get("schedule") != "standard"
|
|
586
|
+
or delivery_config.get("time_slot") is not None
|
|
587
|
+
or plan.get("use_cash") is not False
|
|
588
|
+
or plan.get("use_credits_and_tokens") is not False
|
|
589
|
+
or plan.get("use_loyalty_points_amount") != 0
|
|
590
|
+
or plan.get("selected_offer_ids") != []
|
|
591
|
+
or plan.get("use_promo_surcharge_ids") != []
|
|
592
|
+
or not _is_array(payment_methods)
|
|
593
|
+
or len(payment_methods) != 1
|
|
594
|
+
):
|
|
595
|
+
raise PurchasePreparationError("The checkout plan uses an unsupported flow.")
|
|
596
|
+
payment_method = _copy_mapping(payment_methods[0], "payment method")
|
|
597
|
+
payment_id = payment_method.get("id")
|
|
598
|
+
delivery_info_id = delivery.get("delivery_info_id")
|
|
599
|
+
if (
|
|
600
|
+
not _is_text(venue.get("id"))
|
|
601
|
+
or not _is_text(venue.get("currency"))
|
|
602
|
+
or not _is_text(payment_id)
|
|
603
|
+
or payment_method.get("type") != "card"
|
|
604
|
+
or payment_id not in enabled_payment_ids
|
|
605
|
+
or not _is_text(delivery_info_id)
|
|
606
|
+
or delivery_info_id not in delivery_target_ids
|
|
607
|
+
or not _is_array(plan.get("menu_items"))
|
|
608
|
+
or not plan["menu_items"]
|
|
609
|
+
):
|
|
610
|
+
raise PurchasePreparationError("The checkout plan is not currently eligible.")
|
|
611
|
+
return payment_method, delivery_info_id, venue
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _validate_quote_scope(
|
|
615
|
+
quote: Mapping[str, Any], payment_method: Mapping[str, Any]
|
|
616
|
+
) -> tuple[dict[str, Any], int | float]:
|
|
617
|
+
if not isinstance(quote, Mapping):
|
|
618
|
+
raise PurchasePreparationError("The retained quote response is unavailable.")
|
|
619
|
+
purchasing_disabled = quote.get("purchasing_disabled", _MISSING)
|
|
620
|
+
if purchasing_disabled is _MISSING or (
|
|
621
|
+
purchasing_disabled is not None and purchasing_disabled is not False
|
|
622
|
+
):
|
|
623
|
+
raise PurchasePreparationError(
|
|
624
|
+
"The quote has unresolved purchasing restrictions."
|
|
625
|
+
)
|
|
626
|
+
call_to_action = quote.get("call_to_action", _MISSING)
|
|
627
|
+
if call_to_action is not _MISSING and (
|
|
628
|
+
not isinstance(call_to_action, Mapping)
|
|
629
|
+
or call_to_action.get("enabled") is not True
|
|
630
|
+
):
|
|
631
|
+
raise PurchasePreparationError("The purchase action is unavailable.")
|
|
632
|
+
if (
|
|
633
|
+
quote.get("is_age_verification_required") is not False
|
|
634
|
+
or quote.get("use_address_matching_for_age_verification") is not False
|
|
635
|
+
):
|
|
636
|
+
raise PurchasePreparationError("Age-verification flows are unsupported.")
|
|
637
|
+
payable_amount = quote.get("payable_amount")
|
|
638
|
+
validation = quote.get("purchase_validation")
|
|
639
|
+
breakdown = quote.get("payment_breakdown")
|
|
640
|
+
if (
|
|
641
|
+
not _is_number(payable_amount)
|
|
642
|
+
or not isinstance(validation, Mapping)
|
|
643
|
+
or not isinstance(breakdown, Mapping)
|
|
644
|
+
):
|
|
645
|
+
raise PurchasePreparationError(
|
|
646
|
+
"The quote is incomplete for purchase preparation."
|
|
647
|
+
)
|
|
648
|
+
validation_copy = deepcopy(dict(validation))
|
|
649
|
+
if not _is_integer(validation_copy.get("end_amount")) or not _is_integer(
|
|
650
|
+
validation_copy.get("delivery_price")
|
|
651
|
+
):
|
|
652
|
+
raise PurchasePreparationError("The quote validation is incomplete.")
|
|
653
|
+
total = _copy_mapping(breakdown.get("total"), "payment total")
|
|
654
|
+
unallocated = _copy_mapping(breakdown.get("unallocated"), "payment allocation")
|
|
655
|
+
if (
|
|
656
|
+
not _is_number(total.get("amount"))
|
|
657
|
+
or not _is_number(unallocated.get("amount"))
|
|
658
|
+
or total.get("amount") != payable_amount
|
|
659
|
+
or unallocated.get("amount") != 0
|
|
660
|
+
):
|
|
661
|
+
raise PurchasePreparationError("The quote payment allocation is incomplete.")
|
|
662
|
+
_validate_single_card_allocation(
|
|
663
|
+
breakdown.get("parts"), payment_method, payable_amount
|
|
664
|
+
)
|
|
665
|
+
if (
|
|
666
|
+
validation_copy.get("credits_amount", _MISSING) is not None
|
|
667
|
+
or validation_copy.get("use_token", _MISSING) is not None
|
|
668
|
+
or validation_copy.get("wolt_loyalty_currency_amount_converted", _MISSING)
|
|
669
|
+
is not None
|
|
670
|
+
or validation_copy.get("discounts") != []
|
|
671
|
+
or validation_copy.get("offers") != []
|
|
672
|
+
or validation_copy.get("surcharges") != []
|
|
673
|
+
):
|
|
674
|
+
raise PurchasePreparationError("The quote uses an unsupported payment flow.")
|
|
675
|
+
return validation_copy, payable_amount
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _validate_single_card_allocation(
|
|
679
|
+
parts: Any, payment_method: Mapping[str, Any], payable_amount: int | float
|
|
680
|
+
) -> None:
|
|
681
|
+
if not _is_array(parts) or len(parts) != 1:
|
|
682
|
+
raise PurchasePreparationError("The quote payment allocation is incomplete.")
|
|
683
|
+
part = _copy_mapping(parts[0], "payment allocation")
|
|
684
|
+
amount = _copy_mapping(part.get("amount"), "payment allocation amount")
|
|
685
|
+
method = _copy_mapping(part.get("payment_method"), "payment allocation method")
|
|
686
|
+
if (
|
|
687
|
+
not _is_number(amount.get("amount"))
|
|
688
|
+
or amount.get("amount") != payable_amount
|
|
689
|
+
or method.get("id") != payment_method.get("id")
|
|
690
|
+
or method.get("type") != payment_method.get("type")
|
|
691
|
+
):
|
|
692
|
+
raise PurchasePreparationError("The quote payment allocation is incomplete.")
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _normalize_context(context: PurchaseContext) -> dict[str, Any]:
|
|
696
|
+
if (
|
|
697
|
+
not _is_text(context.client_nonce)
|
|
698
|
+
or not _is_text(context.ravelin_device_id)
|
|
699
|
+
or not _is_text(context.language)
|
|
700
|
+
or not isinstance(context.client_pre_estimate, str)
|
|
701
|
+
or not _is_text(context.signature)
|
|
702
|
+
or not _is_integer(context.pricing_model_version)
|
|
703
|
+
or not _is_text(context.device_channel)
|
|
704
|
+
or not _is_text(context.menu_items_source)
|
|
705
|
+
or not isinstance(context.use_self_service_cancellation, bool)
|
|
706
|
+
):
|
|
707
|
+
raise PurchasePreparationError(
|
|
708
|
+
"Required caller-supplied purchase inputs are absent."
|
|
709
|
+
)
|
|
710
|
+
signature_datetime = _copy_mapping(context.signature_datetime, "signature_datetime")
|
|
711
|
+
if not _is_integer(signature_datetime.get("$date")):
|
|
712
|
+
raise PurchasePreparationError("signature_datetime must be caller supplied.")
|
|
713
|
+
browser_info = _copy_mapping(context.browser_info, "browser_info")
|
|
714
|
+
payment_links = _copy_mapping(context.payment_links, "payment_links")
|
|
715
|
+
delivery_info_options = _copy_mapping(
|
|
716
|
+
context.delivery_info_options, "delivery_info_options"
|
|
717
|
+
)
|
|
718
|
+
additional_checkout_options = _copy_mapping(
|
|
719
|
+
context.additional_checkout_options, "additional_checkout_options"
|
|
720
|
+
)
|
|
721
|
+
if (
|
|
722
|
+
set(delivery_info_options) != {"use_last_100m_address_picker"}
|
|
723
|
+
or not isinstance(delivery_info_options["use_last_100m_address_picker"], bool)
|
|
724
|
+
or set(additional_checkout_options) != {"no_contact_delivery"}
|
|
725
|
+
or not isinstance(additional_checkout_options["no_contact_delivery"], bool)
|
|
726
|
+
):
|
|
727
|
+
raise PurchasePreparationError("Caller checkout options are unsupported.")
|
|
728
|
+
optional_fields = _copy_mapping(context.optional_fields, "optional_fields")
|
|
729
|
+
if set(optional_fields).difference({"consumer_comment", "corporate_order_comment"}):
|
|
730
|
+
raise PurchasePreparationError(
|
|
731
|
+
"Unsupported optional purchase fields were supplied."
|
|
732
|
+
)
|
|
733
|
+
if not _is_array(context.purchase_items) or not context.purchase_items:
|
|
734
|
+
raise PurchasePreparationError(
|
|
735
|
+
"Exact caller-supplied purchase items are required."
|
|
736
|
+
)
|
|
737
|
+
return {
|
|
738
|
+
"client_nonce": context.client_nonce,
|
|
739
|
+
"ravelin_device_id": context.ravelin_device_id,
|
|
740
|
+
"signature_datetime": signature_datetime,
|
|
741
|
+
"language": context.language,
|
|
742
|
+
"client_pre_estimate": context.client_pre_estimate,
|
|
743
|
+
"signature": context.signature,
|
|
744
|
+
"pricing_model_version": context.pricing_model_version,
|
|
745
|
+
"device_channel": context.device_channel,
|
|
746
|
+
"browser_info": browser_info,
|
|
747
|
+
"payment_links": payment_links,
|
|
748
|
+
"delivery_info_options": delivery_info_options,
|
|
749
|
+
"additional_checkout_options": additional_checkout_options,
|
|
750
|
+
"menu_items_source": context.menu_items_source,
|
|
751
|
+
"use_self_service_cancellation": context.use_self_service_cancellation,
|
|
752
|
+
"purchase_items": list(context.purchase_items),
|
|
753
|
+
"optional_fields": optional_fields,
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _validate_purchase_items(
|
|
758
|
+
supplied_items: Sequence[Mapping[str, Any]],
|
|
759
|
+
plan_items: Sequence[Mapping[str, Any]],
|
|
760
|
+
selected_items: Sequence[Mapping[str, Any]],
|
|
761
|
+
) -> list[dict[str, Any]]:
|
|
762
|
+
if len(supplied_items) != len(plan_items) or len(plan_items) != len(selected_items):
|
|
763
|
+
raise PurchasePreparationError(
|
|
764
|
+
"Purchase items do not match the retained selection."
|
|
765
|
+
)
|
|
766
|
+
validated: list[dict[str, Any]] = []
|
|
767
|
+
for supplied, plan_item, selected_item in zip(
|
|
768
|
+
supplied_items, plan_items, selected_items, strict=True
|
|
769
|
+
):
|
|
770
|
+
item = _copy_mapping(supplied, "purchase item")
|
|
771
|
+
_validate_purchase_item(item, plan_item, selected_item)
|
|
772
|
+
validated.append(item)
|
|
773
|
+
return validated
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _validate_purchase_item(
|
|
777
|
+
item: Mapping[str, Any],
|
|
778
|
+
plan_item: Mapping[str, Any],
|
|
779
|
+
selected_item: Mapping[str, Any],
|
|
780
|
+
) -> None:
|
|
781
|
+
required = {
|
|
782
|
+
"id",
|
|
783
|
+
"configIndex",
|
|
784
|
+
"count",
|
|
785
|
+
"name",
|
|
786
|
+
"exclude_from_credits",
|
|
787
|
+
"exclude_from_discounts_min_basket",
|
|
788
|
+
"from_recommendation",
|
|
789
|
+
"alcohol_percentage",
|
|
790
|
+
"product_hierarchy_tags",
|
|
791
|
+
"vat_percentage",
|
|
792
|
+
"vat_percentage_decimal",
|
|
793
|
+
"baseprice",
|
|
794
|
+
"end_amount",
|
|
795
|
+
"restrictions",
|
|
796
|
+
"options",
|
|
797
|
+
"checksum",
|
|
798
|
+
}
|
|
799
|
+
if (
|
|
800
|
+
required.difference(item)
|
|
801
|
+
or {"category", "exclude_from_discounts"}.intersection(item)
|
|
802
|
+
or item.get("id") != plan_item.get("id")
|
|
803
|
+
or not _is_integer(item.get("count"))
|
|
804
|
+
or item.get("count") != plan_item.get("count")
|
|
805
|
+
or not _is_integer(item.get("baseprice"))
|
|
806
|
+
or item.get("baseprice") != plan_item.get("base_price")
|
|
807
|
+
or not _is_integer(item.get("end_amount"))
|
|
808
|
+
or item.get("end_amount") != plan_item.get("end_amount")
|
|
809
|
+
or item.get("checksum") != selected_item.get("checksum")
|
|
810
|
+
or item.get("restrictions") != plan_item.get("restrictions")
|
|
811
|
+
or item.get("exclude_from_credits") != plan_item.get("exclude_from_credits")
|
|
812
|
+
or item.get("exclude_from_discounts_min_basket")
|
|
813
|
+
!= plan_item.get("exclude_from_discounts_min_basket")
|
|
814
|
+
or not isinstance(item.get("exclude_from_credits"), bool)
|
|
815
|
+
or not isinstance(item.get("exclude_from_discounts_min_basket"), bool)
|
|
816
|
+
or not _is_integer(item.get("configIndex"))
|
|
817
|
+
or not _is_array(item.get("name"))
|
|
818
|
+
or not _is_array(item.get("product_hierarchy_tags"))
|
|
819
|
+
or item.get("from_recommendation") is not False
|
|
820
|
+
or not _is_number(item.get("alcohol_percentage"))
|
|
821
|
+
or item.get("alcohol_percentage") != 0
|
|
822
|
+
or not _is_array(item.get("restrictions"))
|
|
823
|
+
or item.get("restrictions")
|
|
824
|
+
):
|
|
825
|
+
raise PurchasePreparationError(
|
|
826
|
+
"Caller purchase items do not match the safe scope."
|
|
827
|
+
)
|
|
828
|
+
post_fields = selected_item.get("post_checkout_fields")
|
|
829
|
+
if (
|
|
830
|
+
not isinstance(post_fields, Mapping)
|
|
831
|
+
or item["configIndex"] != post_fields.get("configIndex")
|
|
832
|
+
or item["name"] != post_fields.get("name")
|
|
833
|
+
or item["from_recommendation"] != post_fields.get("from_recommendation")
|
|
834
|
+
or item["alcohol_percentage"] != post_fields.get("alcohol_percentage")
|
|
835
|
+
or item["product_hierarchy_tags"] != post_fields.get("product_hierarchy_tags")
|
|
836
|
+
or item["vat_percentage"] != post_fields.get("vat_percentage")
|
|
837
|
+
or item["vat_percentage_decimal"] != post_fields.get("vat_percentage_decimal")
|
|
838
|
+
):
|
|
839
|
+
raise PurchasePreparationError(
|
|
840
|
+
"Purchase item fields do not match the selection."
|
|
841
|
+
)
|
|
842
|
+
_validate_purchase_options(
|
|
843
|
+
item["options"], plan_item.get("options"), selected_item.get("options")
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _validate_purchase_options(
|
|
848
|
+
supplied_options: Any,
|
|
849
|
+
plan_options: Any,
|
|
850
|
+
selected_options: Any,
|
|
851
|
+
) -> None:
|
|
852
|
+
if (
|
|
853
|
+
not _is_array(supplied_options)
|
|
854
|
+
or not _is_array(plan_options)
|
|
855
|
+
or not _is_array(selected_options)
|
|
856
|
+
or len(supplied_options) != len(plan_options)
|
|
857
|
+
or len(plan_options) != len(selected_options)
|
|
858
|
+
):
|
|
859
|
+
raise PurchasePreparationError(
|
|
860
|
+
"Purchase option fields do not match the selection."
|
|
861
|
+
)
|
|
862
|
+
for supplied, plan_option, selected_option in zip(
|
|
863
|
+
supplied_options, plan_options, selected_options, strict=True
|
|
864
|
+
):
|
|
865
|
+
option = _copy_mapping(supplied, "purchase option")
|
|
866
|
+
if (
|
|
867
|
+
set(option) != {"type", "id", "name", "values"}
|
|
868
|
+
or option.get("id") != plan_option.get("id")
|
|
869
|
+
or not _is_text(option.get("type"))
|
|
870
|
+
or not _is_array(option.get("name"))
|
|
871
|
+
or not _is_array(option.get("values"))
|
|
872
|
+
):
|
|
873
|
+
raise PurchasePreparationError(
|
|
874
|
+
"Purchase option fields do not match the selection."
|
|
875
|
+
)
|
|
876
|
+
post_fields = selected_option.get("post_checkout_fields")
|
|
877
|
+
if (
|
|
878
|
+
not isinstance(post_fields, Mapping)
|
|
879
|
+
or option["type"] != post_fields.get("type")
|
|
880
|
+
or option["name"] != post_fields.get("name")
|
|
881
|
+
):
|
|
882
|
+
raise PurchasePreparationError(
|
|
883
|
+
"Purchase option fields do not match the selection."
|
|
884
|
+
)
|
|
885
|
+
_validate_purchase_option_values(option["values"], plan_option.get("values"))
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _validate_purchase_option_values(supplied_values: Any, plan_values: Any) -> None:
|
|
889
|
+
if (
|
|
890
|
+
not _is_array(supplied_values)
|
|
891
|
+
or not _is_array(plan_values)
|
|
892
|
+
or len(supplied_values) != len(plan_values)
|
|
893
|
+
):
|
|
894
|
+
raise PurchasePreparationError(
|
|
895
|
+
"Purchase option values do not match the checkout plan."
|
|
896
|
+
)
|
|
897
|
+
for supplied, plan_value in zip(supplied_values, plan_values, strict=True):
|
|
898
|
+
value = _copy_mapping(supplied, "purchase option value")
|
|
899
|
+
if (
|
|
900
|
+
set(value) != {"count", "name", "price", "id"}
|
|
901
|
+
or value.get("id") != plan_value.get("id")
|
|
902
|
+
or not _is_integer(value.get("count"))
|
|
903
|
+
or value.get("count") != plan_value.get("count")
|
|
904
|
+
or not _is_integer(value.get("price"))
|
|
905
|
+
or value.get("price") != plan_value.get("price")
|
|
906
|
+
or not _is_array(value.get("name"))
|
|
907
|
+
):
|
|
908
|
+
raise PurchasePreparationError(
|
|
909
|
+
"Purchase option values do not match the checkout plan."
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _purchase_line_summary(item: Mapping[str, Any]) -> PurchaseLineSummary:
|
|
914
|
+
return PurchaseLineSummary(
|
|
915
|
+
item_id=item["id"],
|
|
916
|
+
count=item["count"],
|
|
917
|
+
names=_purchase_name_summaries(item["name"], "purchase item names"),
|
|
918
|
+
options=tuple(_purchase_option_summary(option) for option in item["options"]),
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _purchase_option_summary(option: Mapping[str, Any]) -> PurchaseOptionSummary:
|
|
923
|
+
return PurchaseOptionSummary(
|
|
924
|
+
option_id=option["id"],
|
|
925
|
+
names=_purchase_name_summaries(option["name"], "purchase option names"),
|
|
926
|
+
values=tuple(
|
|
927
|
+
PurchaseOptionValueSummary(
|
|
928
|
+
value_id=value["id"],
|
|
929
|
+
count=value["count"],
|
|
930
|
+
names=_purchase_name_summaries(
|
|
931
|
+
value["name"], "purchase option value names"
|
|
932
|
+
),
|
|
933
|
+
)
|
|
934
|
+
for value in option["values"]
|
|
935
|
+
),
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _purchase_name_summaries(names: Any, label: str) -> tuple[PurchaseNameSummary, ...]:
|
|
940
|
+
if not _is_array(names) or not names:
|
|
941
|
+
raise PurchasePreparationError(f"{label} are incomplete.")
|
|
942
|
+
summaries: list[PurchaseNameSummary] = []
|
|
943
|
+
for record in names:
|
|
944
|
+
name = _copy_mapping(record, label)
|
|
945
|
+
value = name.get("value")
|
|
946
|
+
language = name.get("lang")
|
|
947
|
+
if not _is_text(value) or not _is_text(language):
|
|
948
|
+
raise PurchasePreparationError(f"{label} are incomplete.")
|
|
949
|
+
summaries.append(PurchaseNameSummary(value=value, language=language))
|
|
950
|
+
return tuple(summaries)
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _summary_binding_data(summary: PurchaseConfirmationSummary) -> dict[str, Any]:
|
|
954
|
+
return {
|
|
955
|
+
"checkout_id": summary.checkout_id,
|
|
956
|
+
"venue_id": summary.venue_id,
|
|
957
|
+
"delivery_info_id": summary.delivery_info_id,
|
|
958
|
+
"payment_method_id": summary.payment_method_id,
|
|
959
|
+
"courier_tip": summary.courier_tip,
|
|
960
|
+
"payable_amount": summary.payable_amount,
|
|
961
|
+
"end_amount": summary.end_amount,
|
|
962
|
+
"items": [_line_summary_binding_data(item) for item in summary.items],
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _line_summary_binding_data(item: PurchaseLineSummary) -> dict[str, Any]:
|
|
967
|
+
return {
|
|
968
|
+
"item_id": item.item_id,
|
|
969
|
+
"count": item.count,
|
|
970
|
+
"names": [_name_summary_binding_data(name) for name in item.names],
|
|
971
|
+
"options": [_option_summary_binding_data(option) for option in item.options],
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _option_summary_binding_data(option: PurchaseOptionSummary) -> dict[str, Any]:
|
|
976
|
+
return {
|
|
977
|
+
"option_id": option.option_id,
|
|
978
|
+
"names": [_name_summary_binding_data(name) for name in option.names],
|
|
979
|
+
"values": [
|
|
980
|
+
{
|
|
981
|
+
"value_id": value.value_id,
|
|
982
|
+
"count": value.count,
|
|
983
|
+
"names": [_name_summary_binding_data(name) for name in value.names],
|
|
984
|
+
}
|
|
985
|
+
for value in option.values
|
|
986
|
+
],
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _name_summary_binding_data(name: PurchaseNameSummary) -> dict[str, str]:
|
|
991
|
+
return {"value": name.value, "language": name.language}
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _parse_purchase_result(response: Mapping[str, Any]) -> PurchaseResult:
|
|
995
|
+
results = response.get("results")
|
|
996
|
+
if not isinstance(results, Mapping):
|
|
997
|
+
raise PurchasePreparationError(
|
|
998
|
+
"Purchase response did not establish an order ID."
|
|
999
|
+
)
|
|
1000
|
+
purchase_id = _extract_purchase_id(response)
|
|
1001
|
+
fields = (
|
|
1002
|
+
purchase_id,
|
|
1003
|
+
results.get("status"),
|
|
1004
|
+
results.get("currency"),
|
|
1005
|
+
results.get("amount"),
|
|
1006
|
+
results.get("delivery_method"),
|
|
1007
|
+
results.get("delivery_price"),
|
|
1008
|
+
results.get("payment_method_type"),
|
|
1009
|
+
)
|
|
1010
|
+
if (
|
|
1011
|
+
not _is_text(fields[0])
|
|
1012
|
+
or fields[1] != "received"
|
|
1013
|
+
or not _is_text(fields[2])
|
|
1014
|
+
or not _is_number(fields[3])
|
|
1015
|
+
or fields[4] != "homedelivery"
|
|
1016
|
+
or not _is_number(fields[5])
|
|
1017
|
+
or fields[6] != "card"
|
|
1018
|
+
):
|
|
1019
|
+
raise PurchasePreparationError(
|
|
1020
|
+
"Purchase response did not establish an order ID."
|
|
1021
|
+
)
|
|
1022
|
+
return PurchaseResult(
|
|
1023
|
+
purchase_id=fields[0],
|
|
1024
|
+
status=fields[1],
|
|
1025
|
+
currency=fields[2],
|
|
1026
|
+
amount=fields[3],
|
|
1027
|
+
delivery_method=fields[4],
|
|
1028
|
+
delivery_price=fields[5],
|
|
1029
|
+
payment_method_type=fields[6],
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _extract_purchase_id(response: Mapping[str, Any]) -> str | None:
|
|
1034
|
+
results = response.get("results")
|
|
1035
|
+
if not isinstance(results, Mapping):
|
|
1036
|
+
return None
|
|
1037
|
+
identifier = results.get("id")
|
|
1038
|
+
if not isinstance(identifier, Mapping):
|
|
1039
|
+
return None
|
|
1040
|
+
purchase_id = identifier.get("$oid")
|
|
1041
|
+
return purchase_id if _is_text(purchase_id) else None
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _mark_unknown_without_masking(
|
|
1045
|
+
store: PurchaseAttemptStore, attempt_key: str, purchase_id: str | None
|
|
1046
|
+
) -> None:
|
|
1047
|
+
try:
|
|
1048
|
+
store.mark_unknown(attempt_key, purchase_id)
|
|
1049
|
+
except PurchaseAttemptStoreError:
|
|
1050
|
+
pass
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def _payload_digest(payload: Mapping[str, Any]) -> str:
|
|
1054
|
+
try:
|
|
1055
|
+
encoded = json.dumps(
|
|
1056
|
+
payload,
|
|
1057
|
+
allow_nan=False,
|
|
1058
|
+
ensure_ascii=False,
|
|
1059
|
+
separators=(",", ":"),
|
|
1060
|
+
sort_keys=True,
|
|
1061
|
+
).encode("utf-8")
|
|
1062
|
+
except (TypeError, ValueError, UnicodeEncodeError):
|
|
1063
|
+
raise PurchasePreparationError(
|
|
1064
|
+
"Caller purchase inputs cannot be safely serialized."
|
|
1065
|
+
) from None
|
|
1066
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _digest_text(value: str) -> str:
|
|
1070
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _copy_mapping(value: Any, label: str) -> dict[str, Any]:
|
|
1074
|
+
if not isinstance(value, Mapping):
|
|
1075
|
+
raise PurchasePreparationError(f"{label} must be caller supplied.")
|
|
1076
|
+
return deepcopy(dict(value))
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _is_array(value: Any) -> bool:
|
|
1080
|
+
return isinstance(value, Sequence) and not isinstance(value, (str, bytes))
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _is_text(value: Any) -> bool:
|
|
1084
|
+
return isinstance(value, str) and bool(value.strip())
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _is_integer(value: Any) -> bool:
|
|
1088
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _is_number(value: Any) -> bool:
|
|
1092
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _normalize_reference_ids(value: Any, label: str) -> frozenset[str]:
|
|
1096
|
+
if not isinstance(value, (set, frozenset)) or not all(
|
|
1097
|
+
_is_text(item) for item in value
|
|
1098
|
+
):
|
|
1099
|
+
raise PurchasePreparationError(f"Current {label} are required.")
|
|
1100
|
+
return frozenset(value)
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
_MISSING = object()
|