agentcart 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentcart/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """agentcart: security-first agentic checkout over MCP."""
2
+
3
+ __version__ = "0.1.0"
agentcart/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,33 @@
1
+ """Adapter registry. Adding a store = adding one module here (see docs/ADAPTERS.md)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..errors import CheckoutError
6
+ from ..netsafe import dev_loopback_enabled, host_matches, registrable_host
7
+ from .base import SiteAdapter
8
+ from .demo import DemoShopAdapter
9
+
10
+
11
+ def all_adapters() -> list[SiteAdapter]:
12
+ adapters: list[SiteAdapter] = []
13
+ if dev_loopback_enabled():
14
+ adapters.append(DemoShopAdapter())
15
+ return adapters
16
+
17
+
18
+ def for_url(url: str) -> SiteAdapter:
19
+ host = registrable_host(url)
20
+ for a in all_adapters():
21
+ if any(host_matches(host, d) for d in a.domains):
22
+ return a
23
+ supported = ", ".join(d for a in all_adapters() for d in a.domains) or "none yet"
24
+ raise CheckoutError(
25
+ f"no checkout adapter for '{host}' yet (supported: {supported}). "
26
+ "fetch_product still works on any site; contributions welcome, see docs/ADAPTERS.md")
27
+
28
+
29
+ def by_name(name: str) -> SiteAdapter:
30
+ for a in all_adapters():
31
+ if a.name == name:
32
+ return a
33
+ raise CheckoutError(f"unknown site '{name}'")
@@ -0,0 +1,95 @@
1
+ """Site adapter contract. One adapter per store; see docs/ADAPTERS.md."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import asdict, dataclass, field
7
+ from decimal import Decimal
8
+ from typing import Any
9
+ from urllib.parse import parse_qs, urlsplit
10
+
11
+ from playwright.async_api import Page
12
+
13
+ from ..errors import CheckoutError
14
+
15
+
16
+ @dataclass
17
+ class LineItem:
18
+ name: str
19
+ quantity: int
20
+ unit_price: Decimal
21
+
22
+
23
+ @dataclass
24
+ class Quote:
25
+ items: list[LineItem]
26
+ subtotal: Decimal
27
+ shipping: Decimal
28
+ total: Decimal
29
+ currency: str
30
+ deliver_to: str | None = None
31
+ notes: list[str] = field(default_factory=list)
32
+
33
+ def to_public(self) -> dict[str, Any]:
34
+ d = asdict(self)
35
+ for k in ("subtotal", "shipping", "total"):
36
+ d[k] = str(d[k])
37
+ for it in d["items"]:
38
+ it["unit_price"] = str(it["unit_price"])
39
+ return d
40
+
41
+
42
+ @dataclass
43
+ class UpiIntent:
44
+ payee_vpa: str
45
+ payee_name: str | None
46
+ amount: Decimal | None
47
+ currency: str | None
48
+ raw: str
49
+
50
+
51
+ def parse_upi_intent(uri: str) -> UpiIntent:
52
+ parts = urlsplit(uri)
53
+ if parts.scheme != "upi" or parts.netloc.lower() != "pay":
54
+ raise CheckoutError("payment link is not a upi://pay intent")
55
+ q = {k: v[0] for k, v in parse_qs(parts.query).items()}
56
+ if "pa" not in q or "@" not in q["pa"]:
57
+ raise CheckoutError("UPI intent has no valid payee address")
58
+ amt = Decimal(q["am"]).quantize(Decimal("0.01")) if q.get("am") else None
59
+ return UpiIntent(q["pa"], q.get("pn"), amt, q.get("cu"), uri)
60
+
61
+
62
+ @dataclass
63
+ class OrderResult:
64
+ order_id: str | None
65
+ status: str
66
+ total: Decimal | None
67
+
68
+
69
+ class SiteAdapter(ABC):
70
+ #: short id used for profiles and CLI ('agentcart login <name>')
71
+ name: str
72
+ #: domains this adapter handles (bare, no www.)
73
+ domains: tuple[str, ...]
74
+ #: True only if the adapter implements pay_with_balance against a pre-funded store balance
75
+ supports_autopay: bool = False
76
+
77
+ @abstractmethod
78
+ async def prepare_checkout(self, page: Page, product_url: str, quantity: int) -> Quote:
79
+ """Add the product to cart and stop on the payment-method page. Never pays."""
80
+
81
+ async def select_upi(self, page: Page, quote: Quote) -> UpiIntent | None:
82
+ """Choose UPI as the payment method and return the upi:// intent if the page exposes one.
83
+
84
+ Returning None is fine: the user pays in the visible browser window instead.
85
+ """
86
+ return None
87
+
88
+ async def pay_with_balance(self, page: Page, quote: Quote) -> OrderResult:
89
+ raise CheckoutError(f"{self.name} does not support auto-pay")
90
+
91
+ async def order_status(self, page: Page) -> OrderResult:
92
+ raise CheckoutError(f"{self.name} cannot report order status yet")
93
+
94
+ async def login_url(self) -> str:
95
+ return f"https://{self.domains[0]}/"
@@ -0,0 +1,73 @@
1
+ """Adapter for the bundled demo shop (loopback only). Also the reference implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+ from urllib.parse import urlsplit
7
+
8
+ from playwright.async_api import Page
9
+
10
+ from ..errors import CheckoutError
11
+ from ..sanitize import clean_text, parse_money
12
+ from .base import LineItem, OrderResult, Quote, SiteAdapter, UpiIntent, parse_upi_intent
13
+
14
+
15
+ async def _money(page: Page, testid: str) -> Decimal:
16
+ v = parse_money(await page.get_by_test_id(testid).inner_text())
17
+ if v is None:
18
+ raise CheckoutError(f"could not read '{testid}' amount from the page")
19
+ return v
20
+
21
+
22
+ class DemoShopAdapter(SiteAdapter):
23
+ name = "demo"
24
+ domains = ("127.0.0.1", "localhost")
25
+ supports_autopay = True
26
+
27
+ async def prepare_checkout(self, page: Page, product_url: str, quantity: int) -> Quote:
28
+ await page.goto(product_url)
29
+ await page.get_by_test_id("qty").fill(str(quantity))
30
+ await page.get_by_test_id("add-to-cart").click()
31
+ await page.wait_for_url("**/checkout")
32
+ qty = int(await page.get_by_test_id("item-qty").inner_text())
33
+ if qty != quantity:
34
+ raise CheckoutError(f"cart has quantity {qty}, expected {quantity}")
35
+ quote = Quote(
36
+ items=[LineItem(clean_text(await page.get_by_test_id("item-name").inner_text(), 200) or "?",
37
+ qty, await _money(page, "item-price"))],
38
+ subtotal=await _money(page, "subtotal"),
39
+ shipping=await _money(page, "shipping"),
40
+ total=await _money(page, "total"),
41
+ currency="INR",
42
+ deliver_to=clean_text(await page.get_by_test_id("address").inner_text(), 200),
43
+ )
44
+ await page.get_by_test_id("to-payment").click()
45
+ await page.wait_for_url("**/payment")
46
+ due = await _money(page, "due")
47
+ if due != quote.total:
48
+ raise CheckoutError(f"payment page amount ₹{due} differs from cart total ₹{quote.total}")
49
+ return quote
50
+
51
+ async def select_upi(self, page: Page, quote: Quote) -> UpiIntent | None:
52
+ await page.get_by_test_id("method-upi").check()
53
+ href = await page.get_by_test_id("upi-intent").get_attribute("href")
54
+ return parse_upi_intent(href) if href else None
55
+
56
+ async def pay_with_balance(self, page: Page, quote: Quote) -> OrderResult:
57
+ if await _money(page, "due") != quote.total:
58
+ raise CheckoutError("amount changed before payment; aborted")
59
+ await page.get_by_test_id("pay-balance").click()
60
+ await page.wait_for_load_state()
61
+ return await self.order_status(page)
62
+
63
+ async def order_status(self, page: Page) -> OrderResult:
64
+ if "/order/" not in urlsplit(page.url).path:
65
+ if await page.get_by_test_id("error").count():
66
+ return OrderResult(None, "FAILED: " + (await page.get_by_test_id("error").inner_text()),
67
+ None)
68
+ return OrderResult(None, "PENDING", None)
69
+ return OrderResult(
70
+ clean_text(await page.get_by_test_id("order-id").inner_text(), 40),
71
+ clean_text(await page.get_by_test_id("order-status").inner_text(), 40) or "UNKNOWN",
72
+ await _money(page, "order-total"),
73
+ )
agentcart/audit.py ADDED
@@ -0,0 +1,106 @@
1
+ """Append-only, hash-chained audit log of every money-related action.
2
+
3
+ Each line is JSON: {"seq", "ts", "event", "data", "prev", "hash"} where
4
+ hash = sha256(prev || canonical_json(seq, ts, event, data)). The first entry's prev is the vault's
5
+ random genesis value. Any edit, reordering or deletion in the middle breaks the chain; on a broken
6
+ chain auto-pay fails closed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import sys
16
+ from collections.abc import Iterator
17
+ from datetime import UTC, datetime
18
+ from decimal import Decimal
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from . import paths
23
+ from .errors import TamperError
24
+
25
+
26
+ def _canon(obj: Any) -> bytes:
27
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str).encode()
28
+
29
+
30
+ @contextlib.contextmanager
31
+ def _locked(path: Path) -> Iterator[None]:
32
+ lock = path.with_suffix(".lock")
33
+ fd = os.open(lock, os.O_RDWR | os.O_CREAT | paths.O_BINARY, 0o600)
34
+ try:
35
+ if sys.platform == "win32": # pragma: no cover
36
+ import msvcrt
37
+
38
+ os.lseek(fd, 0, os.SEEK_SET)
39
+ msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
40
+ try:
41
+ yield
42
+ finally:
43
+ os.lseek(fd, 0, os.SEEK_SET)
44
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
45
+ else:
46
+ import fcntl
47
+
48
+ fcntl.flock(fd, fcntl.LOCK_EX)
49
+ yield # released on close
50
+ finally:
51
+ os.close(fd)
52
+
53
+
54
+ def read_verified(genesis: str, path: Path | None = None) -> list[dict[str, Any]]:
55
+ path = path or paths.audit_file()
56
+ if not path.exists():
57
+ return []
58
+ entries: list[dict[str, Any]] = []
59
+ prev = genesis
60
+ for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
61
+ if not line.strip():
62
+ continue
63
+ try:
64
+ e = json.loads(line)
65
+ except ValueError as err:
66
+ raise TamperError(f"audit log line {n} is not valid JSON") from err
67
+ body = {k: e.get(k) for k in ("seq", "ts", "event", "data")}
68
+ expect = hashlib.sha256(prev.encode() + _canon(body)).hexdigest()
69
+ if e.get("prev") != prev or e.get("hash") != expect or e.get("seq") != len(entries):
70
+ raise TamperError(f"audit log chain broken at line {n}")
71
+ entries.append(e)
72
+ prev = e["hash"]
73
+ return entries
74
+
75
+
76
+ def append(genesis: str, event: str, data: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
77
+ path = path or paths.audit_file()
78
+ with _locked(path):
79
+ entries = read_verified(genesis, path)
80
+ prev = entries[-1]["hash"] if entries else genesis
81
+ body = {"seq": len(entries), "ts": datetime.now(UTC).isoformat(), "event": event,
82
+ "data": json.loads(_canon(data))}
83
+ entry = {**body, "prev": prev, "hash": hashlib.sha256(prev.encode() + _canon(body)).hexdigest()}
84
+ fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | paths.O_BINARY, 0o600)
85
+ try:
86
+ os.write(fd, _canon(entry) + b"\n")
87
+ os.fsync(fd)
88
+ finally:
89
+ os.close(fd)
90
+ return entry
91
+
92
+
93
+ def autopay_spend(entries: list[dict[str, Any]], now: datetime | None = None) -> tuple[Decimal, int]:
94
+ """Return (INR spent via auto-pay this calendar month, auto-pay orders in the last 24h)."""
95
+ now = now or datetime.now(UTC)
96
+ month_total, day_count = Decimal("0"), 0
97
+ for e in entries:
98
+ if e["event"] not in ("autopay_attempt",):
99
+ continue
100
+ ts = datetime.fromisoformat(e["ts"])
101
+ amount = Decimal(str(e["data"]["amount"]))
102
+ if (ts.year, ts.month) == (now.year, now.month):
103
+ month_total += amount
104
+ if (now - ts).total_seconds() < 86400:
105
+ day_count += 1
106
+ return month_total, day_count
agentcart/browser.py ADDED
@@ -0,0 +1,139 @@
1
+ """Hardened Playwright browser sessions.
2
+
3
+ * one persistent profile per site, stored on-device (0700) -- you log in once yourself with
4
+ 'agentcart login <site>'; agentcart never sees or stores your password
5
+ * headed (visible) by default so you can always watch what the agent does
6
+ * main-frame navigations outside the adapter's own domains (plus known payment gateways) are blocked
7
+ * downloads, geolocation, camera, mic, notifications and clipboard are denied
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import ipaddress
14
+ import os
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+ from urllib.parse import urlsplit
19
+
20
+ from playwright.async_api import BrowserContext, Page, Playwright, Route, async_playwright
21
+
22
+ from . import paths
23
+ from .netsafe import dev_loopback_enabled, host_matches
24
+
25
+ # Hosted payment pages Indian merchants commonly redirect to. Navigation to these is allowed.
26
+ PAYMENT_GATEWAYS = (
27
+ "razorpay.com", "payu.in", "payumoney.com", "cashfree.com", "ccavenue.com", "paytm.in",
28
+ "paytm.com", "phonepe.com", "juspay.in", "billdesk.com", "stripe.com", "easebuzz.in",
29
+ )
30
+
31
+
32
+ def _executable() -> str | None:
33
+ explicit = os.environ.get("AGENTCART_CHROMIUM")
34
+ if explicit:
35
+ return explicit
36
+ return None
37
+
38
+
39
+ _MISSING_BROWSER = "Executable doesn't exist"
40
+
41
+
42
+ async def _install_chromium() -> None:
43
+ """One-time download of Playwright's Chromium (fixed command, no user-controlled input)."""
44
+ print("agentcart: first run, downloading Chromium (one time, ~150 MB)...", file=sys.stderr)
45
+ proc = await asyncio.create_subprocess_exec(
46
+ sys.executable, "-m", "playwright", "install", "chromium",
47
+ stdout=sys.stderr, stderr=sys.stderr,
48
+ )
49
+ if await proc.wait() != 0:
50
+ raise RuntimeError("Chromium download failed; run 'uvx playwright install chromium' manually")
51
+
52
+
53
+ @dataclass
54
+ class Session:
55
+ context: BrowserContext
56
+ page: Page
57
+
58
+
59
+ class BrowserManager:
60
+ def __init__(self) -> None:
61
+ self._pw: Playwright | None = None
62
+
63
+ async def _playwright(self) -> Playwright:
64
+ if self._pw is None:
65
+ self._pw = await async_playwright().start()
66
+ return self._pw
67
+
68
+ async def open(self, site: str, domains: tuple[str, ...], headless: bool | None = None) -> Session:
69
+ pw = await self._playwright()
70
+ if headless is None:
71
+ headless = os.environ.get("AGENTCART_HEADLESS") == "1"
72
+ profile = paths.ensure_private_dir(paths.profiles_dir() / site)
73
+ kwargs: dict[str, Any] = {
74
+ "headless": headless,
75
+ "accept_downloads": False,
76
+ "permissions": [],
77
+ "service_workers": "block",
78
+ "locale": "en-IN",
79
+ "timezone_id": "Asia/Kolkata",
80
+ }
81
+ exe = _executable()
82
+ if exe:
83
+ kwargs["executable_path"] = exe
84
+ try:
85
+ ctx = await pw.chromium.launch_persistent_context(str(profile), **kwargs)
86
+ except Exception as e:
87
+ if exe or _MISSING_BROWSER not in str(e):
88
+ raise
89
+ await _install_chromium()
90
+ ctx = await pw.chromium.launch_persistent_context(str(profile), **kwargs)
91
+ allowed = tuple(domains) + PAYMENT_GATEWAYS
92
+
93
+ async def guard(route: Route) -> None:
94
+ req = route.request
95
+ if req.is_navigation_request():
96
+ main = req.frame.parent_frame is None
97
+ # Top-level page: only the store + payment gateways. Iframes (captcha, 3-D Secure,
98
+ # gateway widgets) may be any https origin, never plain http or private hosts.
99
+ ok = (_navigation_allowed(req.url, allowed) if main
100
+ else _navigation_allowed(req.url, None))
101
+ if not ok:
102
+ await route.abort("blockedbyclient")
103
+ return
104
+ await route.continue_()
105
+
106
+ await ctx.route("**/*", guard)
107
+ page = ctx.pages[0] if ctx.pages else await ctx.new_page()
108
+ page.set_default_timeout(20_000)
109
+ return Session(ctx, page)
110
+
111
+ async def close(self) -> None:
112
+ if self._pw is not None:
113
+ await self._pw.stop()
114
+ self._pw = None
115
+
116
+
117
+ def _navigation_allowed(url: str, allowed: tuple[str, ...] | None) -> bool:
118
+ """allowed=None means 'any public https origin' (used for iframes)."""
119
+ parts = urlsplit(url)
120
+ if parts.scheme in ("about", "data") and url.startswith(("about:blank", "data:text/html")):
121
+ return url == "about:blank"
122
+ host = (parts.hostname or "").lower()
123
+ if dev_loopback_enabled():
124
+ try:
125
+ if ipaddress.ip_address(host).is_loopback:
126
+ return parts.scheme in ("http", "https")
127
+ except ValueError:
128
+ if host == "localhost":
129
+ return True
130
+ if parts.scheme != "https" or _is_private_literal(host) or host == "localhost":
131
+ return False
132
+ return allowed is None or any(host_matches(host, d) for d in allowed)
133
+
134
+
135
+ def _is_private_literal(host: str) -> bool:
136
+ try:
137
+ return not ipaddress.ip_address(host.strip("[]")).is_global
138
+ except ValueError:
139
+ return False
agentcart/checkout.py ADDED
@@ -0,0 +1,194 @@
1
+ """Checkout orchestration shared by the MCP server and the CLI. All security decisions live here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import secrets
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from decimal import Decimal
11
+ from typing import Any
12
+
13
+ import segno
14
+
15
+ from . import audit, paths, policy, vault
16
+ from .adapters import for_url
17
+ from .adapters.base import OrderResult, Quote, SiteAdapter, UpiIntent
18
+ from .browser import BrowserManager, Session
19
+ from .errors import AgentCartError, CheckoutError, PolicyViolation
20
+ from .extract import Product, extract_product
21
+ from .netsafe import fetch_html, registrable_host, validate_url_syntax
22
+ from .sanitize import UNTRUSTED_NOTE
23
+
24
+ MAX_SESSIONS = 3
25
+ SESSION_TTL_S = 20 * 60
26
+ MAX_QTY = 10
27
+
28
+
29
+ async def fetch_product(url: str) -> dict[str, Any]:
30
+ res = await fetch_html(url)
31
+ product = extract_product(res.text, res.final_url)
32
+ return {"source_url": res.final_url, "note": UNTRUSTED_NOTE, "untrusted": product.to_public(),
33
+ "found": bool(product.name or product.price)}
34
+
35
+
36
+ @dataclass
37
+ class Checkout:
38
+ id: str
39
+ adapter: SiteAdapter
40
+ session: Session
41
+ host: str
42
+ product_url: str
43
+ quote: Quote
44
+ listed_price: Decimal | None
45
+ upi: UpiIntent | None
46
+ created: float = field(default_factory=time.monotonic)
47
+ state: str = "awaiting_payment"
48
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
49
+
50
+
51
+ def _audit(event: str, data: dict[str, Any]) -> None:
52
+ """Best-effort log when no vault exists yet (Flow 1 works without a vault); strict otherwise."""
53
+ if not vault.exists():
54
+ return
55
+ pol = policy.load()
56
+ if pol.genesis:
57
+ audit.append(pol.genesis, event, data)
58
+
59
+
60
+ class CheckoutManager:
61
+ def __init__(self) -> None:
62
+ self.browser = BrowserManager()
63
+ self.checkouts: dict[str, Checkout] = {}
64
+
65
+ async def _reap(self) -> None:
66
+ now = time.monotonic()
67
+ for cid, c in list(self.checkouts.items()):
68
+ if now - c.created > SESSION_TTL_S and c.state != "paying":
69
+ await self._close(cid)
70
+
71
+ async def _close(self, cid: str) -> None:
72
+ c = self.checkouts.pop(cid, None)
73
+ if c:
74
+ with contextlib.suppress(Exception): # closing a dead browser is fine
75
+ await c.session.context.close()
76
+
77
+ def _get(self, cid: str) -> Checkout:
78
+ c = self.checkouts.get(cid)
79
+ if not c:
80
+ raise CheckoutError("unknown or expired checkout_id; start a new checkout")
81
+ return c
82
+
83
+ async def start(self, url: str, quantity: int) -> dict[str, Any]:
84
+ validate_url_syntax(url)
85
+ if not 1 <= quantity <= MAX_QTY:
86
+ raise CheckoutError(f"quantity must be between 1 and {MAX_QTY}")
87
+ await self._reap()
88
+ if len(self.checkouts) >= MAX_SESSIONS:
89
+ raise CheckoutError("too many open checkouts; cancel one first")
90
+ adapter = for_url(url)
91
+ listed: Product | None = None
92
+ try:
93
+ res = await fetch_html(url)
94
+ listed = extract_product(res.text, res.final_url)
95
+ except AgentCartError:
96
+ listed = None
97
+ if any(c.adapter.name == adapter.name for c in self.checkouts.values()):
98
+ raise CheckoutError(f"a checkout on {adapter.name} is already open; finish or cancel it")
99
+ session = await self.browser.open(adapter.name, adapter.domains)
100
+ try:
101
+ quote = await adapter.prepare_checkout(session.page, url, quantity)
102
+ upi = await adapter.select_upi(session.page, quote)
103
+ except Exception:
104
+ await session.context.close()
105
+ raise
106
+ warnings: list[str] = []
107
+ listed_price = listed.price if listed else None
108
+ if listed_price is not None and quote.items and quote.items[0].unit_price > listed_price:
109
+ warnings.append(f"cart price ₹{quote.items[0].unit_price} is higher than listed ₹{listed_price}")
110
+ if upi:
111
+ if upi.amount != quote.total:
112
+ warnings.append(f"UPI request amount {upi.amount} does not match total {quote.total} — "
113
+ "do NOT pay")
114
+ if (upi.currency or "INR") != "INR":
115
+ warnings.append("UPI request is not in INR — do NOT pay")
116
+ cid = "co_" + secrets.token_urlsafe(9)
117
+ self.checkouts[cid] = Checkout(cid, adapter, session, registrable_host(url), url, quote,
118
+ listed_price, upi)
119
+ _audit("checkout_started", {"id": cid, "site": adapter.name, "total": str(quote.total)})
120
+ out: dict[str, Any] = {
121
+ "checkout_id": cid,
122
+ "site": adapter.name,
123
+ "quote": quote.to_public(),
124
+ "warnings": warnings,
125
+ "how_to_pay": (
126
+ "Flow 1 (default): the browser window is open on the payment page. Scan the UPI QR "
127
+ "there (or the QR below) with your UPI app, CHECK the payee name and amount, and "
128
+ "enter your UPI PIN on your phone. agentcart never sees your PIN."),
129
+ "autopay_available": adapter.supports_autopay,
130
+ }
131
+ if upi:
132
+ qr_path = paths.ensure_private_dir(paths.home() / "qr") / f"{cid}.png"
133
+ qr = segno.make(upi.raw, error="m")
134
+ qr.save(str(qr_path), scale=8)
135
+ out["upi"] = {"payee_vpa": upi.payee_vpa, "payee_name": upi.payee_name,
136
+ "amount": str(upi.amount) if upi.amount is not None else None,
137
+ "qr_png": str(qr_path)}
138
+ return out
139
+
140
+ async def autopay(self, cid: str) -> dict[str, Any]:
141
+ """Flow 2. Every rule is enforced here; any failure falls back to Flow 1."""
142
+ c = self._get(cid)
143
+ async with c.lock:
144
+ if c.state != "awaiting_payment":
145
+ raise CheckoutError(f"checkout is '{c.state}', cannot pay again")
146
+ if not c.adapter.supports_autopay:
147
+ raise PolicyViolation(f"{c.adapter.name} does not support auto-pay; pay with UPI instead")
148
+ pol = policy.load() # verifies signature; TamperError fails closed
149
+ listed_total = (c.listed_price * sum(i.quantity for i in c.quote.items) + c.quote.shipping
150
+ if c.listed_price is not None else Decimal(0))
151
+ d = policy.evaluate_autopay(pol, c.host, c.quote.total, c.quote.currency, listed_total)
152
+ if not d.allowed:
153
+ if pol.genesis:
154
+ audit.append(pol.genesis, "autopay_denied",
155
+ {"id": cid, "amount": str(c.quote.total), "reasons": d.reasons})
156
+ raise PolicyViolation("auto-pay blocked: " + "; ".join(d.reasons) +
157
+ ". You can still pay manually with UPI (Flow 1).")
158
+ # Log BEFORE paying so a crash mid-payment still counts against the caps.
159
+ audit.append(pol.genesis, "autopay_attempt", {"id": cid, "site": c.adapter.name,
160
+ "amount": str(c.quote.total)})
161
+ c.state = "paying"
162
+ try:
163
+ result: OrderResult = await c.adapter.pay_with_balance(c.session.page, c.quote)
164
+ except Exception as e:
165
+ c.state = "unknown"
166
+ audit.append(pol.genesis, "autopay_error", {"id": cid, "error": type(e).__name__})
167
+ raise CheckoutError("payment step failed; check the browser window and your store "
168
+ "account before retrying") from e
169
+ c.state = "paid" if result.order_id else "unknown"
170
+ audit.append(pol.genesis, "autopay_result", {"id": cid, "order_id": result.order_id,
171
+ "status": result.status})
172
+ return {"checkout_id": cid, "order_id": result.order_id, "status": result.status,
173
+ "paid_inr": str(result.total) if result.total is not None else None}
174
+
175
+ async def status(self, cid: str) -> dict[str, Any]:
176
+ c = self._get(cid)
177
+ r = await c.adapter.order_status(c.session.page)
178
+ if r.order_id and c.state == "awaiting_payment":
179
+ c.state = "paid"
180
+ _audit("manual_payment_confirmed", {"id": cid, "order_id": r.order_id})
181
+ return {"checkout_id": cid, "state": c.state, "order_id": r.order_id, "status": r.status}
182
+
183
+ async def cancel(self, cid: str) -> dict[str, Any]:
184
+ c = self._get(cid)
185
+ if c.state == "paying":
186
+ raise CheckoutError("payment in progress; cannot cancel now")
187
+ await self._close(cid)
188
+ _audit("checkout_cancelled", {"id": cid})
189
+ return {"checkout_id": cid, "state": "cancelled"}
190
+
191
+ async def shutdown(self) -> None:
192
+ for cid in list(self.checkouts):
193
+ await self._close(cid)
194
+ await self.browser.close()