diff --git a/rest/python/server/README.md b/rest/python/server/README.md index 28755a9..130d6dd 100644 --- a/rest/python/server/README.md +++ b/rest/python/server/README.md @@ -100,6 +100,54 @@ uv run simple_happy_path_client.py \ --server_url=http://localhost:8182 ``` +## Using Stripe as Payment Service Provider + +By default, the server accepts Google Pay tokens without contacting a real PSP. +To process payments through Stripe: + +1. Install the Stripe dependency: + + ```shell + uv add stripe + ``` + +2. Set your Stripe secret key and start the server: + + ```shell + STRIPE_SECRET_KEY=sk_test_... uv run server.py \ + --products_db_path=/tmp/ucp_test/products.db \ + --transactions_db_path=/tmp/ucp_test/transactions.db \ + --port=8182 + ``` + +3. Update `routes/discovery_profile.json` with your Stripe publishable key + in the `tokenization_specification` parameters. + +When `STRIPE_SECRET_KEY` is set, the server will create a Stripe PaymentIntent +for each Google Pay token received during checkout completion. + +### Verifying the Stripe integration + +You can verify the Stripe handler works end-to-end without running the full +server. In Stripe test mode (`sk_test_*`), use Stripe's +[test tokens](https://docs.stripe.com/testing#cards) like `tok_visa`: + +```shell +STRIPE_SECRET_KEY=sk_test_... uv run python3 -c " +from payment_handlers.stripe_handler import StripePaymentHandler +handler = StripePaymentHandler() +result = handler.process_token('tok_visa', 3500, 'USD') +print('PaymentIntent:', result) # e.g. pi_3T92T6KCyqI7L8TF0fm9FvWn +" +``` + +This creates a real PaymentIntent for \$35.00 in your Stripe test dashboard. +Other useful test tokens: `tok_mastercard`, `tok_chargeDeclined`, +`tok_chargeDeclinedInsufficientFunds`. + +Without `STRIPE_SECRET_KEY`, the server falls back to accepting tokens without +PSP processing (the default mock behavior). + ## Testing Endpoints The server exposes an additional endpoint for simulation and testing purposes: diff --git a/rest/python/server/payment_handlers/__init__.py b/rest/python/server/payment_handlers/__init__.py new file mode 100644 index 0000000..9261a74 --- /dev/null +++ b/rest/python/server/payment_handlers/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Payment handler implementations for UCP Merchant Server.""" diff --git a/rest/python/server/payment_handlers/stripe_handler.py b/rest/python/server/payment_handlers/stripe_handler.py new file mode 100644 index 0000000..14269f6 --- /dev/null +++ b/rest/python/server/payment_handlers/stripe_handler.py @@ -0,0 +1,176 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Stripe payment handler for processing Google Pay tokens via UCP. + +Demonstrates how to process the encrypted Google Pay credential token +through Stripe as the Payment Service Provider (PSP). This is the +"last mile" of UCP payment processing that the mock handler skips. + +When Google Pay is configured with PAYMENT_GATEWAY tokenization for +Stripe, the credential.token contains a Stripe token (tok_...) that +can be used directly to create a PaymentIntent. + +Usage: + Set STRIPE_SECRET_KEY environment variable and pass + --payment_handler=stripe when starting the server. + + In test mode (STRIPE_SECRET_KEY starts with sk_test_), you can use + Stripe's test tokens like "tok_visa" or "tok_mastercard". +""" + +import logging +import os + +from exceptions import PaymentFailedError + +logger = logging.getLogger(__name__) + + +class StripePaymentHandler: + """Process Google Pay tokens through Stripe. + + In UCP, when a platform (Google) renders the Google Pay payment sheet, + it generates an encrypted payment token using the merchant's Stripe + tokenization configuration. This handler receives that token and + creates a Stripe PaymentIntent to actually charge the customer. + + The token flow: + 1. Discovery profile declares Stripe as the tokenization gateway + 2. Google Pay encrypts card data into a Stripe token + 3. UCP sends the token in credential.token during complete_checkout + 4. This handler creates a PaymentIntent with the token + """ + + def __init__(self): + """Initialize Stripe handler from environment.""" + self.api_key = os.environ.get("STRIPE_SECRET_KEY") + self._stripe = None + + @property + def stripe(self): + """Lazy-load stripe module.""" + if self._stripe is None: + try: + import stripe + + stripe.api_key = self.api_key + self._stripe = stripe + except ImportError as err: + raise PaymentFailedError( + "stripe package is required for Stripe payment handler. " + "Install with: uv add stripe", + code="CONFIGURATION_ERROR", + status_code=500, + ) from err + return self._stripe + + @property + def is_configured(self) -> bool: + """Check if Stripe is properly configured.""" + return bool(self.api_key) + + def process_token( + self, + token: str, + amount: int, + currency: str, + ) -> str: + """Process a Google Pay token through Stripe. + + Args: + token: The credential.token from the UCP payment instrument. + In production, this is an encrypted Google Pay token. In + Stripe test mode, use test tokens like "tok_visa". + amount: Total amount in the smallest currency unit (e.g. cents). + currency: ISO 4217 currency code (e.g. "USD"). + + Returns: + The Stripe PaymentIntent ID on success. + + Raises: + PaymentFailedError: If the payment is declined or fails. + """ + if not self.is_configured: + raise PaymentFailedError( + "Stripe is not configured. Set STRIPE_SECRET_KEY.", + code="CONFIGURATION_ERROR", + status_code=500, + ) + + try: + payment_intent = self.stripe.PaymentIntent.create( + amount=amount, + currency=currency.lower(), + payment_method_data={ + "type": "card", + "card": {"token": token}, + }, + confirm=True, + automatic_payment_methods={ + "enabled": True, + "allow_redirects": "never", + }, + ) + + if payment_intent.status == "succeeded": + logger.info( + "Stripe payment succeeded: %s (amount=%d %s)", + payment_intent.id, + amount, + currency, + ) + return payment_intent.id + + if payment_intent.status == "requires_action": + raise PaymentFailedError( + "Payment requires additional authentication (3DS). " + "This is not supported in headless UCP checkout.", + code="REQUIRES_ACTION", + ) + + raise PaymentFailedError( + f"Payment failed with status: {payment_intent.status}", + code="PAYMENT_FAILED", + ) + + except self.stripe.error.CardError as e: + raise PaymentFailedError( + str(e.user_message or e), + code="CARD_DECLINED", + ) from e + except self.stripe.error.InvalidRequestError as e: + raise PaymentFailedError( + f"Invalid payment request: {e}", + code="INVALID_REQUEST", + status_code=400, + ) from e + except self.stripe.error.RateLimitError as e: + raise PaymentFailedError( + "Payment provider rate limit exceeded. Please retry.", + code="RATE_LIMIT", + status_code=429, + ) from e + except self.stripe.error.APIConnectionError as e: + raise PaymentFailedError( + "Could not connect to payment provider.", + code="PSP_UNAVAILABLE", + status_code=503, + ) from e + except self.stripe.error.StripeError as e: + raise PaymentFailedError( + f"Payment processing error: {e}", + code="PSP_ERROR", + status_code=500, + ) from e diff --git a/rest/python/server/payment_handlers/test_stripe_handler.py b/rest/python/server/payment_handlers/test_stripe_handler.py new file mode 100644 index 0000000..6a7a85f --- /dev/null +++ b/rest/python/server/payment_handlers/test_stripe_handler.py @@ -0,0 +1,246 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the Stripe payment handler. + +These tests use mocks by default (no Stripe key needed). To run the +optional live test against the Stripe test API, set STRIPE_SECRET_KEY: + + STRIPE_SECRET_KEY=sk_test_... uv run pytest payment_handlers/test_stripe_handler.py -v + +The live test creates a real PaymentIntent in Stripe's test environment. +""" + +import os +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +# Ensure the server root is importable. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from exceptions import PaymentFailedError +from payment_handlers.stripe_handler import StripePaymentHandler + + +# --------------------------------------------------------------------------- +# Helpers — fake Stripe error hierarchy for except clauses +# --------------------------------------------------------------------------- + + +class FakeStripeError(Exception): + pass + + +class FakeCardError(FakeStripeError): + def __init__(self, message="", param=None, code=None): + super().__init__(message) + self.user_message = message + + +class FakeInvalidRequestError(FakeStripeError): + pass + + +class FakeRateLimitError(FakeStripeError): + pass + + +class FakeAPIConnectionError(FakeStripeError): + pass + + +def _make_mock_stripe(): + """Build a mock stripe module with real exception classes.""" + mock = MagicMock() + mock.error.StripeError = FakeStripeError + mock.error.CardError = FakeCardError + mock.error.InvalidRequestError = FakeInvalidRequestError + mock.error.RateLimitError = FakeRateLimitError + mock.error.APIConnectionError = FakeAPIConnectionError + return mock + + +# --------------------------------------------------------------------------- +# Unit tests (no Stripe key or package required) +# --------------------------------------------------------------------------- + + +class TestStripeHandlerConfiguration: + """Test handler configuration and gating logic.""" + + def test_not_configured_without_env_var(self, monkeypatch): + monkeypatch.delenv("STRIPE_SECRET_KEY", raising=False) + handler = StripePaymentHandler() + assert handler.is_configured is False + + def test_configured_with_env_var(self, monkeypatch): + monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_fake") + handler = StripePaymentHandler() + assert handler.is_configured is True + + def test_process_token_raises_when_not_configured(self, monkeypatch): + monkeypatch.delenv("STRIPE_SECRET_KEY", raising=False) + handler = StripePaymentHandler() + with pytest.raises(PaymentFailedError, match="not configured"): + handler.process_token("tok_visa", 3500, "USD") + + +class TestStripeHandlerMocked: + """Test payment processing with a mocked Stripe module.""" + + @pytest.fixture(autouse=True) + def _setup(self, monkeypatch): + monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_mock") + self.mock_stripe = _make_mock_stripe() + self.handler = StripePaymentHandler() + self.handler._stripe = self.mock_stripe + + def test_successful_payment(self): + pi = MagicMock() + pi.id = "pi_test_123" + pi.status = "succeeded" + self.mock_stripe.PaymentIntent.create.return_value = pi + + result = self.handler.process_token("tok_visa", 3500, "USD") + + assert result == "pi_test_123" + self.mock_stripe.PaymentIntent.create.assert_called_once_with( + amount=3500, + currency="usd", + payment_method_data={ + "type": "card", + "card": {"token": "tok_visa"}, + }, + confirm=True, + automatic_payment_methods={ + "enabled": True, + "allow_redirects": "never", + }, + ) + + def test_requires_action_raises(self): + pi = MagicMock() + pi.status = "requires_action" + self.mock_stripe.PaymentIntent.create.return_value = pi + + with pytest.raises(PaymentFailedError, match="3DS"): + self.handler.process_token("tok_visa", 1000, "USD") + + def test_unexpected_status_raises(self): + pi = MagicMock() + pi.status = "requires_capture" + self.mock_stripe.PaymentIntent.create.return_value = pi + + with pytest.raises(PaymentFailedError, match="requires_capture"): + self.handler.process_token("tok_visa", 1000, "USD") + + def test_card_error_raises(self): + self.mock_stripe.PaymentIntent.create.side_effect = FakeCardError( + "Your card was declined." + ) + + with pytest.raises(PaymentFailedError, match="declined"): + self.handler.process_token("tok_declined", 1000, "USD") + + def test_rate_limit_error_raises(self): + self.mock_stripe.PaymentIntent.create.side_effect = ( + FakeRateLimitError("rate limit") + ) + + with pytest.raises(PaymentFailedError) as exc_info: + self.handler.process_token("tok_visa", 1000, "USD") + assert exc_info.value.status_code == 429 + + def test_api_connection_error_raises(self): + self.mock_stripe.PaymentIntent.create.side_effect = ( + FakeAPIConnectionError("connection failed") + ) + + with pytest.raises(PaymentFailedError) as exc_info: + self.handler.process_token("tok_visa", 1000, "USD") + assert exc_info.value.status_code == 503 + + def test_invalid_request_error_raises(self): + self.mock_stripe.PaymentIntent.create.side_effect = ( + FakeInvalidRequestError("bad param") + ) + + with pytest.raises(PaymentFailedError) as exc_info: + self.handler.process_token("tok_visa", 1000, "USD") + assert exc_info.value.status_code == 400 + + def test_generic_stripe_error_raises(self): + self.mock_stripe.PaymentIntent.create.side_effect = ( + FakeStripeError("unknown error") + ) + + with pytest.raises(PaymentFailedError) as exc_info: + self.handler.process_token("tok_visa", 1000, "USD") + assert exc_info.value.status_code == 500 + + def test_currency_lowered(self): + pi = MagicMock() + pi.id = "pi_eur" + pi.status = "succeeded" + self.mock_stripe.PaymentIntent.create.return_value = pi + + self.handler.process_token("tok_visa", 2000, "EUR") + + call_kwargs = self.mock_stripe.PaymentIntent.create.call_args[1] + assert call_kwargs["currency"] == "eur" + + +class TestStripeHandlerLazyImport: + """Test that stripe is only imported when needed.""" + + def test_import_error_gives_clear_message(self, monkeypatch): + monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_fake") + handler = StripePaymentHandler() + handler._stripe = None + + with patch.dict("sys.modules", {"stripe": None}): + with pytest.raises(PaymentFailedError, match="stripe package"): + _ = handler.stripe + + +# --------------------------------------------------------------------------- +# Live test (only runs when STRIPE_SECRET_KEY is set) +# --------------------------------------------------------------------------- + +live = pytest.mark.skipif( + not os.environ.get("STRIPE_SECRET_KEY"), + reason="STRIPE_SECRET_KEY not set — skipping live Stripe test", +) + + +@live +class TestStripeHandlerLive: + """Integration tests against the real Stripe test API. + + These create actual PaymentIntents visible in your Stripe dashboard. + Only runs when STRIPE_SECRET_KEY=sk_test_... is set. + """ + + def test_live_payment_with_tok_visa(self): + handler = StripePaymentHandler() + result = handler.process_token("tok_visa", 100, "USD") + assert result.startswith("pi_") + + def test_live_payment_declined(self): + handler = StripePaymentHandler() + with pytest.raises(PaymentFailedError): + handler.process_token("tok_chargeDeclined", 100, "USD") diff --git a/rest/python/server/pyproject.toml b/rest/python/server/pyproject.toml index 4358281..d49d9e5 100644 --- a/rest/python/server/pyproject.toml +++ b/rest/python/server/pyproject.toml @@ -22,6 +22,11 @@ dependencies = [ "httpx>=0.26.0", ] +[project.optional-dependencies] +stripe = [ + "stripe>=7.0.0", +] + [dependency-groups] dev = [ "pytest>=8.0.0", diff --git a/rest/python/server/routes/discovery_profile.json b/rest/python/server/routes/discovery_profile.json index b5971d1..75052fc 100644 --- a/rest/python/server/routes/discovery_profile.json +++ b/rest/python/server/routes/discovery_profile.json @@ -91,8 +91,9 @@ "type": "PAYMENT_GATEWAY", "parameters": [ { - "gateway": "example", - "gatewayMerchantId": "exampleGatewayMerchantId" + "gateway": "stripe", + "stripe:version": "2018-10-31", + "stripe:publishableKey": "{{STRIPE_PUBLISHABLE_KEY}}" } ] } diff --git a/rest/python/server/services/checkout_service.py b/rest/python/server/services/checkout_service.py index 3769aef..a1e6cd8 100644 --- a/rest/python/server/services/checkout_service.py +++ b/rest/python/server/services/checkout_service.py @@ -651,7 +651,7 @@ async def complete_checkout( self._ensure_modifiable(checkout, "complete") # Process Payment - await self._process_payment(payment) + await self._process_payment(payment, checkout) # Validate Fulfillment (Required for completion in this implementation) fulfillment_valid = False @@ -1160,7 +1160,11 @@ async def _recalculate_totals( checkout.totals.append(Total(type="total", amount=grand_total)) - async def _process_payment(self, payment: PaymentCreateRequest) -> None: + async def _process_payment( + self, + payment: PaymentCreateRequest, + checkout: Checkout, + ) -> None: """Validate and process payment instruments.""" instruments = payment.instruments if not instruments: @@ -1237,7 +1241,28 @@ async def _process_payment(self, payment: PaymentCreateRequest) -> None: f"Unknown mock token: {token}", code="UNKNOWN_TOKEN" ) elif handler_id == "google_pay": - # Accept any token for now, or specific ones + # Route to Stripe PSP if configured, otherwise accept any token. + # This demonstrates the "last mile" of payment processing: + # forwarding the Google Pay credential token to a real PSP. + from payment_handlers.stripe_handler import StripePaymentHandler + + stripe_handler = StripePaymentHandler() + if stripe_handler.is_configured: + # Extract total amount from checkout + total_amount = 0 + if checkout.totals: + for total in checkout.totals: + if total.type == "total": + total_amount = total.amount + break + currency = checkout.currency or "USD" + stripe_handler.process_token(token, total_amount, currency) + return + # No Stripe configured — accept token as before (mock mode) + logger.info( + "No STRIPE_SECRET_KEY configured; accepting Google Pay " + "token without PSP processing." + ) return elif handler_id == "shop_pay": # For shop_pay, we expect a 'shop_token' credential type.