From fd2313e7d453d8e2be2309b97e542d13ed21be81 Mon Sep 17 00:00:00 2001 From: jar-stripe Date: Mon, 10 Aug 2026 12:16:19 -0700 Subject: [PATCH 1/5] Add async iteration to v2 list pagination (#1874) Committed-By-Agent: goose --- stripe/v2/_list_object.py | 29 ++++- tests/api_resources/test_list_object_v2.py | 126 +++++++++++++++++++++ 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/stripe/v2/_list_object.py b/stripe/v2/_list_object.py index 8e301edce..08fcbd016 100644 --- a/stripe/v2/_list_object.py +++ b/stripe/v2/_list_object.py @@ -1,5 +1,6 @@ +from stripe._any_iterator import AnyIterator from stripe._stripe_object import StripeObject -from typing import List, Optional, TypeVar, Generic +from typing import AsyncIterator, Iterator, List, Optional, TypeVar, Generic T = TypeVar("T", bound=StripeObject) @@ -40,7 +41,13 @@ def __len__(self): def __reversed__(self): return getattr(self, "data", []).__reversed__() - def auto_paging_iter(self): + def auto_paging_iter(self) -> AnyIterator[T]: + return AnyIterator( + self._auto_paging_iter(), + self._auto_paging_iter_async(), + ) + + def _auto_paging_iter(self) -> Iterator[T]: page = self.data next_page_url = self.next_page_url while True: @@ -57,3 +64,21 @@ def auto_paging_iter(self): assert isinstance(result, ListObject) page = result.data next_page_url = result.next_page_url + + async def _auto_paging_iter_async(self) -> AsyncIterator[T]: + page = self.data + next_page_url = self.next_page_url + while True: + for item in page: + yield item + if next_page_url is None: + break + + result = await self._request_async( + "get", + next_page_url, + base_address="api", + ) + assert isinstance(result, ListObject) + page = result.data + next_page_url = result.next_page_url diff --git a/tests/api_resources/test_list_object_v2.py b/tests/api_resources/test_list_object_v2.py index 7767b6a36..84759e873 100644 --- a/tests/api_resources/test_list_object_v2.py +++ b/tests/api_resources/test_list_object_v2.py @@ -146,3 +146,129 @@ def test_iter_forwards_api_key(self, http_client_mock: HTTPClientMock): query_string=query_string_2, api_key="sk_test_iter_forwards_options", ) + + +class TestAutoPagingAsync: + @staticmethod + def pageable_model_response(ids, next_page_url): + return { + "data": [{"id": id, "object": "pageablemodel"} for id in ids], + "next_page_url": next_page_url, + } + + @pytest.mark.anyio + async def test_iter_one_page(self, http_client_mock): + lo = ListObject.construct_from( + self.pageable_model_response(["pm_123", "pm_124"], None), "mykey" + ) + + http_client_mock.assert_no_request() + + seen = [item["id"] async for item in lo.auto_paging_iter()] + + assert seen == ["pm_123", "pm_124"] + + @pytest.mark.anyio + async def test_iter_two_pages(self, http_client_mock): + method = "get" + path = "/v2/pageablemodels" + + lo = ListObject.construct_from( + self.pageable_model_response( + ["pm_123", "pm_124"], + "/v2/pageablemodels?foo=bar&page=page_2", + ), + None, + ) + + http_client_mock.stub_request( + method, + path=path, + query_string="foo=bar&page=page_3", + rbody=json.dumps( + self.pageable_model_response(["pm_127", "pm_128"], None) + ), + ) + + http_client_mock.stub_request( + method, + path=path, + query_string="foo=bar&page=page_2", + rbody=json.dumps( + self.pageable_model_response( + ["pm_125", "pm_126"], + "/v2/pageablemodels?foo=bar&page=page_3", + ) + ), + ) + + seen = [item["id"] async for item in lo.auto_paging_iter()] + + http_client_mock.assert_requested( + method, path=path, query_string="foo=bar&page=page_2" + ) + http_client_mock.assert_requested( + method, path=path, query_string="foo=bar&page=page_3" + ) + + assert seen == [ + "pm_123", + "pm_124", + "pm_125", + "pm_126", + "pm_127", + "pm_128", + ] + + @pytest.mark.anyio + async def test_iter_forwards_api_key( + self, http_client_mock: HTTPClientMock + ): + client = stripe.StripeClient( + http_client=http_client_mock.get_mock_http_client(), + api_key="sk_test_xyz", + ) + + method = "get" + query_string_1 = "object_id=obj_123" + query_string_2 = "object_id=obj_123&page=page_2" + path = "/v2/core/events" + + http_client_mock.stub_request( + method, + path=path, + query_string=query_string_1, + rbody='{"data": [{"id": "x"}], "next_page_url": "/v2/core/events?object_id=obj_123&page=page_2"}', + rcode=200, + rheaders={}, + ) + + http_client_mock.stub_request( + method, + path=path, + query_string=query_string_2, + rbody='{"data": [{"id": "y"}, {"id": "z"}], "next_page_url": null}', + rcode=200, + rheaders={}, + ) + + lo = await client.v2.core.events.list_async( + params={"object_id": "obj_123"}, + options={"api_key": "sk_test_iter_forwards_options"}, + ) + + seen = [item["id"] async for item in lo.auto_paging_iter()] + + assert seen == ["x", "y", "z"] + http_client_mock.assert_requested( + method, + path=path, + query_string=query_string_1, + api_key="sk_test_iter_forwards_options", + ) + http_client_mock.assert_requested( + method, + path=path, + query_string=query_string_2, + api_key="sk_test_iter_forwards_options", + ) From 3f562ca72ce8c00e668fac2d8f184420565cb02e Mon Sep 17 00:00:00 2001 From: David Brownman Date: Mon, 10 Aug 2026 15:08:16 -0700 Subject: [PATCH 2/5] Bump version to 15.5.0 --- CHANGELOG.md | 16 ++++++++++++++++ VERSION | 2 +- pyproject.toml | 2 +- stripe/_version.py | 2 +- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f25c1887..c0dd72c4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## 15.5.0 - 2026-08-10 +* [#1874](https://github.com/stripe/stripe-python/pull/1874) Add async iteration to v2 list auto-pagination + - Adds `async for` support to v2 `ListObject.auto_paging_iter()`. +* [#1869](https://github.com/stripe/stripe-python/pull/1869) Surface `object` property on `EventNotification` +* [#1867](https://github.com/stripe/stripe-python/pull/1867) Emit Claude Code plugin hint at module load time + - Emits new Claude Code plugin hint when `CLAUDECODE` or `CLAUDE_CODE_CHILD_SESSION` environment variables are detected. +* [#1855](https://github.com/stripe/stripe-python/pull/1855) add/adjust event parsing helpers + + - Added methods that return their respective `Event`/`EventNotification` class instances without verifying authenticity. Use them when you've previously verified an event (e.g. you verified, put the event in a queue, and are now processing). Supports events from [AWS EventBridge](https://docs.stripe.com/event-destinations/eventbridge) and [Azure Event Grid](https://docs.stripe.com/event-destinations/eventgrid) natively. + - `Webhook.construct_event_without_verification(payload)` + - `StripeClient.construct_event_without_verification(payload)` + - `StripeClient.parse_event_notification_without_verification(payload)` + - Added `WebhookSignature.generate_signature_header(payload, secret, timestamp=None)`, which computes a full `Stripe-Signature` header for the given payload. Useful for unit tests! + +* [#1863](https://github.com/stripe/stripe-python/pull/1863) Add `stripe.major_api_version` constant + ## 15.4.0 - 2026-07-29 This release changes the pinned API version to 2026-07-29.dahlia. diff --git a/VERSION b/VERSION index c915b5db7..188dd74f5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -15.4.0 +15.5.0 diff --git a/pyproject.toml b/pyproject.toml index 6fb9df204..7f508ea08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stripe" -version = "15.4.0" +version = "15.5.0" readme = "README.md" description = "Python bindings for the Stripe API" authors = [{ name = "Stripe", email = "support@stripe.com" }] diff --git a/stripe/_version.py b/stripe/_version.py index ea5978f38..bb5abc68a 100644 --- a/stripe/_version.py +++ b/stripe/_version.py @@ -1 +1 @@ -VERSION = "15.4.0" +VERSION = "15.5.0" From 74966115e74f146ec34ea4c0bb8a8587633abebb Mon Sep 17 00:00:00 2001 From: David Brownman <1231935+xavdid@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:44:13 -0700 Subject: [PATCH 3/5] better document `StripeObject`'s `to_dict` behavior (#1879) * better document stripeobject's to_dict behavior * tweak some error messages --- README.md | 35 +++++++++++++++++++ stripe/_stripe_object.py | 52 ++++++++++++++++++++++++++-- tests/test_stripe_object.py | 68 +++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0f2b8c940..f6929e52c 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,41 @@ customer = client.v1.customers.retrieve("cus_123456789") print(customer.email) ``` +### Working with API resources + +Every API resource is a subclass of `StripeObject`. It is **not** a `dict`, even though printing one shows a dict-like representation. Having our own class means property names (like `subscription.items`) never collide with builtin methods. + +You can access properties in a variety of ways: + +```python +customer = client.v1.customers.retrieve("cus_123456789") + +customer.email # attribute access +customer["email"] # subscript access +"email" in customer # membership +getattr(customer, "discount", None) # tolerate a field that may be absent +``` + +Though `StripeObject` is not a `dict`, there are helper methods to let you do operations you'd commonly do with a `dict`. Say you have the following (example) object: + +```py +obj = Customer(id='cus_123', subscription=Subscription(id='sub_456', amount=Decimal('7.89')) +``` + +Here's how to accomplish each of these use cases: + +| Use Case | Method | Result | +| ------------------------------------------------------------------------------------ | ------------------------------ | --------------------------------------------------------------------------------------- | +| Recursively iterate over a `StripeObject` where are values are native Python classes | `obj.to_dict()` | `{"id": "cus_123", "subscription": {"id": "sub_456", 'amount': Decimal('7.89')}}` | +| Iterate over the top-level of a `StripeObject` | `obj.to_dict(recursive=False)` | `{"id": "cus_123", "subscription": Subscription(id="sub_456", amount=Decimal("7.89"))}` | +| Get a plain `dict` where all values (in the entire tree) are JSON-serializable | `obj.to_dict(for_json=True)` | `{"id": "cus_123", "subscription": {"id": "sub_456", "amount": "7.89"}}` | +| Dump the object to a json string | `str(obj)` | `'{"id": "cus_123", "subscription": {"id": "sub_456", "amount": "7.89"}}'` | + +In each case, `.to_dict()` **returns a copy** of the original object, so changes to the dict are not reflected in `obj`. + +> [!NOTE] +> See the [original migration guide](https://github.com/stripe/stripe-python/wiki/Migration-guide-for-v15#stripeobject-no-longer-inherits-from-dict), [RFC](https://github.com/stripe/stripe-python/issues/1454), and [PR](https://github.com/stripe/stripe-python/pull/1762) for more information. + ### StripeClient vs legacy pattern We introduced the `StripeClient` class in v8 of the Python SDK. The legacy pattern used prior to that version is still available to use but will be marked as deprecated soon. Review the [migration guide to use StripeClient]() to move from the legacy pattern. diff --git a/stripe/_stripe_object.py b/stripe/_stripe_object.py index c086fc272..f29bbce42 100644 --- a/stripe/_stripe_object.py +++ b/stripe/_stripe_object.py @@ -1,7 +1,14 @@ # pyright: strict import json from copy import deepcopy -from typing_extensions import TYPE_CHECKING, Type, Literal, Self, deprecated +from typing_extensions import ( + TYPE_CHECKING, + NoReturn, + Type, + Literal, + Self, + deprecated, +) from typing import ( Any, Dict, @@ -86,6 +93,20 @@ def _serialize_list( class StripeObject: + """ + The base class for every response returned by the Stripe API. + + A `StripeObject` is **not** a `dict` even though `str()` on one prints JSON. It deliberately keeps a small surface so that API fields never collide with `dict` method names (for example, `Subscription.items` is the API's `items` field, not `dict.items`). + + If you want to do dict operations, on a StripeObject, call `.to_dict()` first. See [the readme](https://github.com/stripe/stripe-python#working-with-api-resources) for more information. + """ + + # Names we know people reach for out of dict habit. Used to give a pointed + # error instead of a bare `AttributeError: get`. + _DICT_METHOD_NAMES = frozenset( + {"get", "keys", "values", "items", "pop", "setdefault"} + ) + _retrieve_params: Mapping[str, Any] _previous: Optional[Mapping[str, Any]] @@ -167,9 +188,17 @@ def __getattr__(self, k): try: if k in self._field_remappings: - k = self._field_remappings[k] - return self[k] + key = self._field_remappings[k] + else: + key = k + return self[key] except KeyError as err: + # Stays an AttributeError (rather than becoming a TypeError) so + # that hasattr() and getattr(obj, "get", None) keep working. + if k in self._DICT_METHOD_NAMES: + raise AttributeError( + f"'{k}' is a dict method, but a {type(self).__name__} is not a dict. Use .to_dict() to convert it. Docs: https://github.com/stripe/stripe-python#working-with-api-resources" + ) from err raise AttributeError(*err.args) from err def __delattr__(self, k): @@ -233,6 +262,23 @@ def __delitem__(self, k: str) -> None: def __contains__(self, k: object) -> bool: return k in self._data + # Defining __getitem__ without __iter__ makes dict(obj), list(obj), and + # `for k in obj` fall back to Python's legacy *sequence* protocol, which asks + # for obj[0] and surfaces a baffling "KeyError: 0". Raising here names the + # actual problem instead. This can't collide with an API field name; + # subclasses that are genuinely iterable (ListObject, SearchResultObject) + # override it. + # + # Hidden from type checkers so that they still report iterating a + # StripeObject as an error, and so the iterable subclasses don't look like + # incompatible overrides of a NoReturn method. + if not TYPE_CHECKING: + + def __iter__(self) -> NoReturn: + raise TypeError( + f"{type(self).__name__} is not iterable or a mapping; call .to_dict() for a plain dict. Docs: https://github.com/stripe/stripe-python#working-with-api-resources" + ) + def __eq__(self, other: object) -> bool: if isinstance(other, StripeObject): return type(self) is type(other) and self._data == other._data diff --git a/tests/test_stripe_object.py b/tests/test_stripe_object.py index 23d0d1c27..d73d79869 100644 --- a/tests/test_stripe_object.py +++ b/tests/test_stripe_object.py @@ -723,6 +723,74 @@ def test_items_field_not_shadowed_by_dict_items(self): ) assert isinstance(obj.items, stripe.ListObject) + @pytest.fixture + def session(self): + return stripe.checkout.Session.construct_from( + { + "id": "cs_1", + "object": "checkout.session", + "metadata": {"a": "1"}, + }, + "key", + ) + + def test_dict_conversion_raises_type_error(self, session): + with pytest.raises(TypeError) as e: + dict(session.metadata) + assert "not iterable or a mapping" in str(e.value) + assert "to_dict()" in str(e.value) + + def test_list_conversion_raises_type_error(self, session): + with pytest.raises(TypeError, match="not iterable or a mapping"): + list(session.metadata) + + def test_iteration_raises_type_error(self, session): + with pytest.raises(TypeError, match="not iterable or a mapping"): + for _ in session.metadata: + pass + + def test_iteration_error_names_the_subclass(self, session): + with pytest.raises(TypeError, match="^Session is not iterable"): + iter(session) + + @pytest.mark.parametrize( + "name", ["get", "keys", "values", "items", "pop", "setdefault"] + ) + def test_dict_methods_get_a_helpful_attribute_error(self, session, name): + with pytest.raises(AttributeError) as e: + getattr(session.metadata, name) + assert f"'{name}' is a dict method" in str(e.value) + assert "to_dict()" in str(e.value) + + def test_dict_method_hint_remains_an_attribute_error(self, session): + """ + hasattr() and getattr() with a default must keep working, which they + only do for AttributeError (not TypeError). + """ + assert not hasattr(session.metadata, "get") + assert getattr(session.metadata, "get", None) is None + + def test_field_named_like_dict_method_still_wins(self): + obj = StripeObject.construct_from( + {"get": "a", "keys": "b", "values": "c", "pop": "d"}, "key" + ) + assert obj.get == "a" + assert obj.keys == "b" + assert obj.values == "c" + assert obj.pop == "d" + + def test_list_object_is_still_iterable(self): + obj = StripeObject.construct_from( + { + "id": "sub_123", + "object": "subscription", + "items": {"object": "list", "data": [{"id": "si_123"}]}, + }, + "key", + ) + assert [item.id for item in obj.items] == ["si_123"] + assert len(obj.items) == 1 + def test_to_dict(self): obj = StripeObject.construct_from( {"id": "foo", "name": "bar"}, From c09975e059ab5b5a50875ca9d3e7ceff51b69360 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Tue, 18 Aug 2026 16:49:28 -0700 Subject: [PATCH 4/5] Bump version to 15.5.1 --- CHANGELOG.md | 3 +++ VERSION | 2 +- pyproject.toml | 2 +- stripe/_version.py | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0dd72c4c..0bcc00340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 15.5.1 - 2026-08-18 +* [#1879](https://github.com/stripe/stripe-python/pull/1879) better document `StripeObject`'s `to_dict` behavior + ## 15.5.0 - 2026-08-10 * [#1874](https://github.com/stripe/stripe-python/pull/1874) Add async iteration to v2 list auto-pagination - Adds `async for` support to v2 `ListObject.auto_paging_iter()`. diff --git a/VERSION b/VERSION index 188dd74f5..2e0b428c4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -15.5.0 +15.5.1 diff --git a/pyproject.toml b/pyproject.toml index 7f508ea08..8d88eba7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stripe" -version = "15.5.0" +version = "15.5.1" readme = "README.md" description = "Python bindings for the Stripe API" authors = [{ name = "Stripe", email = "support@stripe.com" }] diff --git a/stripe/_version.py b/stripe/_version.py index bb5abc68a..ee2c80728 100644 --- a/stripe/_version.py +++ b/stripe/_version.py @@ -1 +1 @@ -VERSION = "15.5.0" +VERSION = "15.5.1" From 188dce631e58bec76563a93a2bc333d3543cfc27 Mon Sep 17 00:00:00 2001 From: David Brownman <1231935+xavdid@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:45:04 -0700 Subject: [PATCH 5/5] copy eventnotification handler code from beta (#1881) --- .../event_notification_handler_endpoint.py | 73 ++ stripe/__init__.py | 17 + stripe/_event_notification_handler.py | 548 ++++++++++++ stripe/_stripe_client.py | 50 ++ tests/test_event_notification_handler.py | 827 ++++++++++++++++++ 5 files changed, 1515 insertions(+) create mode 100644 examples/event_notification_handler_endpoint.py create mode 100644 stripe/_event_notification_handler.py create mode 100644 tests/test_event_notification_handler.py diff --git a/examples/event_notification_handler_endpoint.py b/examples/event_notification_handler_endpoint.py new file mode 100644 index 000000000..2cafda15d --- /dev/null +++ b/examples/event_notification_handler_endpoint.py @@ -0,0 +1,73 @@ +""" +event_notification_handler_endpoint.py - receive and process event notifications (AKA thin events) like "v1.billing.meter.error_report_triggered" using EventNotificationHandler. + +In this example, we: + - write a fallback callback to handle unrecognized event notifications + - create a StripeClient called client + - Initialize an EventNotificationHandler with the client, webhook secret, and fallback callback + - register a specific handler for the "v1.billing.meter.error_report_triggered" event notification type + - use handler.handle() to process the received notification webhook body +""" + +import os +from flask import Flask, request, jsonify + +from stripe import StripeClient, UnhandledNotificationDetails +from stripe.v2.core import EventNotification +from stripe.events import V1BillingMeterErrorReportTriggeredEventNotification + +app = Flask(__name__) +api_key = os.environ.get("STRIPE_API_KEY", "") +webhook_secret = os.environ.get("WEBHOOK_SECRET", "") + + +def fallback_callback( + notif: EventNotification, + client: StripeClient, + details: UnhandledNotificationDetails, +): + print(f"Got an unhandled event of type {notif.type}!") + + +client = StripeClient(api_key) +handler = client.notification_handler(webhook_secret, fallback_callback) + +# Handles events delivered through a channel that has already authenticated them, such as +# AWS EventBridge or Azure Event Grid. Those payloads carry no Stripe-Signature header. +unverified_handler = client.notification_handler_without_verification( + fallback_callback +) + + +# can be anywhere in your codebase; registering on both handlers means either +# endpoint below will route this event type +@handler.on_v1_billing_meter_error_report_triggered +@unverified_handler.on_v1_billing_meter_error_report_triggered +def handle_meter_error( + notif: V1BillingMeterErrorReportTriggeredEventNotification, + client: StripeClient, +): + event = notif.fetch_event() + print(f"Err! No meter found: {event.data.developer_message_summary}") + + +@app.route("/webhook", methods=["POST"]) +def webhook(): + webhook_body = request.data + sig_header = request.headers.get("Stripe-Signature") + + try: + handler.handle(webhook_body, sig_header) + return jsonify(success=True), 200 + except Exception as e: + return jsonify(error=str(e)), 500 + + +@app.route("/webhook-from-cloud-provider", methods=["POST"]) +def webhook_from_cloud_provider(): + # no signature header to pass along; the channel already authenticated this event + try: + unverified_handler.handle(request.data) + return jsonify(success=True), 200 + except Exception as e: + return jsonify(error=str(e)), 500 diff --git a/stripe/__init__.py b/stripe/__init__.py index 6f2ad0969..1bde68b9d 100644 --- a/stripe/__init__.py +++ b/stripe/__init__.py @@ -301,6 +301,11 @@ def set_app_info( OAuthErrorObject as OAuthErrorObject, ) from stripe._event import Event as Event + from stripe._event_notification_handler import ( + StripeEventNotificationHandler as StripeEventNotificationHandler, + StripeEventNotificationHandlerWithoutVerification as StripeEventNotificationHandlerWithoutVerification, + UnhandledNotificationDetails as UnhandledNotificationDetails, + ) from stripe._event_service import EventService as EventService from stripe._exchange_rate import ExchangeRate as ExchangeRate from stripe._exchange_rate_service import ( @@ -696,6 +701,18 @@ def set_app_info( "ErrorObject": ("stripe._error_object", False), "OAuthErrorObject": ("stripe._error_object", False), "Event": ("stripe._event", False), + "StripeEventNotificationHandler": ( + "stripe._event_notification_handler", + False, + ), + "StripeEventNotificationHandlerWithoutVerification": ( + "stripe._event_notification_handler", + False, + ), + "UnhandledNotificationDetails": ( + "stripe._event_notification_handler", + False, + ), "EventService": ("stripe._event_service", False), "ExchangeRate": ("stripe._exchange_rate", False), "ExchangeRateService": ("stripe._exchange_rate_service", False), diff --git a/stripe/_event_notification_handler.py b/stripe/_event_notification_handler.py new file mode 100644 index 000000000..7793e92b1 --- /dev/null +++ b/stripe/_event_notification_handler.py @@ -0,0 +1,548 @@ +# -*- coding: utf-8 -*- +from dataclasses import dataclass +from typing_extensions import TYPE_CHECKING + +from typing import TypeVar, Callable, List + +# Import at runtime for isinstance check and type annotations +from stripe.v2.core._event import EventNotification, UnknownEventNotification + +if TYPE_CHECKING: + from stripe._stripe_client import StripeClient + + # event-notification-types: The beginning of the section generated from our OpenAPI spec + from stripe.events._v1_billing_meter_error_report_triggered_event import ( + V1BillingMeterErrorReportTriggeredEventNotification, + ) + from stripe.events._v1_billing_meter_no_meter_found_event import ( + V1BillingMeterNoMeterFoundEventNotification, + ) + from stripe.events._v2_commerce_product_catalog_imports_failed_event import ( + V2CommerceProductCatalogImportsFailedEventNotification, + ) + from stripe.events._v2_commerce_product_catalog_imports_processing_event import ( + V2CommerceProductCatalogImportsProcessingEventNotification, + ) + from stripe.events._v2_commerce_product_catalog_imports_succeeded_event import ( + V2CommerceProductCatalogImportsSucceededEventNotification, + ) + from stripe.events._v2_commerce_product_catalog_imports_succeeded_with_errors_event import ( + V2CommerceProductCatalogImportsSucceededWithErrorsEventNotification, + ) + from stripe.events._v2_core_account_closed_event import ( + V2CoreAccountClosedEventNotification, + ) + from stripe.events._v2_core_account_created_event import ( + V2CoreAccountCreatedEventNotification, + ) + from stripe.events._v2_core_account_including_configuration_customer_capability_status_updated_event import ( + V2CoreAccountIncludingConfigurationCustomerCapabilityStatusUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_configuration_customer_updated_event import ( + V2CoreAccountIncludingConfigurationCustomerUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_configuration_merchant_capability_status_updated_event import ( + V2CoreAccountIncludingConfigurationMerchantCapabilityStatusUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_configuration_merchant_updated_event import ( + V2CoreAccountIncludingConfigurationMerchantUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_configuration_recipient_capability_status_updated_event import ( + V2CoreAccountIncludingConfigurationRecipientCapabilityStatusUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_configuration_recipient_updated_event import ( + V2CoreAccountIncludingConfigurationRecipientUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_defaults_updated_event import ( + V2CoreAccountIncludingDefaultsUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_future_requirements_updated_event import ( + V2CoreAccountIncludingFutureRequirementsUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_identity_updated_event import ( + V2CoreAccountIncludingIdentityUpdatedEventNotification, + ) + from stripe.events._v2_core_account_including_requirements_updated_event import ( + V2CoreAccountIncludingRequirementsUpdatedEventNotification, + ) + from stripe.events._v2_core_account_link_returned_event import ( + V2CoreAccountLinkReturnedEventNotification, + ) + from stripe.events._v2_core_account_person_created_event import ( + V2CoreAccountPersonCreatedEventNotification, + ) + from stripe.events._v2_core_account_person_deleted_event import ( + V2CoreAccountPersonDeletedEventNotification, + ) + from stripe.events._v2_core_account_person_updated_event import ( + V2CoreAccountPersonUpdatedEventNotification, + ) + from stripe.events._v2_core_account_updated_event import ( + V2CoreAccountUpdatedEventNotification, + ) + from stripe.events._v2_core_event_destination_ping_event import ( + V2CoreEventDestinationPingEventNotification, + ) + # event-notification-types: The end of the section generated from our OpenAPI spec + +# internal type to represent any EventNotification subclass +EventNotificationChild = TypeVar( + "EventNotificationChild", bound="EventNotification" +) + + +@dataclass +class UnhandledNotificationDetails: + """ + Information about an unhandled event notification to make it easier to respond (and potentially update your integration). + """ + + is_known_event_type: bool + """ + If true, the unhandled event's type is known to the SDK (i.e., it was successfully deserialized into a specific `EventNotification` subclass). + """ + + +FallbackCallback = Callable[ + [EventNotification, "StripeClient", UnhandledNotificationDetails], None +] +""" +This function is called when no other callback is registered for a given event notification type. +""" + + +class _BaseEventNotificationHandler: + """ + Shared internal registration and dispatch machinery for the two user-facing event handlers. + """ + + def __init__( + self, + client: "StripeClient", + fallback_callback: FallbackCallback, + ) -> None: + self._registered_handlers = {} + self._client = client + self.fallback_callback = fallback_callback + # once this is true, adding additional handlers results in an error + self._has_handled_events = False + + def _dispatch(self, event_notif: "EventNotification"): + # Create a new client with the event's context. + # This is thread-safe since we're not modifying the original client. + # The new client reuses the HTTP client to avoid TLS handshake overhead. + client_with_event_context = self._client.with_stripe_context( + event_notif.context + ) + + if event_notif.type in self._registered_handlers: + self._registered_handlers[event_notif.type]( + event_notif, client_with_event_context + ) + else: + self.fallback_callback( + event_notif, + client_with_event_context, + UnhandledNotificationDetails( + is_known_event_type=not isinstance( + event_notif, UnknownEventNotification + ) + ), + ) + + def _register( + self, + event_type: str, + func: "Callable[[EventNotificationChild, StripeClient], None]", + ) -> None: + if self._has_handled_events: + raise RuntimeError( + "Cannot register new event handlers after .handle() has been called. This is indicative of a bug." + ) + if event_type in self._registered_handlers: + raise ValueError( + f'Handler for event type "{event_type}" already registered.' + ) + + self._registered_handlers[event_type] = func + + @property + def registered_event_types(self) -> List[str]: + """ + Returns an alphabetized list of all event types that have registered handlers. + """ + return sorted(self._registered_handlers.keys()) + + # event-notification-registration-methods: The beginning of the section generated from our OpenAPI spec + def on_v1_billing_meter_error_report_triggered( + self, + func: "Callable[[V1BillingMeterErrorReportTriggeredEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V1BillingMeterErrorReportTriggeredEvent` (`v1.billing.meter.error_report_triggered`) event notification. + """ + self._register( + "v1.billing.meter.error_report_triggered", + func, + ) + return func + + def on_v1_billing_meter_no_meter_found( + self, + func: "Callable[[V1BillingMeterNoMeterFoundEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V1BillingMeterNoMeterFoundEvent` (`v1.billing.meter.no_meter_found`) event notification. + """ + self._register( + "v1.billing.meter.no_meter_found", + func, + ) + return func + + def on_v2_commerce_product_catalog_imports_failed( + self, + func: "Callable[[V2CommerceProductCatalogImportsFailedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CommerceProductCatalogImportsFailedEvent` (`v2.commerce.product_catalog.imports.failed`) event notification. + """ + self._register( + "v2.commerce.product_catalog.imports.failed", + func, + ) + return func + + def on_v2_commerce_product_catalog_imports_processing( + self, + func: "Callable[[V2CommerceProductCatalogImportsProcessingEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CommerceProductCatalogImportsProcessingEvent` (`v2.commerce.product_catalog.imports.processing`) event notification. + """ + self._register( + "v2.commerce.product_catalog.imports.processing", + func, + ) + return func + + def on_v2_commerce_product_catalog_imports_succeeded( + self, + func: "Callable[[V2CommerceProductCatalogImportsSucceededEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CommerceProductCatalogImportsSucceededEvent` (`v2.commerce.product_catalog.imports.succeeded`) event notification. + """ + self._register( + "v2.commerce.product_catalog.imports.succeeded", + func, + ) + return func + + def on_v2_commerce_product_catalog_imports_succeeded_with_errors( + self, + func: "Callable[[V2CommerceProductCatalogImportsSucceededWithErrorsEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CommerceProductCatalogImportsSucceededWithErrorsEvent` (`v2.commerce.product_catalog.imports.succeeded_with_errors`) event notification. + """ + self._register( + "v2.commerce.product_catalog.imports.succeeded_with_errors", + func, + ) + return func + + def on_v2_core_account_closed( + self, + func: "Callable[[V2CoreAccountClosedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountClosedEvent` (`v2.core.account.closed`) event notification. + """ + self._register( + "v2.core.account.closed", + func, + ) + return func + + def on_v2_core_account_created( + self, + func: "Callable[[V2CoreAccountCreatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountCreatedEvent` (`v2.core.account.created`) event notification. + """ + self._register( + "v2.core.account.created", + func, + ) + return func + + def on_v2_core_account_including_configuration_customer_capability_status_updated( + self, + func: "Callable[[V2CoreAccountIncludingConfigurationCustomerCapabilityStatusUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingConfigurationCustomerCapabilityStatusUpdatedEvent` (`v2.core.account[configuration.customer].capability_status_updated`) event notification. + """ + self._register( + "v2.core.account[configuration.customer].capability_status_updated", + func, + ) + return func + + def on_v2_core_account_including_configuration_customer_updated( + self, + func: "Callable[[V2CoreAccountIncludingConfigurationCustomerUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingConfigurationCustomerUpdatedEvent` (`v2.core.account[configuration.customer].updated`) event notification. + """ + self._register( + "v2.core.account[configuration.customer].updated", + func, + ) + return func + + def on_v2_core_account_including_configuration_merchant_capability_status_updated( + self, + func: "Callable[[V2CoreAccountIncludingConfigurationMerchantCapabilityStatusUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingConfigurationMerchantCapabilityStatusUpdatedEvent` (`v2.core.account[configuration.merchant].capability_status_updated`) event notification. + """ + self._register( + "v2.core.account[configuration.merchant].capability_status_updated", + func, + ) + return func + + def on_v2_core_account_including_configuration_merchant_updated( + self, + func: "Callable[[V2CoreAccountIncludingConfigurationMerchantUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingConfigurationMerchantUpdatedEvent` (`v2.core.account[configuration.merchant].updated`) event notification. + """ + self._register( + "v2.core.account[configuration.merchant].updated", + func, + ) + return func + + def on_v2_core_account_including_configuration_recipient_capability_status_updated( + self, + func: "Callable[[V2CoreAccountIncludingConfigurationRecipientCapabilityStatusUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingConfigurationRecipientCapabilityStatusUpdatedEvent` (`v2.core.account[configuration.recipient].capability_status_updated`) event notification. + """ + self._register( + "v2.core.account[configuration.recipient].capability_status_updated", + func, + ) + return func + + def on_v2_core_account_including_configuration_recipient_updated( + self, + func: "Callable[[V2CoreAccountIncludingConfigurationRecipientUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingConfigurationRecipientUpdatedEvent` (`v2.core.account[configuration.recipient].updated`) event notification. + """ + self._register( + "v2.core.account[configuration.recipient].updated", + func, + ) + return func + + def on_v2_core_account_including_defaults_updated( + self, + func: "Callable[[V2CoreAccountIncludingDefaultsUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingDefaultsUpdatedEvent` (`v2.core.account[defaults].updated`) event notification. + """ + self._register( + "v2.core.account[defaults].updated", + func, + ) + return func + + def on_v2_core_account_including_future_requirements_updated( + self, + func: "Callable[[V2CoreAccountIncludingFutureRequirementsUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingFutureRequirementsUpdatedEvent` (`v2.core.account[future_requirements].updated`) event notification. + """ + self._register( + "v2.core.account[future_requirements].updated", + func, + ) + return func + + def on_v2_core_account_including_identity_updated( + self, + func: "Callable[[V2CoreAccountIncludingIdentityUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingIdentityUpdatedEvent` (`v2.core.account[identity].updated`) event notification. + """ + self._register( + "v2.core.account[identity].updated", + func, + ) + return func + + def on_v2_core_account_including_requirements_updated( + self, + func: "Callable[[V2CoreAccountIncludingRequirementsUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountIncludingRequirementsUpdatedEvent` (`v2.core.account[requirements].updated`) event notification. + """ + self._register( + "v2.core.account[requirements].updated", + func, + ) + return func + + def on_v2_core_account_link_returned( + self, + func: "Callable[[V2CoreAccountLinkReturnedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountLinkReturnedEvent` (`v2.core.account_link.returned`) event notification. + """ + self._register( + "v2.core.account_link.returned", + func, + ) + return func + + def on_v2_core_account_person_created( + self, + func: "Callable[[V2CoreAccountPersonCreatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountPersonCreatedEvent` (`v2.core.account_person.created`) event notification. + """ + self._register( + "v2.core.account_person.created", + func, + ) + return func + + def on_v2_core_account_person_deleted( + self, + func: "Callable[[V2CoreAccountPersonDeletedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountPersonDeletedEvent` (`v2.core.account_person.deleted`) event notification. + """ + self._register( + "v2.core.account_person.deleted", + func, + ) + return func + + def on_v2_core_account_person_updated( + self, + func: "Callable[[V2CoreAccountPersonUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountPersonUpdatedEvent` (`v2.core.account_person.updated`) event notification. + """ + self._register( + "v2.core.account_person.updated", + func, + ) + return func + + def on_v2_core_account_updated( + self, + func: "Callable[[V2CoreAccountUpdatedEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreAccountUpdatedEvent` (`v2.core.account.updated`) event notification. + """ + self._register( + "v2.core.account.updated", + func, + ) + return func + + def on_v2_core_event_destination_ping( + self, + func: "Callable[[V2CoreEventDestinationPingEventNotification, StripeClient], None]", + ): + """ + Registers a callback for the `V2CoreEventDestinationPingEvent` (`v2.core.event_destination.ping`) event notification. + """ + self._register( + "v2.core.event_destination.ping", + func, + ) + return func + + # event-notification-registration-methods: The end of the section generated from our OpenAPI spec + + +class StripeEventNotificationHandler(_BaseEventNotificationHandler): + """ + An on-rails experience for handling Stripe event notifications. Define callbacks for individual event types and an instance of this class will be responsible for verifying and routing the event. + """ + + def __init__( + self, + client: "StripeClient", + webhook_secret: str, + fallback_callback: FallbackCallback, + ) -> None: + super().__init__(client, fallback_callback) + if not webhook_secret: + raise ValueError("webhook_secret must be a non-empty string") + self._webhook_secret = webhook_secret + + def handle(self, webhook_body: str, sig_header: str): + # set before parsing, so that even a failed parse locks out registration. + # modification isn't thread-safe, but we expect callbacks to get registered synchronously at startup + # making a race condition here unlikely + self._has_handled_events = True + + event_notif = self._client.parse_event_notification( + webhook_body, sig_header, self._webhook_secret + ) + + self._dispatch(event_notif) + + @staticmethod + def without_verification( + client: "StripeClient", + fallback_callback: FallbackCallback, + ) -> "StripeEventNotificationHandlerWithoutVerification": + return StripeEventNotificationHandlerWithoutVerification( + client, fallback_callback + ) + + +class StripeEventNotificationHandlerWithoutVerification( + _BaseEventNotificationHandler +): + """ + A variant of StripeEventNotificationHandler that parses events without verifying webhook signatures. Intended for pre-authenticated channels like AWS EventBridge, Azure Event Grid, or your own pre-authenticated queuing system. + + Prefer `StripeEventNotificationHandler.without_verification()` or `client.notification_handler_without_verification()` instead of constructing it directly. + """ + + def handle(self, webhook_body: str): + self._has_handled_events = True + + event_notif = ( + self._client.parse_event_notification_without_verification( + webhook_body + ) + ) + + self._dispatch(event_notif) diff --git a/stripe/_stripe_client.py b/stripe/_stripe_client.py index c086ba3d5..a810170e4 100644 --- a/stripe/_stripe_client.py +++ b/stripe/_stripe_client.py @@ -9,6 +9,11 @@ from stripe._api_mode import ApiMode from stripe._error import AuthenticationError +from stripe._event_notification_handler import ( + StripeEventNotificationHandler, + StripeEventNotificationHandlerWithoutVerification, + FallbackCallback, +) from stripe._request_options import extract_options_from_dict from stripe._requestor_options import RequestorOptions, BaseAddresses from stripe._client_options import _ClientOptions @@ -333,6 +338,51 @@ def deserialize( api_mode=api_mode, ) + def with_stripe_context( + self, stripe_context: "Optional[Union[str, StripeContext]]" + ) -> "StripeClient": + """ + Creates a new StripeClient with the same configuration as this client, + but with a different stripe_context. This is useful for handling webhooks + where each event may have its own context. + + The new client reuses the HTTP client from this client to avoid + re-establishing TLS connections. + """ + return StripeClient( + api_key=self._requestor.api_key, # type: ignore + stripe_account=self._requestor._options.stripe_account, + stripe_context=stripe_context, + stripe_version=self._requestor._options.stripe_version, + base_addresses=self._requestor._options.base_addresses, + client_id=self._options.client_id, + max_network_retries=self._requestor._options.max_network_retries, + http_client=self._requestor._client, + ) + + def notification_handler( + self, webhook_secret: str, fallback_callback: FallbackCallback + ) -> StripeEventNotificationHandler: + """ + Returns an StripeEventNotificationHandler instance tied to this client. + """ + return StripeEventNotificationHandler( + self, webhook_secret, fallback_callback + ) + + def notification_handler_without_verification( + self, fallback_callback: FallbackCallback + ) -> StripeEventNotificationHandlerWithoutVerification: + """ + A variant of StripeEventNotificationHandler that parses events without + verifying webhook signatures. Intended for pre-authenticated channels + like AWS EventBridge, Azure Event Grid, or your own queue system that + verifies payloads before storage. + """ + return StripeEventNotificationHandler.without_verification( + self, fallback_callback + ) + # deprecated v1 services: The beginning of the section generated from our OpenAPI spec @property @deprecated( diff --git a/tests/test_event_notification_handler.py b/tests/test_event_notification_handler.py new file mode 100644 index 000000000..9ad872843 --- /dev/null +++ b/tests/test_event_notification_handler.py @@ -0,0 +1,827 @@ +import json +import pytest +from typing import Optional +from unittest.mock import Mock + +from stripe import SignatureVerificationError, StripeClient +from stripe._event_notification_handler import ( + StripeEventNotificationHandler, + StripeEventNotificationHandlerWithoutVerification, + UnhandledNotificationDetails, +) +from stripe._stripe_context import StripeContext +from stripe.events._v1_billing_meter_error_report_triggered_event import ( + V1BillingMeterErrorReportTriggeredEventNotification, +) +from stripe.events._v2_core_account_created_event import ( + V2CoreAccountCreatedEventNotification, +) +from stripe.v2.core._event import EventNotification, UnknownEventNotification +from tests.http_client_mock import HTTPClientMock +from tests.test_webhook import DUMMY_WEBHOOK_SECRET, generate_header + + +class TestEventNotificationHandler: + @pytest.fixture(scope="function") + def stripe_client(self, http_client_mock: HTTPClientMock) -> StripeClient: + return StripeClient( + api_key="sk_test_1234", + stripe_context=StripeContext.parse("original_context_123"), + http_client=http_client_mock.get_mock_http_client(), + ) + + @pytest.fixture(scope="function") + def fallback_callback(self) -> Mock: + """Mock handler for unhandled events""" + return Mock() + + @pytest.fixture(scope="function") + def event_handler( + self, stripe_client: StripeClient, fallback_callback: Mock + ) -> StripeEventNotificationHandler: + return StripeEventNotificationHandler( + client=stripe_client, + webhook_secret=DUMMY_WEBHOOK_SECRET, + fallback_callback=fallback_callback, + ) + + @pytest.fixture(scope="function") + def v1_billing_meter_payload(self) -> str: + """A payload for v1.billing.meter.error_report_triggered event""" + return json.dumps( + { + "id": "evt_123", + "object": "v2.core.event", + "type": "v1.billing.meter.error_report_triggered", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": "event_context_456", + "related_object": { + "id": "mtr_123", + "type": "billing.meter", + "url": "/v1/billing/meters/mtr_123", + }, + } + ) + + @pytest.fixture(scope="function") + def v2_account_created_payload(self) -> str: + """A payload for v2.core.account.created event with None context""" + return json.dumps( + { + "id": "evt_789", + "object": "v2.core.event", + "type": "v2.core.account.created", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": None, + "related_object": { + "id": "acct_abc", + "type": "account", + "url": "/v2/core/accounts/acct_abc", + }, + } + ) + + @pytest.fixture(scope="function") + def unknown_event_payload(self) -> str: + """A payload for an unknown event type (llama.created)""" + return json.dumps( + { + "id": "evt_unknown", + "object": "v2.core.event", + "type": "llama.created", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": "event_context_unknown", + "related_object": { + "id": "llama_123", + "type": "llama", + "url": "/v1/llamas/llama_123", + }, + } + ) + + def test_routes_event_to_registered_handler( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + """Test that a registered event type is routed to the correct handler""" + handler = Mock() + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + handler.assert_called_once() + + call_args = handler.call_args[0] + assert isinstance( + call_args[0], V1BillingMeterErrorReportTriggeredEventNotification + ) + + fallback_callback.assert_not_called() + + def test_routes_different_events_to_correct_handlers( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + v2_account_created_payload: str, + fallback_callback: Mock, + ) -> None: + """Test that different event types route to their respective handlers""" + billing_handler = Mock() + account_handler = Mock() + + event_handler.on_v1_billing_meter_error_report_triggered( + billing_handler + ) + event_handler.on_v2_core_account_created(account_handler) + + sig_header1 = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header1) + + sig_header2 = generate_header(payload=v2_account_created_payload) + event_handler.handle(v2_account_created_payload, sig_header2) + + billing_handler.assert_called_once() + account_handler.assert_called_once() + + assert isinstance( + billing_handler.call_args[0][0], + V1BillingMeterErrorReportTriggeredEventNotification, + ) + assert isinstance( + account_handler.call_args[0][0], + V2CoreAccountCreatedEventNotification, + ) + + fallback_callback.assert_not_called() + + def test_handler_receives_correct_runtime_type( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + ) -> None: + """Test that handlers receive the correctly typed event notification""" + received_event: Optional[EventNotification] = None + received_client: Optional[StripeClient] = None + + def handler( + event: V1BillingMeterErrorReportTriggeredEventNotification, + client: StripeClient, + ) -> None: + nonlocal received_event, received_client + received_event = event + received_client = client + + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + assert isinstance( + received_event, V1BillingMeterErrorReportTriggeredEventNotification + ) + assert received_event.type == "v1.billing.meter.error_report_triggered" + assert received_event.id == "evt_123" + assert received_event.related_object.id == "mtr_123" + assert isinstance(received_client, StripeClient) + + def test_cannot_register_handler_after_handling( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + ) -> None: + """Test that registering handlers after handle() raises RuntimeError""" + handler = Mock() + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + with pytest.raises( + RuntimeError, + match="Cannot register new event handlers after .handle\\(\\) has been called", + ): + event_handler.on_v2_core_account_created(Mock()) + + def test_failed_parse_still_prevents_registration( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + ) -> None: + """Attempting to handle an event locks registration even if the parse fails""" + with pytest.raises(SignatureVerificationError): + event_handler.handle(v1_billing_meter_payload, "t=1,v1=not-a-sig") + + with pytest.raises( + RuntimeError, + match="Cannot register new event handlers after .handle\\(\\) has been called", + ): + event_handler.on_v2_core_account_created(Mock()) + + def test_cannot_register_duplicate_handler( + self, event_handler: StripeEventNotificationHandler + ) -> None: + """Test that registering the same event type twice raises ValueError""" + handler1 = Mock() + handler2 = Mock() + + event_handler.on_v1_billing_meter_error_report_triggered(handler1) + + with pytest.raises( + ValueError, + match='Handler for event type "v1.billing.meter.error_report_triggered" already registered', + ): + event_handler.on_v1_billing_meter_error_report_triggered(handler2) + + def test_handler_uses_event_stripe_context( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + stripe_client: StripeClient, + ) -> None: + """Test that the handler receives a client with stripe_context from the event""" + received_context: Optional[StripeContext | str] = None + + def handler( + event: V1BillingMeterErrorReportTriggeredEventNotification, + client: StripeClient, + ) -> None: + nonlocal received_context + received_context = client._requestor._options.stripe_context + + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + assert str(received_context) == "event_context_456" + + def test_stripe_context_restored_after_handler_success( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + stripe_client: StripeClient, + ) -> None: + """Test that the original stripe_context is restored after successful handler execution""" + + def handler( + event: V1BillingMeterErrorReportTriggeredEventNotification, + client: StripeClient, + ) -> None: + assert ( + str(client._requestor._options.stripe_context) + == "event_context_456" + ) + + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + def test_stripe_context_restored_after_handler_error( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + stripe_client: StripeClient, + ) -> None: + """Test that the original stripe_context is restored even when handler raises an exception""" + + def handler( + event: V1BillingMeterErrorReportTriggeredEventNotification, + client: StripeClient, + ) -> None: + assert ( + str(client._requestor._options.stripe_context) + == "event_context_456" + ) + raise RuntimeError("Handler error!") + + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + sig_header = generate_header(payload=v1_billing_meter_payload) + + with pytest.raises(RuntimeError, match="Handler error!"): + event_handler.handle(v1_billing_meter_payload, sig_header) + + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + def test_stripe_context_set_to_none_when_event_has_no_context( + self, + event_handler: StripeEventNotificationHandler, + v2_account_created_payload: str, + stripe_client: StripeClient, + ) -> None: + """Test that stripe_context is set to None when event context is None""" + received_context: Optional[StripeContext | str] = None + + def handler( + event: V2CoreAccountCreatedEventNotification, client: StripeClient + ) -> None: + nonlocal received_context + received_context = client._requestor._options.stripe_context + + event_handler.on_v2_core_account_created(handler) + + # Verify we're working with StripeContext instances + assert isinstance( + stripe_client._requestor._options.stripe_context, StripeContext + ) + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + sig_header = generate_header(payload=v2_account_created_payload) + event_handler.handle(v2_account_created_payload, sig_header) + + assert received_context is None + + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + def test_unknown_event_routes_to_on_unhandled( + self, + event_handler: StripeEventNotificationHandler, + unknown_event_payload: str, + fallback_callback: Mock, + ) -> None: + """Test that events without SDK types route to on_unhandled handler""" + sig_header = generate_header(payload=unknown_event_payload) + + event_handler.handle(unknown_event_payload, sig_header) + + fallback_callback.assert_called_once() + + call_args = fallback_callback.call_args[0] + event_notif = call_args[0] + client = call_args[1] + info = call_args[2] + + assert isinstance(event_notif, UnknownEventNotification) + assert event_notif.type == "llama.created" + assert isinstance(client, StripeClient) + assert isinstance(info, UnhandledNotificationDetails) + assert info.is_known_event_type is False + + def test_known_unregistered_event_routes_to_on_unhandled( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + """Test that known event types without a registered handler route to on_unhandled""" + sig_header = generate_header(payload=v1_billing_meter_payload) + + event_handler.handle(v1_billing_meter_payload, sig_header) + + fallback_callback.assert_called_once() + + call_args = fallback_callback.call_args[0] + event_notif = call_args[0] + client = call_args[1] + info = call_args[2] + + assert isinstance( + event_notif, V1BillingMeterErrorReportTriggeredEventNotification + ) + assert event_notif.type == "v1.billing.meter.error_report_triggered" + assert isinstance(client, StripeClient) + assert isinstance(info, UnhandledNotificationDetails) + assert info.is_known_event_type is True + + def test_registered_event_does_not_call_on_unhandled( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + """Test that registered events don't trigger on_unhandled""" + handler = Mock() + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + sig_header = generate_header(payload=v1_billing_meter_payload) + event_handler.handle(v1_billing_meter_payload, sig_header) + + handler.assert_called_once() + fallback_callback.assert_not_called() + + def test_handler_client_retains_configuration( + self, + http_client_mock: HTTPClientMock, + fallback_callback: Mock, + v1_billing_meter_payload: str, + ) -> None: + """Test that the client passed to handlers retains all configuration except stripe_context""" + api_key = "sk_test_custom_key" + original_context = "original_context_xyz" + + client = StripeClient( + api_key=api_key, + stripe_context=StripeContext.parse(original_context), + http_client=http_client_mock.get_mock_http_client(), + ) + + notif_handler = StripeEventNotificationHandler( + client=client, + webhook_secret=DUMMY_WEBHOOK_SECRET, + fallback_callback=fallback_callback, + ) + + received_api_key: Optional[str] = None + received_context: Optional[StripeContext | str] = None + + def handler( + event: V1BillingMeterErrorReportTriggeredEventNotification, + client: StripeClient, + ) -> None: + nonlocal received_api_key, received_context + received_api_key = client._requestor.api_key + received_context = client._requestor._options.stripe_context + + notif_handler.on_v1_billing_meter_error_report_triggered(handler) + + sig_header = generate_header(payload=v1_billing_meter_payload) + notif_handler.handle(v1_billing_meter_payload, sig_header) + + assert received_api_key == api_key + assert str(received_context) == "event_context_456" + assert ( + str(client._requestor._options.stripe_context) == original_context + ) + + def test_on_unhandled_receives_correct_info_for_unknown( + self, + event_handler: StripeEventNotificationHandler, + unknown_event_payload: str, + fallback_callback: Mock, + ) -> None: + """Test that on_unhandled receives correct UnhandledNotificationDetails for unknown events""" + sig_header = generate_header(payload=unknown_event_payload) + + event_handler.handle(unknown_event_payload, sig_header) + + fallback_callback.assert_called_once() + info = fallback_callback.call_args[0][2] + + assert isinstance(info, UnhandledNotificationDetails) + assert info.is_known_event_type is False + + def test_on_unhandled_receives_correct_info_for_known_unregistered( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + """Test that on_unhandled receives correct UnhandledNotificationDetails for known unregistered events""" + sig_header = generate_header(payload=v1_billing_meter_payload) + + event_handler.handle(v1_billing_meter_payload, sig_header) + + fallback_callback.assert_called_once() + info = fallback_callback.call_args[0][2] + + assert isinstance(info, UnhandledNotificationDetails) + assert info.is_known_event_type is True + + def test_validates_webhook_signature( + self, + event_handler: StripeEventNotificationHandler, + v1_billing_meter_payload: str, + ) -> None: + """Test that invalid webhook signatures are rejected""" + from stripe._error import SignatureVerificationError + + with pytest.raises(SignatureVerificationError): + event_handler.handle(v1_billing_meter_payload, "invalid_signature") + + def test_registered_event_types_empty( + self, event_handler: StripeEventNotificationHandler + ) -> None: + """Test that registered_event_types returns empty list when no handlers are registered""" + assert event_handler.registered_event_types == [] + + def test_registered_event_types_single( + self, event_handler: StripeEventNotificationHandler + ) -> None: + """Test that registered_event_types returns a single event type""" + handler = Mock() + event_handler.on_v1_billing_meter_error_report_triggered(handler) + + assert event_handler.registered_event_types == [ + "v1.billing.meter.error_report_triggered" + ] + + def test_registered_event_types_multiple_alphabetized( + self, event_handler: StripeEventNotificationHandler + ) -> None: + """Test that registered_event_types returns multiple event types in alphabetical order""" + handler = Mock() + + # Register in non-alphabetical order + event_handler.on_v2_core_account_updated(handler) + event_handler.on_v1_billing_meter_error_report_triggered(handler) + event_handler.on_v2_core_account_created(handler) + + expected = [ + "v1.billing.meter.error_report_triggered", + "v2.core.account.created", + "v2.core.account.updated", + ] + + assert event_handler.registered_event_types == expected + + def test_can_call_wrapped_functions( + self, event_handler: StripeEventNotificationHandler + ): + @event_handler.on_v1_billing_meter_error_report_triggered # type: ignore + def rand_int(notif, client): + """cool docstring""" + return 4 + + assert rand_int(None, None) == 4 # type: ignore + + def test_rejects_empty_webhook_secret( + self, stripe_client: StripeClient, fallback_callback: Mock + ) -> None: + """Test that the constructor rejects an empty webhook secret""" + with pytest.raises( + ValueError, match="webhook_secret must be a non-empty string" + ): + StripeEventNotificationHandler( + client=stripe_client, + webhook_secret="", + fallback_callback=fallback_callback, + ) + + def test_rejects_none_webhook_secret( + self, stripe_client: StripeClient, fallback_callback: Mock + ) -> None: + """Test that the constructor rejects a None webhook secret""" + with pytest.raises( + ValueError, match="webhook_secret must be a non-empty string" + ): + StripeEventNotificationHandler( + client=stripe_client, + webhook_secret=None, # type: ignore + fallback_callback=fallback_callback, + ) + + +class TestEventNotificationHandlerWithoutVerification: + @pytest.fixture(scope="function") + def stripe_client(self, http_client_mock: HTTPClientMock) -> StripeClient: + return StripeClient( + api_key="sk_test_1234", + stripe_context=StripeContext.parse("original_context_123"), + http_client=http_client_mock.get_mock_http_client(), + ) + + @pytest.fixture(scope="function") + def fallback_callback(self) -> Mock: + return Mock() + + @pytest.fixture(scope="function") + def handler_without_verification( + self, stripe_client: StripeClient, fallback_callback: Mock + ) -> StripeEventNotificationHandlerWithoutVerification: + return StripeEventNotificationHandler.without_verification( + client=stripe_client, + fallback_callback=fallback_callback, + ) + + @pytest.fixture(scope="function") + def v1_billing_meter_payload(self) -> str: + return json.dumps( + { + "id": "evt_123", + "object": "v2.core.event", + "type": "v1.billing.meter.error_report_triggered", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": "event_context_456", + "related_object": { + "id": "mtr_123", + "type": "billing.meter", + "url": "/v1/billing/meters/mtr_123", + }, + } + ) + + @pytest.fixture(scope="function") + def unknown_event_payload(self) -> str: + return json.dumps( + { + "id": "evt_unknown", + "object": "v2.core.event", + "type": "llama.created", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": "event_context_unknown", + "related_object": { + "id": "llama_123", + "type": "llama", + "url": "/v1/llamas/llama_123", + }, + } + ) + + def test_routes_event_to_registered_handler( + self, + handler_without_verification, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + handler = Mock() + handler_without_verification.on_v1_billing_meter_error_report_triggered( + handler + ) + + handler_without_verification.handle(v1_billing_meter_payload) + + handler.assert_called_once() + call_args = handler.call_args[0] + assert isinstance( + call_args[0], V1BillingMeterErrorReportTriggeredEventNotification + ) + fallback_callback.assert_not_called() + + def test_handle_takes_single_argument( + self, + handler_without_verification, + v1_billing_meter_payload: str, + ) -> None: + handler = Mock() + handler_without_verification.on_v1_billing_meter_error_report_triggered( + handler + ) + + # No signature needed - just the payload + handler_without_verification.handle(v1_billing_meter_payload) + + handler.assert_called_once() + + def test_fallback_receives_unregistered_events( + self, + handler_without_verification, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + handler_without_verification.handle(v1_billing_meter_payload) + + fallback_callback.assert_called_once() + info = fallback_callback.call_args[0][2] + assert isinstance(info, UnhandledNotificationDetails) + assert info.is_known_event_type is True + + def test_unknown_event_has_is_known_event_type_false( + self, + handler_without_verification, + unknown_event_payload: str, + fallback_callback: Mock, + ) -> None: + handler_without_verification.handle(unknown_event_payload) + + fallback_callback.assert_called_once() + info = fallback_callback.call_args[0][2] + assert info.is_known_event_type is False + + def test_context_propagation( + self, + handler_without_verification, + v1_billing_meter_payload: str, + stripe_client: StripeClient, + ) -> None: + received_context = None + + def handler(event, client): + nonlocal received_context + received_context = client._requestor._options.stripe_context + + handler_without_verification.on_v1_billing_meter_error_report_triggered( + handler + ) + handler_without_verification.handle(v1_billing_meter_payload) + + assert str(received_context) == "event_context_456" + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + def test_static_factory( + self, stripe_client: StripeClient, fallback_callback: Mock + ) -> None: + handler = StripeEventNotificationHandler.without_verification( + stripe_client, fallback_callback + ) + assert isinstance( + handler, StripeEventNotificationHandlerWithoutVerification + ) + + def test_failed_parse_still_prevents_registration( + self, + handler_without_verification: StripeEventNotificationHandlerWithoutVerification, + ) -> None: + """Attempting to handle an event locks registration even if the parse fails""" + with pytest.raises(ValueError): + handler_without_verification.handle("not json") + + with pytest.raises( + RuntimeError, + match="Cannot register new event handlers after .handle\\(\\) has been called", + ): + handler_without_verification.on_v2_core_account_created(Mock()) + + def test_handlers_are_siblings_not_subclasses(self) -> None: + """ + Neither handler is substitutable for the other, so neither should be a + subclass of the other. Each defines its own `handle` over a shared base. + """ + assert not issubclass( + StripeEventNotificationHandler, + StripeEventNotificationHandlerWithoutVerification, + ) + assert not issubclass( + StripeEventNotificationHandlerWithoutVerification, + StripeEventNotificationHandler, + ) + + def test_client_factory( + self, stripe_client: StripeClient, fallback_callback: Mock + ) -> None: + handler = stripe_client.notification_handler_without_verification( + fallback_callback + ) + assert handler is not None + assert hasattr(handler, "handle") + + def test_handles_cloud_provider_envelope( + self, + handler_without_verification, + ) -> None: + """Test that events wrapped in cloud provider envelopes are parsed correctly""" + inner_payload = { + "id": "evt_123", + "object": "v2.core.event", + "type": "v1.billing.meter.error_report_triggered", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": "event_context_456", + "related_object": { + "id": "mtr_123", + "type": "billing.meter", + "url": "/v1/billing/meters/mtr_123", + }, + } + # AWS EventBridge envelope + eventbridge_payload = json.dumps( + { + "version": "0", + "id": "abc-123", + "source": "aws.partner/stripe.com/ed_xxx", + "detail-type": "event", + "detail": inner_payload, + } + ) + + handler = Mock() + handler_without_verification.on_v1_billing_meter_error_report_triggered( + handler + ) + handler_without_verification.handle(eventbridge_payload) + + handler.assert_called_once() + call_args = handler.call_args[0] + assert isinstance( + call_args[0], V1BillingMeterErrorReportTriggeredEventNotification + )