diff --git a/docs/webhooks-receiver.md b/docs/webhooks-receiver.md index b4f9b5c..1029d81 100644 --- a/docs/webhooks-receiver.md +++ b/docs/webhooks-receiver.md @@ -69,6 +69,7 @@ flips give the **entity directly**; only an arbitrary mutation gives a | `database.record.updated` / `replaced` | mutation | `Mutation` of documents | | `database.records.inserted` | batch | `list` of documents | | `files.file.uploaded` | entity | `FileResourceRef` | +| `payments.webhook.received` | entity | `PaymentFact` — the normalized payment fact (`event_name` = the provider's own name, `status`, `raw["amountMinor"]`, …); `event.metadata.payment` = provider ids | Wrapper identifiers (entity id, schema, record ids) are lifted onto `event.metadata` (`event.metadata.user`, `.schema`, `.record`, `.records`). diff --git a/src/norbix_python/webhooks/__init__.py b/src/norbix_python/webhooks/__init__.py index a1630c2..947dcfe 100644 --- a/src/norbix_python/webhooks/__init__.py +++ b/src/norbix_python/webhooks/__init__.py @@ -19,6 +19,7 @@ WebhookEvent, WebhookEventMetadata, WebhookHandleResult, + WebhookPaymentRef, WebhookRecordIds, WebhookSchemaInfo, ) @@ -40,6 +41,7 @@ "AsyncNorbixWebhookReceiver", "FileDeleted", "FileResourceRef", + "WebhookPaymentRef", "Mutation", "NorbixWebhookError", "NorbixWebhookEvents", diff --git a/src/norbix_python/webhooks/events.py b/src/norbix_python/webhooks/events.py index 0409d6a..988aa85 100644 --- a/src/norbix_python/webhooks/events.py +++ b/src/norbix_python/webhooks/events.py @@ -22,6 +22,7 @@ "membership.user.reactivated", "files.file.uploaded", "files.file.deleted", + "payments.webhook.received", ) @@ -54,3 +55,9 @@ class Membership: class Files: FILE_UPLOADED: Final = "files.file.uploaded" FILE_DELETED: Final = "files.file.deleted" + + class Payments: + #: A payment provider (Stripe, Apple In-App, Google In-App, …) called the + #: project's payments webhook. The payload is the normalized payment fact + #: (``PaymentFact``); the provider's own event name is ``payload.event_name``. + WEBHOOK_RECEIVED: Final = "payments.webhook.received" diff --git a/src/norbix_python/webhooks/models.py b/src/norbix_python/webhooks/models.py index 9dff913..1677844 100644 --- a/src/norbix_python/webhooks/models.py +++ b/src/norbix_python/webhooks/models.py @@ -73,6 +73,14 @@ class WebhookEntityRef(_Model): id: str +class WebhookPaymentRef(_Model): + """The provider ids of a payments.* delivery.""" + + provider: str + provider_event_id: str = Field(alias="providerEventId") + event_name: str = Field(alias="eventName") + + class WebhookRecordIds(_Model): """Batch record ids.""" @@ -93,6 +101,9 @@ class WebhookEventMetadata(_Model): record: WebhookEntityRef | None = None records: WebhookRecordIds | None = None integration_id: str | None = Field(default=None, alias="integrationId") + #: payments.* events: the provider and its own event id / name + #: (e.g. ``Stripe``, ``evt_…``, ``payment_intent.succeeded``). + payment: WebhookPaymentRef | None = None class WebhookEnvelope(_Model): diff --git a/src/norbix_python/webhooks/normalize.py b/src/norbix_python/webhooks/normalize.py index d5c43ee..ef9b9b3 100644 --- a/src/norbix_python/webhooks/normalize.py +++ b/src/norbix_python/webhooks/normalize.py @@ -2,6 +2,7 @@ from typing import Any +from ..functions import PaymentFact from .models import ( FileDeleted, FileResourceRef, @@ -11,6 +12,7 @@ WebhookEntityRef, WebhookEnvelope, WebhookEventMetadata, + WebhookPaymentRef, WebhookRecordIds, WebhookSchemaInfo, ) @@ -85,4 +87,18 @@ def normalize_webhook(envelope: WebhookEnvelope) -> tuple[Any, WebhookEventMetad return FileDeleted(path=data.get("path")), metadata return envelope.data, metadata + if event.startswith("payments."): + # The whole data object IS the normalized payment fact (the same shape + # as a Code function's ``data.payment``); the provider ids are lifted + # onto metadata so a handler can de-duplicate on them. + if isinstance(data.get("integrationId"), str): + metadata.integration_id = data["integrationId"] + if all(isinstance(data.get(k), str) for k in ("provider", "providerEventId", "eventName")): + metadata.payment = WebhookPaymentRef( + provider=data["provider"], + providerEventId=data["providerEventId"], + eventName=data["eventName"], + ) + return PaymentFact(_as_dict(data)), metadata + return envelope.data, metadata diff --git a/tests/webhooks/test_receiver.py b/tests/webhooks/test_receiver.py index c083746..8da1d96 100644 --- a/tests/webhooks/test_receiver.py +++ b/tests/webhooks/test_receiver.py @@ -317,3 +317,36 @@ async def run() -> object: result = asyncio.run(run()) assert result.event_id == "evt_3" # type: ignore[attr-defined] + + +def test_payments_payload_is_the_payment_fact_with_provider_ids_on_metadata() -> None: + # gateway campaign audit/payments: every payment provider callback reaches a + # destination as ONE event; data is the normalized fact, never the raw payload. + receiver = NorbixWebhookReceiver() + captured: dict[str, object] = {} + fact = { + "integrationId": "int_stripe", + "provider": "Stripe", + "providerEventId": "evt_1", + "eventName": "payment_intent.succeeded", + "status": "Succeeded", + "amountMinor": 1099, + "currency": "USD", + "externalIds": {"customer": "cus_1"}, + } + + receiver.on( + NorbixWebhookEvents.Payments.WEBHOOK_RECEIVED, + lambda payload, event: captured.update(payload=payload, event=event), + ) + receiver.handle(raw_body=_body("payments.webhook.received", fact), headers={}) + + payment = captured["payload"] + assert payment.event_name == "payment_intent.succeeded" # type: ignore[attr-defined] + assert payment.status == "Succeeded" # type: ignore[attr-defined] + assert payment.raw["amountMinor"] == 1099 # type: ignore[attr-defined] + event = captured["event"] + assert event.metadata.integration_id == "int_stripe" # type: ignore[attr-defined] + assert event.metadata.payment.provider == "Stripe" # type: ignore[attr-defined] + assert event.metadata.payment.provider_event_id == "evt_1" # type: ignore[attr-defined] + assert "payments.webhook.received" in NORBIX_WEBHOOK_EVENT_NAMES