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/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()