provecart 0.1.0__tar.gz
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.
- provecart-0.1.0/PKG-INFO +94 -0
- provecart-0.1.0/README.md +79 -0
- provecart-0.1.0/pyproject.toml +26 -0
- provecart-0.1.0/setup.cfg +4 -0
- provecart-0.1.0/src/provecart/__init__.py +21 -0
- provecart-0.1.0/src/provecart/client.py +286 -0
- provecart-0.1.0/src/provecart.egg-info/PKG-INFO +94 -0
- provecart-0.1.0/src/provecart.egg-info/SOURCES.txt +9 -0
- provecart-0.1.0/src/provecart.egg-info/dependency_links.txt +1 -0
- provecart-0.1.0/src/provecart.egg-info/top_level.txt +1 -0
- provecart-0.1.0/tests/test_client.py +259 -0
provecart-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: provecart
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ProveCart client: check an AI agent's cart, booking, or reservation against the buyer's signed instructions before payment is captured.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://provecart.com
|
|
7
|
+
Project-URL: Documentation, https://provecart.com/agents
|
|
8
|
+
Project-URL: Correction format, https://provecart.com/standards/correction-format
|
|
9
|
+
Keywords: ai-agents,agent-commerce,verification,payments,provecart
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# provecart
|
|
17
|
+
|
|
18
|
+
Your agent put the wrong variant in the cart — the wrong size, the wrong model, a quantity nobody asked for — and your user paid for it. By the time anyone noticed, the order had shipped, and the return came out of somebody's pocket. That is the failure this package exists to stop: one check, between what the agent built and the payment, that compares the transaction against what the buyer actually said.
|
|
19
|
+
|
|
20
|
+
ProveCart is a neutral verification API for agent commerce. You record the buyer's instruction as a signed mandate, send what the agent actually built, and get back a signed verdict — `MATCH`, `REVIEW`, or `BLOCK` — before payment is captured (or, for bookings, before a ticket is issued). A `BLOCK` or `REVIEW` answer carries machine-readable corrections, so the agent can fix what it built and resubmit instead of losing the sale or guessing. Every check is written to a sealed evidence record. ProveCart never holds or moves money.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install provecart
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Standard library only. No dependencies.
|
|
27
|
+
|
|
28
|
+
## Check a cart
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from provecart import ProveCartClient, route_verdict
|
|
32
|
+
client = ProveCartClient(api_key="YOUR_API_KEY")
|
|
33
|
+
verdict = client.check_cart(merchant={"name": "Example Shop", "domain": "shop.example.com"}, items=[{"name": "Wireless Headphones", "variant": "black", "quantity": 1, "unit_price": 249.99}], total_price=249.99, mandate={"user_id": "user-123", "item_description": "Wireless noise cancelling over-ear headphones", "item_keywords": ["wireless", "headphones"], "category": "electronics", "max_total_price": 300.00, "currency": "USD", "max_quantity": 1, "expires_at": "2027-01-01T00:00:00Z"})
|
|
34
|
+
decision, detail = route_verdict(verdict) # proceed / fix / escalate
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Already holding a mandate id? Pass `mandate_id=` instead of `mandate=` — `check_cart` takes either. The lower-level pair is also there when you want the steps separate: `create_mandate(**fields)` returns the signed mandate, and `verify_cart(mandate_id, merchant, items, total_price, ...)` runs the check.
|
|
38
|
+
|
|
39
|
+
## Check a booking
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from provecart import ProveCartClient, route_verdict
|
|
43
|
+
client = ProveCartClient(api_key="YOUR_API_KEY")
|
|
44
|
+
verdict = client.check_booking(merchant={"name": "Example Air", "domain": "air.example.com"}, price={"total": 412.00}, travelers=["Alex Rivera"], legs=[{"from_airport": "SFO", "to_airport": "JFK", "depart_at": "2027-03-10T08:00:00Z", "arrive_at": "2027-03-10T16:30:00Z"}], mandate={"user_id": "user-123", "travelers": [{"name": "Alex Rivera"}], "origin": {"airport": "SFO"}, "destination": {"airport": "JFK"}, "departure_date": "2027-03-10", "max_total_price": 500.00, "currency": "USD", "expires_at": "2027-03-01T00:00:00Z"})
|
|
45
|
+
decision, detail = route_verdict(verdict) # gate both capture and ticket issuance on MATCH
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The separate steps are `create_booking_mandate(**fields)` and `verify_booking(mandate_id, merchant, price, travelers=..., legs=..., stays=..., fare=...)`.
|
|
49
|
+
|
|
50
|
+
## Check a reservation
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from provecart import ProveCartClient, route_verdict
|
|
54
|
+
client = ProveCartClient(api_key="YOUR_API_KEY")
|
|
55
|
+
verdict = client.check_reservation(merchant={"name": "Example Table", "domain": "table.example.com"}, date="2027-03-10", time="19:00", party_size=2, name_on_reservation="Alex Rivera", total_amount=40.00, mandate={"user_id": "user-123", "service_description": "Dinner reservation for two", "date": "2027-03-10", "time": "19:00", "party_size": 2, "name_on_reservation": "Alex Rivera", "max_total_price": 60.00, "currency": "USD", "expires_at": "2027-03-09T00:00:00Z"})
|
|
56
|
+
decision, detail = route_verdict(verdict) # proceed / fix / escalate
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The separate steps are `create_reservation_mandate(**fields)` and `verify_reservation(mandate_id, merchant, date, time, party_size, name_on_reservation, total_amount, ...)`.
|
|
60
|
+
|
|
61
|
+
## What to do with the verdict
|
|
62
|
+
|
|
63
|
+
`route_verdict` encodes the consumer rules of the Agent Correction Format, and they are deliberately strict:
|
|
64
|
+
|
|
65
|
+
- **MATCH → proceed.** Capture the payment (or issue the ticket).
|
|
66
|
+
- **BLOCK → fix, narrowly.** Fix only when every correction in the response is a known action that brings the transaction back inside the buyer's existing authorization. Apply exactly those corrections, then resubmit as a new check carrying the earlier check's `record_id` as `previous_record_id`, within the response's `max_attempts` budget.
|
|
67
|
+
- **REVIEW, unknown verdict, unknown action → escalate.** Stop and ask the buyer. Never guess, and never widen the buyer's authorization to make a verdict go away.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
decision, detail = route_verdict(first_verdict)
|
|
71
|
+
if decision == "fix":
|
|
72
|
+
# ... apply detail["corrections"] to the transaction, nothing else ...
|
|
73
|
+
second = client.check_cart(..., mandate_id=same_id, previous_record_id=detail["record_id"])
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Offline and mock runs
|
|
77
|
+
|
|
78
|
+
The client takes an optional `transport` callable, `(path, body) -> (status, dict)`, that replaces the HTTP layer entirely. Point it at canned verdicts and the full mandate → check → route → resubmit loop runs with no network and no API key traffic — that is how this package's own test suite works, and it is the recommended way to rehearse an integration before wiring a real key:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
def stub(path, body):
|
|
82
|
+
return 200, {"id": "m-1"} if "mandates" in path else {"verdict": "MATCH", "record_id": "r-1"}
|
|
83
|
+
client = ProveCartClient(api_key="test", transport=stub)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Calls made with a sandbox key against the live API behave exactly as in production and stay sealed under that key, separate from live data.
|
|
87
|
+
|
|
88
|
+
## Links
|
|
89
|
+
|
|
90
|
+
- Agents and certification: https://provecart.com/agents
|
|
91
|
+
- Correction format standard: https://provecart.com/standards/correction-format
|
|
92
|
+
- API reference: https://api.provecart.com/docs
|
|
93
|
+
|
|
94
|
+
ProveCart. Prove the cart. Patent pending.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# provecart
|
|
2
|
+
|
|
3
|
+
Your agent put the wrong variant in the cart — the wrong size, the wrong model, a quantity nobody asked for — and your user paid for it. By the time anyone noticed, the order had shipped, and the return came out of somebody's pocket. That is the failure this package exists to stop: one check, between what the agent built and the payment, that compares the transaction against what the buyer actually said.
|
|
4
|
+
|
|
5
|
+
ProveCart is a neutral verification API for agent commerce. You record the buyer's instruction as a signed mandate, send what the agent actually built, and get back a signed verdict — `MATCH`, `REVIEW`, or `BLOCK` — before payment is captured (or, for bookings, before a ticket is issued). A `BLOCK` or `REVIEW` answer carries machine-readable corrections, so the agent can fix what it built and resubmit instead of losing the sale or guessing. Every check is written to a sealed evidence record. ProveCart never holds or moves money.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install provecart
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Standard library only. No dependencies.
|
|
12
|
+
|
|
13
|
+
## Check a cart
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from provecart import ProveCartClient, route_verdict
|
|
17
|
+
client = ProveCartClient(api_key="YOUR_API_KEY")
|
|
18
|
+
verdict = client.check_cart(merchant={"name": "Example Shop", "domain": "shop.example.com"}, items=[{"name": "Wireless Headphones", "variant": "black", "quantity": 1, "unit_price": 249.99}], total_price=249.99, mandate={"user_id": "user-123", "item_description": "Wireless noise cancelling over-ear headphones", "item_keywords": ["wireless", "headphones"], "category": "electronics", "max_total_price": 300.00, "currency": "USD", "max_quantity": 1, "expires_at": "2027-01-01T00:00:00Z"})
|
|
19
|
+
decision, detail = route_verdict(verdict) # proceed / fix / escalate
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Already holding a mandate id? Pass `mandate_id=` instead of `mandate=` — `check_cart` takes either. The lower-level pair is also there when you want the steps separate: `create_mandate(**fields)` returns the signed mandate, and `verify_cart(mandate_id, merchant, items, total_price, ...)` runs the check.
|
|
23
|
+
|
|
24
|
+
## Check a booking
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from provecart import ProveCartClient, route_verdict
|
|
28
|
+
client = ProveCartClient(api_key="YOUR_API_KEY")
|
|
29
|
+
verdict = client.check_booking(merchant={"name": "Example Air", "domain": "air.example.com"}, price={"total": 412.00}, travelers=["Alex Rivera"], legs=[{"from_airport": "SFO", "to_airport": "JFK", "depart_at": "2027-03-10T08:00:00Z", "arrive_at": "2027-03-10T16:30:00Z"}], mandate={"user_id": "user-123", "travelers": [{"name": "Alex Rivera"}], "origin": {"airport": "SFO"}, "destination": {"airport": "JFK"}, "departure_date": "2027-03-10", "max_total_price": 500.00, "currency": "USD", "expires_at": "2027-03-01T00:00:00Z"})
|
|
30
|
+
decision, detail = route_verdict(verdict) # gate both capture and ticket issuance on MATCH
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The separate steps are `create_booking_mandate(**fields)` and `verify_booking(mandate_id, merchant, price, travelers=..., legs=..., stays=..., fare=...)`.
|
|
34
|
+
|
|
35
|
+
## Check a reservation
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from provecart import ProveCartClient, route_verdict
|
|
39
|
+
client = ProveCartClient(api_key="YOUR_API_KEY")
|
|
40
|
+
verdict = client.check_reservation(merchant={"name": "Example Table", "domain": "table.example.com"}, date="2027-03-10", time="19:00", party_size=2, name_on_reservation="Alex Rivera", total_amount=40.00, mandate={"user_id": "user-123", "service_description": "Dinner reservation for two", "date": "2027-03-10", "time": "19:00", "party_size": 2, "name_on_reservation": "Alex Rivera", "max_total_price": 60.00, "currency": "USD", "expires_at": "2027-03-09T00:00:00Z"})
|
|
41
|
+
decision, detail = route_verdict(verdict) # proceed / fix / escalate
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The separate steps are `create_reservation_mandate(**fields)` and `verify_reservation(mandate_id, merchant, date, time, party_size, name_on_reservation, total_amount, ...)`.
|
|
45
|
+
|
|
46
|
+
## What to do with the verdict
|
|
47
|
+
|
|
48
|
+
`route_verdict` encodes the consumer rules of the Agent Correction Format, and they are deliberately strict:
|
|
49
|
+
|
|
50
|
+
- **MATCH → proceed.** Capture the payment (or issue the ticket).
|
|
51
|
+
- **BLOCK → fix, narrowly.** Fix only when every correction in the response is a known action that brings the transaction back inside the buyer's existing authorization. Apply exactly those corrections, then resubmit as a new check carrying the earlier check's `record_id` as `previous_record_id`, within the response's `max_attempts` budget.
|
|
52
|
+
- **REVIEW, unknown verdict, unknown action → escalate.** Stop and ask the buyer. Never guess, and never widen the buyer's authorization to make a verdict go away.
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
decision, detail = route_verdict(first_verdict)
|
|
56
|
+
if decision == "fix":
|
|
57
|
+
# ... apply detail["corrections"] to the transaction, nothing else ...
|
|
58
|
+
second = client.check_cart(..., mandate_id=same_id, previous_record_id=detail["record_id"])
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Offline and mock runs
|
|
62
|
+
|
|
63
|
+
The client takes an optional `transport` callable, `(path, body) -> (status, dict)`, that replaces the HTTP layer entirely. Point it at canned verdicts and the full mandate → check → route → resubmit loop runs with no network and no API key traffic — that is how this package's own test suite works, and it is the recommended way to rehearse an integration before wiring a real key:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
def stub(path, body):
|
|
67
|
+
return 200, {"id": "m-1"} if "mandates" in path else {"verdict": "MATCH", "record_id": "r-1"}
|
|
68
|
+
client = ProveCartClient(api_key="test", transport=stub)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Calls made with a sandbox key against the live API behave exactly as in production and stay sealed under that key, separate from live data.
|
|
72
|
+
|
|
73
|
+
## Links
|
|
74
|
+
|
|
75
|
+
- Agents and certification: https://provecart.com/agents
|
|
76
|
+
- Correction format standard: https://provecart.com/standards/correction-format
|
|
77
|
+
- API reference: https://api.provecart.com/docs
|
|
78
|
+
|
|
79
|
+
ProveCart. Prove the cart. Patent pending.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "provecart"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "ProveCart client: check an AI agent's cart, booking, or reservation against the buyer's signed instructions before payment is captured."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
keywords = ["ai-agents", "agent-commerce", "verification", "payments", "provecart"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
17
|
+
]
|
|
18
|
+
dependencies = []
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://provecart.com"
|
|
22
|
+
Documentation = "https://provecart.com/agents"
|
|
23
|
+
"Correction format" = "https://provecart.com/standards/correction-format"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""ProveCart client: verify an agent's cart, booking, or reservation
|
|
2
|
+
against the buyer's signed instructions before payment is captured.
|
|
3
|
+
"""
|
|
4
|
+
from .client import (
|
|
5
|
+
KNOWN_ACTIONS,
|
|
6
|
+
Client,
|
|
7
|
+
ProveCartClient,
|
|
8
|
+
ProveCartError,
|
|
9
|
+
route_verdict,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"KNOWN_ACTIONS",
|
|
16
|
+
"Client",
|
|
17
|
+
"ProveCartClient",
|
|
18
|
+
"ProveCartError",
|
|
19
|
+
"route_verdict",
|
|
20
|
+
"__version__",
|
|
21
|
+
]
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""ProveCart client + verdict routing.
|
|
2
|
+
|
|
3
|
+
Self-contained (standard library only). Implements the consumer rules
|
|
4
|
+
of the Agent Correction Format (ACF v0.2, com.provecart.conformity):
|
|
5
|
+
|
|
6
|
+
MATCH -> proceed to payment (or ticket issuance).
|
|
7
|
+
BLOCK + fixes -> apply ONLY repairs that bring the transaction
|
|
8
|
+
INTO the buyer's existing authorization, then
|
|
9
|
+
resubmit as a new check carrying
|
|
10
|
+
previous_record_id, within max_attempts.
|
|
11
|
+
REVIEW / unknown -> stop and escalate to the buyer. Never guess,
|
|
12
|
+
action never widen the authorization.
|
|
13
|
+
|
|
14
|
+
API contract (https://api.provecart.com, X-API-Key header):
|
|
15
|
+
POST /mandates -> signed cart mandate (use its "id")
|
|
16
|
+
POST /verify -> cart verdict
|
|
17
|
+
POST /booking-mandates -> signed booking mandate
|
|
18
|
+
POST /verify-booking -> booking verdict
|
|
19
|
+
POST /reservation-mandates -> signed reservation mandate
|
|
20
|
+
POST /verify-reservation -> reservation verdict
|
|
21
|
+
|
|
22
|
+
Every verify response carries {verdict, corrections[], max_attempts,
|
|
23
|
+
record_id, verdict_token, ...}. A corrected resubmission is a new
|
|
24
|
+
verify call against the same mandate with previous_record_id set to
|
|
25
|
+
the earlier check's record_id.
|
|
26
|
+
"""
|
|
27
|
+
import json
|
|
28
|
+
import urllib.error
|
|
29
|
+
import urllib.request
|
|
30
|
+
|
|
31
|
+
KNOWN_ACTIONS = {
|
|
32
|
+
"remove_item", "replace_item", "change_variant", "reduce_quantity",
|
|
33
|
+
"reduce_total", "change_currency", "correct_traveler_name",
|
|
34
|
+
"change_airport", "change_travel_date", "change_departure_time",
|
|
35
|
+
"add_itinerary", "rebuild_itinerary", "change_cabin",
|
|
36
|
+
"change_fare_terms", "add_hotel_stay", "change_room",
|
|
37
|
+
"change_provider", "change_location", "change_date", "change_time",
|
|
38
|
+
"change_party_size", "correct_reservation_name", "reduce_deposit",
|
|
39
|
+
"change_cancellation_terms",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
DEFAULT_BASE_URL = "https://api.provecart.com"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ProveCartError(RuntimeError):
|
|
46
|
+
"""Raised when the ProveCart API returns a non-200 status."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ProveCartClient:
|
|
50
|
+
"""Client for the ProveCart verification API.
|
|
51
|
+
|
|
52
|
+
Parameters:
|
|
53
|
+
api_key: integrator API key, sent as the X-API-Key header.
|
|
54
|
+
base_url: API base URL (default https://api.provecart.com).
|
|
55
|
+
transport: optional callable ``(path, body) -> (status, dict)``
|
|
56
|
+
replacing the HTTP layer. Intended for offline tests and
|
|
57
|
+
rehearsal runs; when supplied, no network call is made.
|
|
58
|
+
timeout: HTTP timeout in seconds for the default transport.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, api_key, base_url=DEFAULT_BASE_URL,
|
|
62
|
+
transport=None, timeout=20):
|
|
63
|
+
self.api_key = api_key
|
|
64
|
+
self.base = base_url.rstrip("/")
|
|
65
|
+
self._transport = transport
|
|
66
|
+
self.timeout = timeout
|
|
67
|
+
|
|
68
|
+
# -- transport ---------------------------------------------------
|
|
69
|
+
def _post(self, path, body, extra_headers=None):
|
|
70
|
+
if self._transport is not None:
|
|
71
|
+
return self._transport(path, body)
|
|
72
|
+
headers = {"Content-Type": "application/json",
|
|
73
|
+
"X-API-Key": self.api_key}
|
|
74
|
+
if extra_headers:
|
|
75
|
+
headers.update(extra_headers)
|
|
76
|
+
req = urllib.request.Request(
|
|
77
|
+
self.base + path,
|
|
78
|
+
data=json.dumps(body).encode(),
|
|
79
|
+
method="POST",
|
|
80
|
+
headers=headers)
|
|
81
|
+
try:
|
|
82
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
83
|
+
return resp.status, json.loads(resp.read())
|
|
84
|
+
except urllib.error.HTTPError as e:
|
|
85
|
+
try:
|
|
86
|
+
return e.code, json.loads(e.read() or b"{}")
|
|
87
|
+
except Exception:
|
|
88
|
+
return e.code, {}
|
|
89
|
+
|
|
90
|
+
def _post_ok(self, path, body, what, idempotency_key=None):
|
|
91
|
+
extra = ({"Idempotency-Key": idempotency_key}
|
|
92
|
+
if idempotency_key else None)
|
|
93
|
+
status, out = self._post(path, body, extra_headers=extra) \
|
|
94
|
+
if extra else self._post(path, body)
|
|
95
|
+
if status != 200:
|
|
96
|
+
raise ProveCartError(f"{what} failed: {status} {out}")
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
# -- mandate creation ---------------------------------------------
|
|
100
|
+
def create_mandate(self, **fields):
|
|
101
|
+
"""Create a signed cart mandate: POST /mandates.
|
|
102
|
+
|
|
103
|
+
Required fields include user_id, item_description, category,
|
|
104
|
+
max_total_price and expires_at; item_keywords, currency,
|
|
105
|
+
max_quantity, merchant_allowlist/blocklist, brand and
|
|
106
|
+
variant_constraints are also accepted. Returns the signed
|
|
107
|
+
mandate dict; use its "id" as mandate_id.
|
|
108
|
+
"""
|
|
109
|
+
return self._post_ok("/mandates", fields, "mandate creation")
|
|
110
|
+
|
|
111
|
+
# Backwards-compatible alias: the cart mandate is the original kind.
|
|
112
|
+
create_cart_mandate = create_mandate
|
|
113
|
+
|
|
114
|
+
def create_booking_mandate(self, **fields):
|
|
115
|
+
"""Create a signed booking mandate: POST /booking-mandates.
|
|
116
|
+
|
|
117
|
+
Required fields include user_id, travelers, origin,
|
|
118
|
+
destination, departure_date, max_total_price and expires_at.
|
|
119
|
+
Returns the signed mandate dict; use its "id".
|
|
120
|
+
"""
|
|
121
|
+
return self._post_ok("/booking-mandates", fields,
|
|
122
|
+
"booking mandate creation")
|
|
123
|
+
|
|
124
|
+
def create_reservation_mandate(self, **fields):
|
|
125
|
+
"""Create a signed reservation mandate:
|
|
126
|
+
POST /reservation-mandates.
|
|
127
|
+
|
|
128
|
+
Required fields include user_id, service_description, date,
|
|
129
|
+
time, party_size, name_on_reservation, max_total_price and
|
|
130
|
+
expires_at. Returns the signed mandate dict; use its "id".
|
|
131
|
+
"""
|
|
132
|
+
return self._post_ok("/reservation-mandates", fields,
|
|
133
|
+
"reservation mandate creation")
|
|
134
|
+
|
|
135
|
+
# -- verification ---------------------------------------------------
|
|
136
|
+
def verify_cart(self, mandate_id, merchant, items, total_price,
|
|
137
|
+
currency="USD", tax=0.0, shipping=0.0, fees=0.0,
|
|
138
|
+
previous_record_id=None, cart_changed=False,
|
|
139
|
+
idempotency_key=None):
|
|
140
|
+
"""Verify a cart: POST /verify. Returns the verdict dict."""
|
|
141
|
+
body = {"mandate_id": mandate_id, "merchant": merchant,
|
|
142
|
+
"items": items, "total_price": total_price,
|
|
143
|
+
"currency": currency, "tax": tax, "shipping": shipping,
|
|
144
|
+
"fees": fees, "cart_changed": cart_changed}
|
|
145
|
+
if previous_record_id:
|
|
146
|
+
body["previous_record_id"] = previous_record_id
|
|
147
|
+
return self._post_ok("/verify", body, "verify",
|
|
148
|
+
idempotency_key=idempotency_key)
|
|
149
|
+
|
|
150
|
+
def verify_booking(self, mandate_id, merchant, price,
|
|
151
|
+
travelers=None, legs=None, stays=None, fare=None,
|
|
152
|
+
currency="USD", previous_record_id=None,
|
|
153
|
+
booking_changed=False, idempotency_key=None):
|
|
154
|
+
"""Verify a booking: POST /verify-booking.
|
|
155
|
+
|
|
156
|
+
``price`` is a dict with at least {"total": ...}; ``legs``
|
|
157
|
+
items carry from_airport, to_airport, depart_at, arrive_at;
|
|
158
|
+
``travelers`` is a list of traveler names. Returns the
|
|
159
|
+
verdict dict.
|
|
160
|
+
"""
|
|
161
|
+
body = {"mandate_id": mandate_id, "merchant": merchant,
|
|
162
|
+
"price": price, "currency": currency,
|
|
163
|
+
"booking_changed": booking_changed}
|
|
164
|
+
if travelers is not None:
|
|
165
|
+
body["travelers"] = travelers
|
|
166
|
+
if legs is not None:
|
|
167
|
+
body["legs"] = legs
|
|
168
|
+
if stays is not None:
|
|
169
|
+
body["stays"] = stays
|
|
170
|
+
if fare is not None:
|
|
171
|
+
body["fare"] = fare
|
|
172
|
+
if previous_record_id:
|
|
173
|
+
body["previous_record_id"] = previous_record_id
|
|
174
|
+
return self._post_ok("/verify-booking", body, "verify-booking",
|
|
175
|
+
idempotency_key=idempotency_key)
|
|
176
|
+
|
|
177
|
+
def verify_reservation(self, mandate_id, merchant, date, time,
|
|
178
|
+
party_size, name_on_reservation, total_amount,
|
|
179
|
+
provider_name=None, location=None,
|
|
180
|
+
deposit_amount=0.0, currency="USD",
|
|
181
|
+
cancellation=None, previous_record_id=None,
|
|
182
|
+
reservation_changed=False,
|
|
183
|
+
idempotency_key=None):
|
|
184
|
+
"""Verify a reservation: POST /verify-reservation.
|
|
185
|
+
Returns the verdict dict."""
|
|
186
|
+
body = {"mandate_id": mandate_id, "merchant": merchant,
|
|
187
|
+
"date": date, "time": time, "party_size": party_size,
|
|
188
|
+
"name_on_reservation": name_on_reservation,
|
|
189
|
+
"deposit_amount": deposit_amount,
|
|
190
|
+
"total_amount": total_amount, "currency": currency,
|
|
191
|
+
"reservation_changed": reservation_changed}
|
|
192
|
+
if provider_name is not None:
|
|
193
|
+
body["provider_name"] = provider_name
|
|
194
|
+
if location is not None:
|
|
195
|
+
body["location"] = location
|
|
196
|
+
if cancellation is not None:
|
|
197
|
+
body["cancellation"] = cancellation
|
|
198
|
+
if previous_record_id:
|
|
199
|
+
body["previous_record_id"] = previous_record_id
|
|
200
|
+
return self._post_ok("/verify-reservation", body,
|
|
201
|
+
"verify-reservation",
|
|
202
|
+
idempotency_key=idempotency_key)
|
|
203
|
+
|
|
204
|
+
# -- one-call checks: create-or-take a mandate, then verify --------
|
|
205
|
+
def _resolve_mandate_id(self, mandate_id, mandate, creator):
|
|
206
|
+
if mandate is not None:
|
|
207
|
+
created = creator(**mandate)
|
|
208
|
+
return created["id"]
|
|
209
|
+
if not mandate_id:
|
|
210
|
+
raise ValueError(
|
|
211
|
+
"pass mandate_id= or mandate={...} so the check has a "
|
|
212
|
+
"signed instruction to compare against")
|
|
213
|
+
return mandate_id
|
|
214
|
+
|
|
215
|
+
def check_cart(self, merchant, items, total_price, *,
|
|
216
|
+
mandate_id=None, mandate=None, **verify_kwargs):
|
|
217
|
+
"""Check a cart, creating the mandate first when ``mandate``
|
|
218
|
+
(a dict of mandate fields) is given instead of ``mandate_id``.
|
|
219
|
+
Returns the verdict response dict."""
|
|
220
|
+
mid = self._resolve_mandate_id(mandate_id, mandate,
|
|
221
|
+
self.create_mandate)
|
|
222
|
+
return self.verify_cart(mid, merchant, items, total_price,
|
|
223
|
+
**verify_kwargs)
|
|
224
|
+
|
|
225
|
+
def check_booking(self, merchant, price, *, mandate_id=None,
|
|
226
|
+
mandate=None, **verify_kwargs):
|
|
227
|
+
"""Check a booking, creating the booking mandate first when
|
|
228
|
+
``mandate`` is given instead of ``mandate_id``.
|
|
229
|
+
Returns the verdict response dict."""
|
|
230
|
+
mid = self._resolve_mandate_id(mandate_id, mandate,
|
|
231
|
+
self.create_booking_mandate)
|
|
232
|
+
return self.verify_booking(mid, merchant, price, **verify_kwargs)
|
|
233
|
+
|
|
234
|
+
def check_reservation(self, merchant, date, time, party_size,
|
|
235
|
+
name_on_reservation, total_amount, *,
|
|
236
|
+
mandate_id=None, mandate=None, **verify_kwargs):
|
|
237
|
+
"""Check a reservation, creating the reservation mandate
|
|
238
|
+
first when ``mandate`` is given instead of ``mandate_id``.
|
|
239
|
+
Returns the verdict response dict."""
|
|
240
|
+
mid = self._resolve_mandate_id(mandate_id, mandate,
|
|
241
|
+
self.create_reservation_mandate)
|
|
242
|
+
return self.verify_reservation(
|
|
243
|
+
mid, merchant, date, time, party_size, name_on_reservation,
|
|
244
|
+
total_amount, **verify_kwargs)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# Short alias.
|
|
248
|
+
Client = ProveCartClient
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def route_verdict(response, attempt=1):
|
|
252
|
+
"""Return (decision, detail): decision is 'proceed', 'fix' or
|
|
253
|
+
'escalate'.
|
|
254
|
+
|
|
255
|
+
'fix' is returned only when the verdict is BLOCK, every
|
|
256
|
+
correction is a known action, no correction is marked
|
|
257
|
+
non-correctable, and the retry budget (max_attempts) has not
|
|
258
|
+
been spent. A fix never widens the buyer's authorization: apply
|
|
259
|
+
only the listed corrections, then resubmit carrying the
|
|
260
|
+
response's record_id as previous_record_id. Anything else —
|
|
261
|
+
REVIEW, an unknown verdict, an unknown action — escalates to
|
|
262
|
+
the buyer.
|
|
263
|
+
"""
|
|
264
|
+
verdict = response.get("verdict")
|
|
265
|
+
if verdict == "MATCH":
|
|
266
|
+
return "proceed", {"record_id": response.get("record_id")}
|
|
267
|
+
if verdict == "BLOCK":
|
|
268
|
+
corrections = response.get("corrections") or []
|
|
269
|
+
max_attempts = response.get("max_attempts")
|
|
270
|
+
budget_ok = max_attempts is None or attempt < max_attempts
|
|
271
|
+
usable = (
|
|
272
|
+
bool(corrections)
|
|
273
|
+
and all(c.get("action") in KNOWN_ACTIONS for c in corrections)
|
|
274
|
+
and all(c.get("correctable", True) for c in corrections)
|
|
275
|
+
)
|
|
276
|
+
if usable and budget_ok:
|
|
277
|
+
return "fix", {"corrections": corrections,
|
|
278
|
+
"record_id": response.get("record_id"),
|
|
279
|
+
"max_attempts": max_attempts}
|
|
280
|
+
reason = "BLOCK without usable corrections"
|
|
281
|
+
if usable and not budget_ok:
|
|
282
|
+
reason = "BLOCK retry budget (max_attempts) spent"
|
|
283
|
+
return "escalate", {"reason": reason,
|
|
284
|
+
"record_id": response.get("record_id")}
|
|
285
|
+
return "escalate", {"reason": f"verdict {verdict} needs the buyer",
|
|
286
|
+
"record_id": response.get("record_id")}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: provecart
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ProveCart client: check an AI agent's cart, booking, or reservation against the buyer's signed instructions before payment is captured.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://provecart.com
|
|
7
|
+
Project-URL: Documentation, https://provecart.com/agents
|
|
8
|
+
Project-URL: Correction format, https://provecart.com/standards/correction-format
|
|
9
|
+
Keywords: ai-agents,agent-commerce,verification,payments,provecart
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# provecart
|
|
17
|
+
|
|
18
|
+
Your agent put the wrong variant in the cart — the wrong size, the wrong model, a quantity nobody asked for — and your user paid for it. By the time anyone noticed, the order had shipped, and the return came out of somebody's pocket. That is the failure this package exists to stop: one check, between what the agent built and the payment, that compares the transaction against what the buyer actually said.
|
|
19
|
+
|
|
20
|
+
ProveCart is a neutral verification API for agent commerce. You record the buyer's instruction as a signed mandate, send what the agent actually built, and get back a signed verdict — `MATCH`, `REVIEW`, or `BLOCK` — before payment is captured (or, for bookings, before a ticket is issued). A `BLOCK` or `REVIEW` answer carries machine-readable corrections, so the agent can fix what it built and resubmit instead of losing the sale or guessing. Every check is written to a sealed evidence record. ProveCart never holds or moves money.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install provecart
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Standard library only. No dependencies.
|
|
27
|
+
|
|
28
|
+
## Check a cart
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from provecart import ProveCartClient, route_verdict
|
|
32
|
+
client = ProveCartClient(api_key="YOUR_API_KEY")
|
|
33
|
+
verdict = client.check_cart(merchant={"name": "Example Shop", "domain": "shop.example.com"}, items=[{"name": "Wireless Headphones", "variant": "black", "quantity": 1, "unit_price": 249.99}], total_price=249.99, mandate={"user_id": "user-123", "item_description": "Wireless noise cancelling over-ear headphones", "item_keywords": ["wireless", "headphones"], "category": "electronics", "max_total_price": 300.00, "currency": "USD", "max_quantity": 1, "expires_at": "2027-01-01T00:00:00Z"})
|
|
34
|
+
decision, detail = route_verdict(verdict) # proceed / fix / escalate
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Already holding a mandate id? Pass `mandate_id=` instead of `mandate=` — `check_cart` takes either. The lower-level pair is also there when you want the steps separate: `create_mandate(**fields)` returns the signed mandate, and `verify_cart(mandate_id, merchant, items, total_price, ...)` runs the check.
|
|
38
|
+
|
|
39
|
+
## Check a booking
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from provecart import ProveCartClient, route_verdict
|
|
43
|
+
client = ProveCartClient(api_key="YOUR_API_KEY")
|
|
44
|
+
verdict = client.check_booking(merchant={"name": "Example Air", "domain": "air.example.com"}, price={"total": 412.00}, travelers=["Alex Rivera"], legs=[{"from_airport": "SFO", "to_airport": "JFK", "depart_at": "2027-03-10T08:00:00Z", "arrive_at": "2027-03-10T16:30:00Z"}], mandate={"user_id": "user-123", "travelers": [{"name": "Alex Rivera"}], "origin": {"airport": "SFO"}, "destination": {"airport": "JFK"}, "departure_date": "2027-03-10", "max_total_price": 500.00, "currency": "USD", "expires_at": "2027-03-01T00:00:00Z"})
|
|
45
|
+
decision, detail = route_verdict(verdict) # gate both capture and ticket issuance on MATCH
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The separate steps are `create_booking_mandate(**fields)` and `verify_booking(mandate_id, merchant, price, travelers=..., legs=..., stays=..., fare=...)`.
|
|
49
|
+
|
|
50
|
+
## Check a reservation
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from provecart import ProveCartClient, route_verdict
|
|
54
|
+
client = ProveCartClient(api_key="YOUR_API_KEY")
|
|
55
|
+
verdict = client.check_reservation(merchant={"name": "Example Table", "domain": "table.example.com"}, date="2027-03-10", time="19:00", party_size=2, name_on_reservation="Alex Rivera", total_amount=40.00, mandate={"user_id": "user-123", "service_description": "Dinner reservation for two", "date": "2027-03-10", "time": "19:00", "party_size": 2, "name_on_reservation": "Alex Rivera", "max_total_price": 60.00, "currency": "USD", "expires_at": "2027-03-09T00:00:00Z"})
|
|
56
|
+
decision, detail = route_verdict(verdict) # proceed / fix / escalate
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The separate steps are `create_reservation_mandate(**fields)` and `verify_reservation(mandate_id, merchant, date, time, party_size, name_on_reservation, total_amount, ...)`.
|
|
60
|
+
|
|
61
|
+
## What to do with the verdict
|
|
62
|
+
|
|
63
|
+
`route_verdict` encodes the consumer rules of the Agent Correction Format, and they are deliberately strict:
|
|
64
|
+
|
|
65
|
+
- **MATCH → proceed.** Capture the payment (or issue the ticket).
|
|
66
|
+
- **BLOCK → fix, narrowly.** Fix only when every correction in the response is a known action that brings the transaction back inside the buyer's existing authorization. Apply exactly those corrections, then resubmit as a new check carrying the earlier check's `record_id` as `previous_record_id`, within the response's `max_attempts` budget.
|
|
67
|
+
- **REVIEW, unknown verdict, unknown action → escalate.** Stop and ask the buyer. Never guess, and never widen the buyer's authorization to make a verdict go away.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
decision, detail = route_verdict(first_verdict)
|
|
71
|
+
if decision == "fix":
|
|
72
|
+
# ... apply detail["corrections"] to the transaction, nothing else ...
|
|
73
|
+
second = client.check_cart(..., mandate_id=same_id, previous_record_id=detail["record_id"])
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Offline and mock runs
|
|
77
|
+
|
|
78
|
+
The client takes an optional `transport` callable, `(path, body) -> (status, dict)`, that replaces the HTTP layer entirely. Point it at canned verdicts and the full mandate → check → route → resubmit loop runs with no network and no API key traffic — that is how this package's own test suite works, and it is the recommended way to rehearse an integration before wiring a real key:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
def stub(path, body):
|
|
82
|
+
return 200, {"id": "m-1"} if "mandates" in path else {"verdict": "MATCH", "record_id": "r-1"}
|
|
83
|
+
client = ProveCartClient(api_key="test", transport=stub)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Calls made with a sandbox key against the live API behave exactly as in production and stay sealed under that key, separate from live data.
|
|
87
|
+
|
|
88
|
+
## Links
|
|
89
|
+
|
|
90
|
+
- Agents and certification: https://provecart.com/agents
|
|
91
|
+
- Correction format standard: https://provecart.com/standards/correction-format
|
|
92
|
+
- API reference: https://api.provecart.com/docs
|
|
93
|
+
|
|
94
|
+
ProveCart. Prove the cart. Patent pending.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
provecart
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Offline unit tests for the provecart client.
|
|
2
|
+
|
|
3
|
+
A stub transport replaces the HTTP layer: no network, no live calls.
|
|
4
|
+
Covers MATCH / BLOCK-fix / REVIEW routing and previous_record_id
|
|
5
|
+
chaining for carts, bookings, and reservations.
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
11
|
+
|
|
12
|
+
from provecart import ProveCartClient, route_verdict # noqa: E402
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StubTransport:
|
|
16
|
+
"""Records every (path, body) call and replays queued responses."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, responses):
|
|
19
|
+
self.responses = list(responses)
|
|
20
|
+
self.calls = []
|
|
21
|
+
|
|
22
|
+
def __call__(self, path, body):
|
|
23
|
+
self.calls.append((path, dict(body)))
|
|
24
|
+
return self.responses.pop(0)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def mandate_ok(mid="m-1"):
|
|
28
|
+
return (200, {"id": mid, "signature": "sig"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
MERCHANT = {"name": "Example Shop", "domain": "shop.example.com"}
|
|
32
|
+
CART_MANDATE = {
|
|
33
|
+
"user_id": "user-123",
|
|
34
|
+
"item_description": "Wireless noise cancelling over-ear headphones",
|
|
35
|
+
"item_keywords": ["wireless", "headphones"],
|
|
36
|
+
"category": "electronics",
|
|
37
|
+
"max_total_price": 300.00,
|
|
38
|
+
"currency": "USD",
|
|
39
|
+
"max_quantity": 1,
|
|
40
|
+
"expires_at": "2027-01-01T00:00:00Z",
|
|
41
|
+
}
|
|
42
|
+
ITEMS = [{"name": "Wireless Headphones", "variant": "black",
|
|
43
|
+
"quantity": 1, "unit_price": 249.99}]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_match_routes_proceed():
|
|
47
|
+
stub = StubTransport([mandate_ok(),
|
|
48
|
+
(200, {"verdict": "MATCH", "record_id": "r-1"})])
|
|
49
|
+
client = ProveCartClient("test-key", transport=stub)
|
|
50
|
+
out = client.check_cart(MERCHANT, ITEMS, 249.99, mandate=CART_MANDATE)
|
|
51
|
+
assert out["verdict"] == "MATCH"
|
|
52
|
+
assert stub.calls[0][0] == "/mandates"
|
|
53
|
+
assert stub.calls[1][0] == "/verify"
|
|
54
|
+
assert stub.calls[1][1]["mandate_id"] == "m-1"
|
|
55
|
+
decision, detail = route_verdict(out)
|
|
56
|
+
assert decision == "proceed"
|
|
57
|
+
assert detail["record_id"] == "r-1"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_check_cart_with_existing_mandate_id_skips_creation():
|
|
61
|
+
stub = StubTransport([(200, {"verdict": "MATCH", "record_id": "r-9"})])
|
|
62
|
+
client = ProveCartClient("test-key", transport=stub)
|
|
63
|
+
out = client.check_cart(MERCHANT, ITEMS, 249.99, mandate_id="m-42")
|
|
64
|
+
assert out["verdict"] == "MATCH"
|
|
65
|
+
assert len(stub.calls) == 1
|
|
66
|
+
assert stub.calls[0][1]["mandate_id"] == "m-42"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_block_with_known_corrections_routes_fix():
|
|
70
|
+
resp = {"verdict": "BLOCK", "record_id": "r-2", "max_attempts": 3,
|
|
71
|
+
"corrections": [
|
|
72
|
+
{"action": "change_variant", "dimension": "variant",
|
|
73
|
+
"expected": "black", "actual": "white"},
|
|
74
|
+
{"action": "reduce_quantity", "dimension": "quantity",
|
|
75
|
+
"expected": 1, "actual": 2}]}
|
|
76
|
+
decision, detail = route_verdict(resp)
|
|
77
|
+
assert decision == "fix"
|
|
78
|
+
assert detail["record_id"] == "r-2"
|
|
79
|
+
assert detail["max_attempts"] == 3
|
|
80
|
+
assert detail["corrections"] == resp["corrections"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_block_fix_then_resubmit_chains_previous_record_id():
|
|
84
|
+
blocked = (200, {"verdict": "BLOCK", "record_id": "r-2",
|
|
85
|
+
"max_attempts": 3,
|
|
86
|
+
"corrections": [{"action": "change_variant"}]})
|
|
87
|
+
matched = (200, {"verdict": "MATCH", "record_id": "r-3"})
|
|
88
|
+
stub = StubTransport([blocked, matched])
|
|
89
|
+
client = ProveCartClient("test-key", transport=stub)
|
|
90
|
+
first = client.verify_cart("m-1", MERCHANT, ITEMS, 249.99)
|
|
91
|
+
decision, detail = route_verdict(first)
|
|
92
|
+
assert decision == "fix"
|
|
93
|
+
fixed = [{"name": "Wireless Headphones", "variant": "black",
|
|
94
|
+
"quantity": 1, "unit_price": 249.99}]
|
|
95
|
+
second = client.verify_cart(
|
|
96
|
+
"m-1", MERCHANT, fixed, 249.99,
|
|
97
|
+
previous_record_id=detail["record_id"], cart_changed=True)
|
|
98
|
+
assert second["verdict"] == "MATCH"
|
|
99
|
+
assert stub.calls[1][1]["previous_record_id"] == "r-2"
|
|
100
|
+
assert stub.calls[1][1]["cart_changed"] is True
|
|
101
|
+
assert "previous_record_id" not in stub.calls[0][1]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_review_routes_escalate():
|
|
105
|
+
decision, detail = route_verdict(
|
|
106
|
+
{"verdict": "REVIEW", "record_id": "r-4"})
|
|
107
|
+
assert decision == "escalate"
|
|
108
|
+
assert detail["record_id"] == "r-4"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_unknown_verdict_routes_escalate():
|
|
112
|
+
decision, _ = route_verdict({"verdict": "SOMETHING_ELSE"})
|
|
113
|
+
assert decision == "escalate"
|
|
114
|
+
decision, _ = route_verdict({})
|
|
115
|
+
assert decision == "escalate"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_block_with_unknown_action_routes_escalate():
|
|
119
|
+
resp = {"verdict": "BLOCK", "record_id": "r-5", "max_attempts": 3,
|
|
120
|
+
"corrections": [{"action": "raise_the_budget"}]}
|
|
121
|
+
decision, _ = route_verdict(resp)
|
|
122
|
+
assert decision == "escalate"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_block_without_corrections_routes_escalate():
|
|
126
|
+
decision, _ = route_verdict(
|
|
127
|
+
{"verdict": "BLOCK", "record_id": "r-6", "corrections": []})
|
|
128
|
+
assert decision == "escalate"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_block_non_correctable_routes_escalate():
|
|
132
|
+
resp = {"verdict": "BLOCK", "record_id": "r-7", "max_attempts": 3,
|
|
133
|
+
"corrections": [{"action": "remove_item",
|
|
134
|
+
"correctable": False}]}
|
|
135
|
+
decision, _ = route_verdict(resp)
|
|
136
|
+
assert decision == "escalate"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_block_after_budget_spent_routes_escalate():
|
|
140
|
+
resp = {"verdict": "BLOCK", "record_id": "r-8", "max_attempts": 3,
|
|
141
|
+
"corrections": [{"action": "remove_item"}]}
|
|
142
|
+
decision, detail = route_verdict(resp, attempt=3)
|
|
143
|
+
assert decision == "escalate"
|
|
144
|
+
assert "max_attempts" in detail["reason"]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_booking_check_creates_mandate_and_verifies():
|
|
148
|
+
stub = StubTransport([mandate_ok("bm-1"),
|
|
149
|
+
(200, {"verdict": "MATCH", "record_id": "br-1"})])
|
|
150
|
+
client = ProveCartClient("test-key", transport=stub)
|
|
151
|
+
mandate = {
|
|
152
|
+
"user_id": "user-123",
|
|
153
|
+
"travelers": [{"name": "Alex Rivera"}],
|
|
154
|
+
"origin": {"airport": "SFO"},
|
|
155
|
+
"destination": {"airport": "JFK"},
|
|
156
|
+
"departure_date": "2027-03-10",
|
|
157
|
+
"max_total_price": 500.00,
|
|
158
|
+
"expires_at": "2027-03-01T00:00:00Z",
|
|
159
|
+
}
|
|
160
|
+
legs = [{"from_airport": "SFO", "to_airport": "JFK",
|
|
161
|
+
"depart_at": "2027-03-10T08:00:00Z",
|
|
162
|
+
"arrive_at": "2027-03-10T16:30:00Z"}]
|
|
163
|
+
out = client.check_booking(
|
|
164
|
+
MERCHANT, {"total": 412.00}, mandate=mandate,
|
|
165
|
+
travelers=["Alex Rivera"], legs=legs)
|
|
166
|
+
assert out["verdict"] == "MATCH"
|
|
167
|
+
assert stub.calls[0][0] == "/booking-mandates"
|
|
168
|
+
assert stub.calls[1][0] == "/verify-booking"
|
|
169
|
+
assert stub.calls[1][1]["mandate_id"] == "bm-1"
|
|
170
|
+
assert stub.calls[1][1]["price"] == {"total": 412.00}
|
|
171
|
+
decision, _ = route_verdict(out)
|
|
172
|
+
assert decision == "proceed"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_booking_resubmit_chains_previous_record_id():
|
|
176
|
+
stub = StubTransport([
|
|
177
|
+
(200, {"verdict": "BLOCK", "record_id": "br-2", "max_attempts": 3,
|
|
178
|
+
"corrections": [{"action": "change_airport"}]}),
|
|
179
|
+
(200, {"verdict": "MATCH", "record_id": "br-3"})])
|
|
180
|
+
client = ProveCartClient("test-key", transport=stub)
|
|
181
|
+
first = client.verify_booking("bm-1", MERCHANT, {"total": 412.00})
|
|
182
|
+
decision, detail = route_verdict(first)
|
|
183
|
+
assert decision == "fix"
|
|
184
|
+
client.verify_booking("bm-1", MERCHANT, {"total": 399.00},
|
|
185
|
+
previous_record_id=detail["record_id"],
|
|
186
|
+
booking_changed=True)
|
|
187
|
+
assert stub.calls[1][1]["previous_record_id"] == "br-2"
|
|
188
|
+
assert stub.calls[1][1]["booking_changed"] is True
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_reservation_check_creates_mandate_and_verifies():
|
|
192
|
+
stub = StubTransport([mandate_ok("rm-1"),
|
|
193
|
+
(200, {"verdict": "MATCH", "record_id": "rr-1"})])
|
|
194
|
+
client = ProveCartClient("test-key", transport=stub)
|
|
195
|
+
mandate = {
|
|
196
|
+
"user_id": "user-123",
|
|
197
|
+
"service_description": "Dinner reservation for two",
|
|
198
|
+
"date": "2027-03-10", "time": "19:00", "party_size": 2,
|
|
199
|
+
"name_on_reservation": "Alex Rivera",
|
|
200
|
+
"max_total_price": 60.00,
|
|
201
|
+
"expires_at": "2027-03-09T00:00:00Z",
|
|
202
|
+
}
|
|
203
|
+
out = client.check_reservation(
|
|
204
|
+
MERCHANT, "2027-03-10", "19:00", 2, "Alex Rivera", 40.00,
|
|
205
|
+
mandate=mandate)
|
|
206
|
+
assert out["verdict"] == "MATCH"
|
|
207
|
+
assert stub.calls[0][0] == "/reservation-mandates"
|
|
208
|
+
assert stub.calls[1][0] == "/verify-reservation"
|
|
209
|
+
assert stub.calls[1][1]["mandate_id"] == "rm-1"
|
|
210
|
+
assert stub.calls[1][1]["total_amount"] == 40.00
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_reservation_block_routes_fix_and_chains():
|
|
214
|
+
stub = StubTransport([
|
|
215
|
+
(200, {"verdict": "BLOCK", "record_id": "rr-2", "max_attempts": 3,
|
|
216
|
+
"corrections": [{"action": "change_party_size"}]}),
|
|
217
|
+
(200, {"verdict": "REVIEW", "record_id": "rr-3"})])
|
|
218
|
+
client = ProveCartClient("test-key", transport=stub)
|
|
219
|
+
first = client.verify_reservation(
|
|
220
|
+
"rm-1", MERCHANT, "2027-03-10", "19:00", 4, "Alex Rivera", 40.00)
|
|
221
|
+
decision, detail = route_verdict(first)
|
|
222
|
+
assert decision == "fix"
|
|
223
|
+
second = client.verify_reservation(
|
|
224
|
+
"rm-1", MERCHANT, "2027-03-10", "19:00", 2, "Alex Rivera", 40.00,
|
|
225
|
+
previous_record_id=detail["record_id"], reservation_changed=True)
|
|
226
|
+
assert stub.calls[1][1]["previous_record_id"] == "rr-2"
|
|
227
|
+
decision2, _ = route_verdict(second)
|
|
228
|
+
assert decision2 == "escalate"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_create_mandate_helpers_hit_the_three_endpoints():
|
|
232
|
+
stub = StubTransport([mandate_ok("a"), mandate_ok("b"),
|
|
233
|
+
mandate_ok("c")])
|
|
234
|
+
client = ProveCartClient("test-key", transport=stub)
|
|
235
|
+
assert client.create_mandate(**CART_MANDATE)["id"] == "a"
|
|
236
|
+
assert client.create_booking_mandate(user_id="u")["id"] == "b"
|
|
237
|
+
assert client.create_reservation_mandate(user_id="u")["id"] == "c"
|
|
238
|
+
paths = [c[0] for c in stub.calls]
|
|
239
|
+
assert paths == ["/mandates", "/booking-mandates",
|
|
240
|
+
"/reservation-mandates"]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_api_error_raises():
|
|
244
|
+
stub = StubTransport([(403, {"detail": "bad key"})])
|
|
245
|
+
client = ProveCartClient("test-key", transport=stub)
|
|
246
|
+
try:
|
|
247
|
+
client.create_mandate(**CART_MANDATE)
|
|
248
|
+
except RuntimeError:
|
|
249
|
+
return
|
|
250
|
+
raise AssertionError("expected RuntimeError on non-200")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_check_requires_mandate_or_id():
|
|
254
|
+
client = ProveCartClient("test-key", transport=StubTransport([]))
|
|
255
|
+
try:
|
|
256
|
+
client.check_cart(MERCHANT, ITEMS, 249.99)
|
|
257
|
+
except ValueError:
|
|
258
|
+
return
|
|
259
|
+
raise AssertionError("expected ValueError without a mandate")
|