Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/webhooks-receiver.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
2 changes: 2 additions & 0 deletions src/norbix_python/webhooks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
WebhookEvent,
WebhookEventMetadata,
WebhookHandleResult,
WebhookPaymentRef,
WebhookRecordIds,
WebhookSchemaInfo,
)
Expand All @@ -40,6 +41,7 @@
"AsyncNorbixWebhookReceiver",
"FileDeleted",
"FileResourceRef",
"WebhookPaymentRef",
"Mutation",
"NorbixWebhookError",
"NorbixWebhookEvents",
Expand Down
7 changes: 7 additions & 0 deletions src/norbix_python/webhooks/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"membership.user.reactivated",
"files.file.uploaded",
"files.file.deleted",
"payments.webhook.received",
)


Expand Down Expand Up @@ -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"
11 changes: 11 additions & 0 deletions src/norbix_python/webhooks/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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):
Expand Down
16 changes: 16 additions & 0 deletions src/norbix_python/webhooks/normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from typing import Any

from ..functions import PaymentFact
from .models import (
FileDeleted,
FileResourceRef,
Expand All @@ -11,6 +12,7 @@
WebhookEntityRef,
WebhookEnvelope,
WebhookEventMetadata,
WebhookPaymentRef,
WebhookRecordIds,
WebhookSchemaInfo,
)
Expand Down Expand Up @@ -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
33 changes: 33 additions & 0 deletions tests/webhooks/test_receiver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading