From 531f0f79264e90b47e7adf86d6eb93c32b3d7a9d Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Fri, 31 Jul 2026 17:14:48 -0400 Subject: [PATCH] fix(rest/python): determine currency server side instead of reading it from the create request A checkout create that follows the schema returns 500. checkout.json annotates `currency` with `ucp_request: omit`, so a conformant platform does not send it, and the generated CheckoutCreateRequest has no such field. create_checkout reads `checkout_req.currency` regardless, which raises AttributeError on the pydantic extra lookup and surfaces as Internal Server Error. Reproduction against main, with the minimal conformant body: POST /checkout-sessions {"line_items": [{"item": {"id": "bouquet_roses"}, "quantity": 1}]} -> 500 The same request with `"currency": "USD"` added returns 201, because extra="allow" makes the attribute resolve. The update path had the same read at checkout_service.py:432 and failed the same way. Since the spec assigns the determination to the business, an update no longer takes currency from the request either. The Node sample already sets currency server side, so this brings the Python sample in line with it. Adds two integration tests covering create and update with the conformant body. The existing tests all build payloads through _create_checkout_payload, which sets id and currency, so this path was never exercised. --- rest/python/server/config.py | 12 +++++ rest/python/server/integration_test.py | 44 +++++++++++++++++++ .../server/services/checkout_service.py | 8 ++-- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/rest/python/server/config.py b/rest/python/server/config.py index fcf4b2c2..34618b29 100644 --- a/rest/python/server/config.py +++ b/rest/python/server/config.py @@ -26,6 +26,18 @@ _SERVER_VERSION_CACHE = None +# checkout.json annotates `currency` with `ucp_request: omit` and describes +# it as "reflecting the merchant's market determination ... buyers provide +# signals, merchants determine currency". A conformant platform therefore does +# not send it, and the generated CheckoutCreateRequest has no such field. This +# sample serves a single market, so the determination is a constant. +DEFAULT_CURRENCY = "USD" + + +def get_default_currency() -> str: + """Return the currency this business trades in.""" + return DEFAULT_CURRENCY + def get_server_version() -> str: """Read and cache the server version from the discovery profile.""" diff --git a/rest/python/server/integration_test.py b/rest/python/server/integration_test.py index bb424a74..9e140f72 100644 --- a/rest/python/server/integration_test.py +++ b/rest/python/server/integration_test.py @@ -937,6 +937,50 @@ def test_profile_includes_cache_control_header(self) -> None: for forbidden in ("private", "no-store", "no-cache"): self.assertNotIn(forbidden, directives) + def test_create_omitting_server_determined_fields(self) -> None: + """A conformant create sends only line_items. + + checkout.json marks currency (and id, status, totals, links) with + `ucp_request: omit`, describing currency as derived from address, context + and geo IP because merchants determine it. The generated + CheckoutCreateRequest carries no currency field for that reason, so a + platform following the schema sends neither. + + The other tests build their payload with _create_checkout_payload, which + sets id and currency; extra="allow" keeps them, so checkout_req.currency + always resolves and this path is never exercised. + """ + with self.client: + response = self.client.post( + "/checkout-sessions", + headers=self._get_headers(idempotency_key="omit1", request_id="omit1"), + json={"line_items": [{"item": {"id": "rose"}, "quantity": 1}]}, + ) + self.assertEqual(response.status_code, 201, f"Response: {response.text}") + body = response.json() + self.assertIsInstance( + body.get("currency"), + str, + "server must determine a currency when the platform omits it", + ) + + def test_update_omitting_server_determined_fields(self) -> None: + """The update path reads the same omitted field and must not fail.""" + with self.client: + created = self.client.post( + "/checkout-sessions", + headers=self._get_headers(idempotency_key="omit2", request_id="omit2"), + json={"line_items": [{"item": {"id": "rose"}, "quantity": 1}]}, + ) + self.assertEqual(created.status_code, 201, f"Response: {created.text}") + checkout_id = self.get_resource_id(created.json()["id"]) + updated = self.client.put( + f"/checkout-sessions/{checkout_id}", + headers=self._get_headers(idempotency_key="omit3", request_id="omit3"), + json={"line_items": [{"item": {"id": "rose"}, "quantity": 2}]}, + ) + self.assertEqual(updated.status_code, 200, f"Response: {updated.text}") + if __name__ == "__main__": absltest.main() diff --git a/rest/python/server/services/checkout_service.py b/rest/python/server/services/checkout_service.py index 68cc9444..ddeecd32 100644 --- a/rest/python/server/services/checkout_service.py +++ b/rest/python/server/services/checkout_service.py @@ -307,7 +307,7 @@ async def create_checkout( ), id=checkout_id, status=CheckoutStatus.IN_PROGRESS, - currency=checkout_req.currency, + currency=config.get_default_currency(), line_items=line_items, totals=[ {"type": "subtotal", "amount": 0}, @@ -429,8 +429,10 @@ async def update_checkout( ) existing.line_items = line_items - if checkout_req.currency: - existing.currency = checkout_req.currency + # `currency` carries `ucp_request: omit`, so the business determines it + # and an update never takes it from the request. Reading it here also + # raised AttributeError whenever a conformant platform omitted it, which + # is the same defect as the create path. if checkout_req.payment: existing.payment = PaymentResponse(