Skip to content

Commit 8433980

Browse files
feat(gateway): session state machine, signed checkout, Razorpay payment links, ACP adapter (#5)
- FastAPI gateway with network manifests (/.well-known/bazaar, /.well-known/ucp), merchant manifests/catalog/llms.txt/exports and deterministic discovery ranked by stock, serviceability, budget fit, readiness and ETA. - RFC 9421 signature identification on every request (unsigned = tier 0); money endpoints require the agent-pay tag and name the failed step on 401. - Sessions: open -> ready_for_payment -> in_progress -> completed, with review-first parking, cancel, replay; Request-Id/API-Version headers and Idempotency-Key replay on completion. - Checkout runs the policy gate, holds stock, issues a Razorpay UPI payment link; payment.captured webhooks (signature-verified, idempotent) complete the order, commit stock, consume the grant and write fairness-ledger rows. - Merchant console API: compile CSV -> review queue -> publish, policy, kill switch, offer rules gated by the fairness auditor, audit timeline, stats. - ACP adapter: checkout_sessions create/update/complete/cancel/get and delegate_payment (grant + delegated buyer key) so ChatGPT-style buyers pass through the same mandates and policy checks. - BuyerAgentClient helper for signed calls and mandate creation.
1 parent 59ee05f commit 8433980

13 files changed

Lines changed: 1352 additions & 0 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ RAZORPAY_WEBHOOK_SECRET=
1414
BAZAAR_BASE_URL=http://localhost:8000
1515
BAZAAR_DB_URL=sqlite:///data/runtime/bazaar.db
1616
BAZAAR_API_VERSION=2026-08-28
17+
BAZAAR_ADMIN_TOKEN=dev-admin-token

bazaar/gateway/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from bazaar.gateway.app import create_app
2+
from bazaar.gateway.state import BazaarState
3+
4+
__all__ = ["create_app", "BazaarState"]

bazaar/gateway/adapters/__init__.py

Whitespace-only changes.

bazaar/gateway/adapters/acp.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
"""Agentic Commerce Protocol (OpenAI/Stripe) adapter.
2+
3+
Maps ACP checkout sessions onto Bazaar sessions so a ChatGPT-style buyer works unchanged:
4+
5+
* ``POST /acp/{merchant}/checkout_sessions`` create (items + fulfillment address)
6+
* ``POST /acp/{merchant}/checkout_sessions/{id}`` update items/address
7+
* ``POST /acp/{merchant}/checkout_sessions/{id}/complete`` pay with a delegated payment token
8+
* ``POST /acp/{merchant}/checkout_sessions/{id}/cancel``
9+
* ``GET /acp/{merchant}/checkout_sessions/{id}``
10+
* ``POST /acp/{merchant}/delegate_payment`` ACP "delegated payment": buyer authorises
11+
via the platform; Bazaar issues a Scoped Payment Grant and holds a delegated buyer key so it
12+
can close AP2-shaped mandates on the buyer's behalf — the same policy gate then applies.
13+
14+
Statuses follow ACP: ``not_ready_for_payment | ready_for_payment | in_progress | completed | canceled``.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import json
20+
from typing import Any
21+
22+
from fastapi import APIRouter, HTTPException, Request, Response
23+
from pydantic import BaseModel, Field
24+
25+
from bazaar.gateway.auth import identify
26+
from bazaar.gateway.checkout import cancel_session, complete_session
27+
from bazaar.gateway.sessions import Session
28+
from bazaar.seller_agent.offer_engine import Quote
29+
from bazaar.trust import keys
30+
from bazaar.trust.http_sig import TAG_PAY
31+
from bazaar.trust.mandates import CheckoutMandate, PaymentMandate
32+
33+
router = APIRouter(prefix="/acp", tags=["acp"])
34+
35+
36+
class Item(BaseModel):
37+
id: str
38+
quantity: int = Field(ge=1)
39+
40+
41+
class Address(BaseModel):
42+
name: str = ""
43+
line_one: str = ""
44+
city: str = ""
45+
state: str = ""
46+
country: str = "IN"
47+
postal_code: str = ""
48+
49+
50+
class CreateIn(BaseModel):
51+
items: list[Item]
52+
fulfillment_address: Address | None = None
53+
buyer: dict[str, Any] | None = None
54+
55+
56+
class UpdateIn(BaseModel):
57+
items: list[Item] | None = None
58+
fulfillment_address: Address | None = None
59+
60+
61+
class DelegateIn(BaseModel):
62+
buyer_ref: str
63+
allowance: dict[str, Any] # {"max_amount": paise, "expires_in_minutes": 30}
64+
65+
66+
class CompleteIn(BaseModel):
67+
payment_data: dict[str, Any] # {"token": "spg_...", "provider": "razorpay"}
68+
buyer: dict[str, Any] | None = None
69+
human_confirmation: bool = True
70+
71+
72+
def _state(request: Request):
73+
return request.app.state.bazaar
74+
75+
76+
def _acp_status(s: Session) -> str:
77+
return {"open": "not_ready_for_payment", "ready_for_payment": "ready_for_payment", "awaiting_merchant_review": "in_progress", "in_progress": "in_progress", "completed": "completed", "canceled": "canceled", "declined": "canceled"}[s.status]
78+
79+
80+
def _render(s: Session, m) -> dict[str, Any]:
81+
q = Quote.model_validate(s.quote) if s.quote else None
82+
line_items = []
83+
totals = []
84+
if q:
85+
line_items = [{"id": ln.sku, "item": {"id": ln.sku, "quantity": ln.qty}, "base_amount": ln.unit_price_paise * ln.qty, "discount": 0, "subtotal": ln.subtotal_paise, "tax": ln.gst_paise, "total": ln.subtotal_paise + ln.gst_paise} for ln in q.lines]
86+
totals = [
87+
{"type": "items_base_amount", "display_text": "Items", "amount": q.subtotal_paise},
88+
{"type": "items_discount", "display_text": "Discount", "amount": q.discount_paise},
89+
{"type": "subtotal", "display_text": "Subtotal", "amount": q.subtotal_paise - q.discount_paise},
90+
{"type": "fulfillment", "display_text": "Delivery", "amount": q.delivery_fee_paise},
91+
{"type": "tax", "display_text": "GST", "amount": q.gst_paise},
92+
{"type": "total", "display_text": "Total", "amount": q.total_paise},
93+
]
94+
msgs = []
95+
if s.status == "declined":
96+
msgs.append({"type": "error", "code": "policy_declined", "content": "; ".join(c["name"] for c in s.last_checks if not c["passed"])})
97+
return {
98+
"id": s.session_id,
99+
"status": _acp_status(s),
100+
"currency": "inr",
101+
"line_items": line_items,
102+
"totals": totals,
103+
"fulfillment_options": [{"type": "shipping", "id": "standard", "title": f"Delivery in ~{q.eta_hours} h", "subtotal": q.delivery_fee_paise, "total": q.delivery_fee_paise}] if q else [],
104+
"payment_provider": {"provider": "razorpay", "supported_payment_methods": ["upi", "card"]},
105+
"order": {"id": s.order_id, "checkout_session_id": s.session_id, "permalink_url": s.payment_url} if s.order_id else None,
106+
"messages": msgs,
107+
"extensions": {"in.razorpay.bazaar.india": {"pincode": q.pincode if q else "", "cod_allowed": q.cod_allowed if q else False, "gst_paise": q.gst_paise if q else 0}},
108+
}
109+
110+
111+
def _quote_for(st, s: Session, items: list[Item], pincode: str) -> None:
112+
tools = st.agent(s.merchant_id).tools
113+
r = tools.quote([{"sku": i.id, "qty": i.quantity} for i in items], pincode, s.segment.value)
114+
if not r.ok:
115+
raise HTTPException(422, detail={"type": "invalid_request", "code": "quote_failed", "message": r.reason})
116+
s.quote = r.result
117+
s.state.update({"quote_id": r.result["quote_id"], "pincode": pincode})
118+
s.status = "ready_for_payment" if pincode else "open"
119+
st.audit.record({"session": s.session_id, "kind": "acp", "action": "quote", "outcome": "ok", "note": f"{len(items)} item(s) to {pincode or '?'}"})
120+
121+
122+
@router.post("/{merchant_id}/checkout_sessions", status_code=201)
123+
async def create(merchant_id: str, body: CreateIn, request: Request):
124+
st = _state(request)
125+
caller = await identify(request, st)
126+
m = st.merchant(merchant_id)
127+
if m is None:
128+
raise HTTPException(404, detail={"type": "invalid_request", "code": "merchant_not_found"})
129+
if m.policy.kill_switch:
130+
raise HTTPException(409, detail={"type": "invalid_request", "code": "merchant_unavailable"})
131+
s = st.new_session(merchant_id=merchant_id, agent_keyid=caller.keyid, tier=caller.tier, source="acp")
132+
_quote_for(st, s, body.items, body.fulfillment_address.postal_code if body.fulfillment_address else "")
133+
return _render(s, m)
134+
135+
136+
@router.get("/{merchant_id}/checkout_sessions/{sid}")
137+
def get(merchant_id: str, sid: str, request: Request):
138+
st = _state(request)
139+
s = st.session(sid)
140+
if s is None or s.merchant_id != merchant_id:
141+
raise HTTPException(404, detail={"type": "invalid_request", "code": "session_not_found"})
142+
return _render(s, st.merchant(merchant_id))
143+
144+
145+
@router.post("/{merchant_id}/checkout_sessions/{sid}")
146+
async def update(merchant_id: str, sid: str, body: UpdateIn, request: Request):
147+
st = _state(request)
148+
s = st.session(sid)
149+
if s is None or s.merchant_id != merchant_id:
150+
raise HTTPException(404, detail={"type": "invalid_request", "code": "session_not_found"})
151+
if s.status not in ("open", "ready_for_payment"):
152+
raise HTTPException(409, detail={"type": "invalid_request", "code": f"session_{s.status}"})
153+
items = body.items or ([Item(id=ln["sku"], quantity=ln["qty"]) for ln in s.quote["lines"]] if s.quote else [])
154+
pincode = body.fulfillment_address.postal_code if body.fulfillment_address else s.state.get("pincode", "")
155+
_quote_for(st, s, items, pincode)
156+
return _render(s, st.merchant(merchant_id))
157+
158+
159+
@router.post("/{merchant_id}/delegate_payment", status_code=201)
160+
async def delegate_payment(merchant_id: str, body: DelegateIn, request: Request):
161+
"""Buyer (via the platform) authorises the agent to pay this merchant up to an allowance."""
162+
st = _state(request)
163+
caller = await identify(request, st, required_tag=TAG_PAY)
164+
if st.merchant(merchant_id) is None:
165+
raise HTTPException(404, detail={"type": "invalid_request", "code": "merchant_not_found"})
166+
max_amount = int(body.allowance.get("max_amount", 0))
167+
ttl = int(body.allowance.get("expires_in_minutes", 30))
168+
priv = keys.generate()
169+
kid = st.register_buyer_key(keys.b64u(keys.public_bytes(priv)))
170+
st.delegated_buyer_keys[body.buyer_ref] = (kid, priv)
171+
g = st.grants.issue(body.buyer_ref, caller.keyid, merchant_id, max_amount, ttl, single_use=True)
172+
st.audit.record({"session": "", "kind": "acp", "action": "delegate_payment", "outcome": "ok", "money": {"grant_id": g.grant_id, "max_amount_paise": max_amount}, "note": f"buyer {body.buyer_ref} via {caller.operator}"})
173+
return {"id": g.grant_id, "created": g.expires_at.isoformat(), "metadata": {"buyer_ref": body.buyer_ref, "merchant_id": merchant_id, "max_amount": max_amount}}
174+
175+
176+
@router.post("/{merchant_id}/checkout_sessions/{sid}/complete")
177+
async def complete(merchant_id: str, sid: str, body: CompleteIn, request: Request):
178+
st = _state(request)
179+
raw = await request.body()
180+
ik = request.headers.get("idempotency-key")
181+
key = f"acp:{sid}:{ik}" if ik else None
182+
if key and key in st.idempotency:
183+
code, payload = st.idempotency[key]
184+
return Response(json.dumps(payload), status_code=code, media_type="application/json", headers={"Idempotent-Replayed": "true"})
185+
caller = await identify(request, st, required_tag=TAG_PAY)
186+
s = st.session(sid)
187+
if s is None or s.merchant_id != merchant_id:
188+
raise HTTPException(404, detail={"type": "invalid_request", "code": "session_not_found"})
189+
if s.status != "ready_for_payment":
190+
raise HTTPException(409, detail={"type": "invalid_request", "code": f"session_{_acp_status(s)}"})
191+
token = body.payment_data.get("token", "")
192+
g = st.grants.get(token)
193+
if g is None:
194+
raise HTTPException(422, detail={"type": "invalid_request", "code": "invalid_payment_token"})
195+
kid_priv = st.delegated_buyer_keys.get(g.buyer_ref)
196+
if kid_priv is None:
197+
raise HTTPException(422, detail={"type": "invalid_request", "code": "no_delegated_authorization"})
198+
kid, priv = kid_priv
199+
q = Quote.model_validate(s.quote)
200+
cm = CheckoutMandate.open(g.buyer_ref, g.max_amount_paise, pincode=q.pincode, merchant_ids=[merchant_id]).close(q.quote_id, merchant_id, q.total_paise)
201+
cm.sign(priv, kid)
202+
pm = PaymentMandate.open(g.buyer_ref, g.max_amount_paise).close(cm)
203+
pm.sign(priv, kid)
204+
res, s = complete_session(st, s, caller.keyid, token, cm, pm, body.human_confirmation)
205+
payload = _render(s, st.merchant(merchant_id))
206+
payload["policy"] = {"allowed": res.allowed, "checks": [c.model_dump() for c in res.checks]}
207+
code = 200 if res.allowed else 422
208+
if key:
209+
st.idempotency[key] = (code, payload)
210+
return Response(json.dumps(payload, default=str), status_code=code, media_type="application/json")
211+
212+
213+
@router.post("/{merchant_id}/checkout_sessions/{sid}/cancel")
214+
def cancel(merchant_id: str, sid: str, request: Request):
215+
st = _state(request)
216+
s = st.session(sid)
217+
if s is None or s.merchant_id != merchant_id:
218+
raise HTTPException(404, detail={"type": "invalid_request", "code": "session_not_found"})
219+
try:
220+
cancel_session(st, s, "acp cancel")
221+
except ValueError as e:
222+
raise HTTPException(409, detail={"type": "invalid_request", "code": str(e)}) from e
223+
return _render(s, st.merchant(merchant_id))

0 commit comments

Comments
 (0)