diff --git a/rest/python/server/config.py b/rest/python/server/config.py index fcf4b2c..34618b2 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 bb424a7..9e140f7 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 68cc944..ddeecd3 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(