From 2dd65913528ddb8aabd41ca5d1f5c2c457ea3460 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Thu, 15 Oct 2026 13:15:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(webhooks):=20payments.webhook.received=20e?= =?UTF-8?q?vent=20=E2=80=94=20typed=20as=20the=20normalized=20payment=20fa?= =?UTF-8?q?ct=20(PaymentFactDto),=20provider=20ids=20on=20event.metadata.p?= =?UTF-8?q?ayment,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every payment provider callback reaches a Norbix webhook destination as one event, payments.webhook.received (gateway campaign audit/payments, decision D1). The receiver now knows the name (NorbixWebhookEvents.Payments.WebhookReceived, the catalog and the groups), types the payload as the generated PaymentFactDto (same fields the gateway sends), and lifts integrationId and { provider, providerEventId, eventName } onto event.metadata. Co-Authored-By: Claude Opus 5.5 --- docs/webhooks-receiver.md | 23 +++++++++++++++ src/webhooks/event-data.ts | 7 ++++- src/webhooks/events.ts | 15 ++++++++++ src/webhooks/normalize.ts | 20 +++++++++++++ tests/webhooks/event-data.test.ts | 6 ++++ tests/webhooks/receiver.test.ts | 47 +++++++++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 1 deletion(-) diff --git a/docs/webhooks-receiver.md b/docs/webhooks-receiver.md index 98f3bcc..7c8e896 100644 --- a/docs/webhooks-receiver.md +++ b/docs/webhooks-receiver.md @@ -110,6 +110,7 @@ receiver.onAll( | Database | `Database.RecordInserted`, `RecordUpdated`, `RecordDeleted`, `RecordReplaced`, `RecordResponsibilityChanged`, `RecordsInserted`, `RecordsUpdated`, `RecordsDeleted` | | Membership | `Membership.UserRegistered`, `UserInvited`, `UserVerified`, `UserUpdated`, `UserDeleted`, `UserBlocked`, `UserReactivated` | | Files | `Files.FileUploaded`, `FileDeleted` | +| Payments | `Payments.WebhookReceived` | Full list: `NORBIX_WEBHOOK_EVENT_NAMES` · grouped: `NORBIX_WEBHOOK_EVENT_GROUPS`. @@ -229,10 +230,32 @@ events give the **entity directly**. Arbitrary mutations give `{ from, to }`. | `database.record.updated` / `replaced` | `{ from: T; to: T }` | | `database.records.inserted` | `T[]` | | `files.file.uploaded` | `FileResourceRefDto` | +| `payments.webhook.received` | `PaymentFactDto` | Wrapper ids (user id, schema, record ids) live on `event.metadata`. For manual typing outside `receiver.on`, use `NorbixWebhookPayload<'event.name'>`. +### Payments + +Every payment provider callback (Stripe, Apple In-App, Google In-App, Paddle, …) +reaches your endpoint as ONE event, `payments.webhook.received`. The payload is +the normalized payment fact — the provider's raw body never leaves Norbix. Branch +on the provider's own event name, which is `payload.eventName`: + +```ts +receiver.on(NorbixWebhookEvents.Payments.WebhookReceived, async (payment, event) => { + // payment: PaymentFactDto — provider, eventName, status, amountMinor, currency, externalIds, … + if (payment.eventName === 'payment_intent.succeeded' && payment.status === 'Succeeded') { + await markOrderPaid(payment.externalIds?.paymentIntent, payment.amountMinor, payment.currency); + } + // event.metadata.payment = { provider, providerEventId, eventName } — de-duplicate on providerEventId +}); +``` + +`amountMinor` is in minor units (cents); it is absent for events with no amount +(subscription changes, Google Play notifications). Subscribe a destination to +`payments.webhook.received` and point a Payments trigger with a Webhook action at it. + ## Configuration `new NorbixWebhookReceiver(options?)` reads these env vars; any option you pass diff --git a/src/webhooks/event-data.ts b/src/webhooks/event-data.ts index 5eda7ec..d9120c8 100644 --- a/src/webhooks/event-data.ts +++ b/src/webhooks/event-data.ts @@ -42,6 +42,9 @@ export interface NorbixWebhookPayloadMap> { // files — entity 'files.file.uploaded': CodeMashHub2.FileResourceRefDto; 'files.file.deleted': NorbixWebhookFileDeleted; + // payments — the normalized payment fact (never the provider's raw payload). + // The provider's own event name is `eventName`; amounts are minor units. + 'payments.webhook.received': CodeMashHub2.PaymentFactDto; } /** Resolve the typed payload for a known event (with optional document type). */ @@ -63,6 +66,8 @@ export interface NorbixWebhookEventMetadata { record?: { id: string }; /** Record ids for batch database events. */ records?: { ids: string[] }; - /** Integration id for files.* events (and database events that carry it). */ + /** Integration id for files.* and payments.* events (and database events that carry it). */ integrationId?: string; + /** payments.* events: the provider and its own event id / name (e.g. Stripe `evt_…`, `payment_intent.succeeded`). */ + payment?: { provider: string; providerEventId: string; eventName: string }; } diff --git a/src/webhooks/events.ts b/src/webhooks/events.ts index 0337c62..2a3f9a1 100644 --- a/src/webhooks/events.ts +++ b/src/webhooks/events.ts @@ -20,6 +20,7 @@ export const NORBIX_WEBHOOK_EVENT_NAMES = [ 'membership.user.reactivated', 'files.file.uploaded', 'files.file.deleted', + 'payments.webhook.received', ] as const; export type NorbixWebhookEventName = (typeof NORBIX_WEBHOOK_EVENT_NAMES)[number]; @@ -55,6 +56,15 @@ export const NorbixWebhookEvents = { FileUploaded: 'files.file.uploaded', FileDeleted: 'files.file.deleted', }, + Payments: { + /** + * A payment provider (Stripe, Apple In-App, Google In-App, …) called the + * project's payments webhook. The payload is the normalized payment fact; + * the provider's own event name (`payment_intent.succeeded`, …) is + * `payload.eventName`. + */ + WebhookReceived: 'payments.webhook.received', + }, } as const satisfies Record>; export interface NorbixWebhookEventGroup { @@ -96,4 +106,9 @@ export const NORBIX_WEBHOOK_EVENT_GROUPS: NorbixWebhookEventGroup[] = [ label: 'Files', events: ['files.file.uploaded', 'files.file.deleted'], }, + { + group: 'payments', + label: 'Payments', + events: ['payments.webhook.received'], + }, ]; diff --git a/src/webhooks/normalize.ts b/src/webhooks/normalize.ts index 3489529..a16a0e5 100644 --- a/src/webhooks/normalize.ts +++ b/src/webhooks/normalize.ts @@ -19,6 +19,7 @@ function isObject(value: unknown): value is Record { * - Entity events → `payload` is the entity (user / document / file). * - Mutation events → `payload` is `{ from, to }`. * - Batch events → `payload` is the array. + * - Payments events → `payload` is the normalized payment fact. * * Wrapper ids (record id, schema, user id, …) are moved onto `metadata`. * Unknown events fall back to `payload = envelope.data`, `metadata = {}`. @@ -94,5 +95,24 @@ export function normalizeNorbixWebhook(envelope: NorbixWebhookEnvelope): NorbixW } } + // Payments: the whole data object IS the normalized fact; lift the + // provider ids onto metadata so a handler can de-duplicate / look them up. + if (event.startsWith('payments.')) { + const metadata: NorbixWebhookEventMetadata = {}; + if (typeof d.integrationId === 'string') metadata.integrationId = d.integrationId; + if ( + typeof d.provider === 'string' && + typeof d.providerEventId === 'string' && + typeof d.eventName === 'string' + ) { + metadata.payment = { + provider: d.provider, + providerEventId: d.providerEventId, + eventName: d.eventName, + }; + } + return { payload: data, metadata }; + } + return { payload: data, metadata: {} }; } diff --git a/tests/webhooks/event-data.test.ts b/tests/webhooks/event-data.test.ts index f079329..1caea85 100644 --- a/tests/webhooks/event-data.test.ts +++ b/tests/webhooks/event-data.test.ts @@ -29,6 +29,12 @@ describe('webhooks payload types', () => { >().toEqualTypeOf>(); }); + it('payments.webhook.received payload is the normalized payment fact', () => { + expectTypeOf< + NorbixWebhookPayload<'payments.webhook.received'> + >().toEqualTypeOf(); + }); + it('database.records.inserted payload is the document array', () => { expectTypeOf< NorbixWebhookPayload<'database.records.inserted', CodeMashHub2.UserDto> diff --git a/tests/webhooks/receiver.test.ts b/tests/webhooks/receiver.test.ts index 3df58bf..2ddd7fb 100644 --- a/tests/webhooks/receiver.test.ts +++ b/tests/webhooks/receiver.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { + NorbixWebhookEvents, NorbixWebhookReceiver, NORBIX_WEBHOOK_HEADERS, NorbixWebhookSignatureError, @@ -170,6 +171,52 @@ describe('NorbixWebhookReceiver', () => { expect(handler.mock.calls[0]?.[0]).toEqual({ from, to }); }); + it('hands a payment delivery to its handler as the fact, provider ids on metadata', async () => { + // The shape a payments Webhook trigger delivers (gateway L4 + // PaymentsTriggerFiresWebhookE2ETests): event = the one Norbix name, + // data = the normalized fact, never the provider's raw payload. + const fact = { + integrationId: 'int_stripe', + provider: 'Stripe', + providerEventId: 'evt_1', + eventName: 'payment_intent.succeeded', + status: 'Succeeded', + amountMinor: 1099, + currency: 'USD', + externalIds: { customer: 'cus_1', paymentIntent: 'pi_1' }, + occurredOn: '2026-10-12T07:59:00Z', + receivedOn: '2026-10-12T07:59:30Z', + }; + const receiver = new NorbixWebhookReceiver(); + const handler = vi.fn(); + receiver.on(NorbixWebhookEvents.Payments.WebhookReceived, handler); + + const result = await receiver.handle({ + rawBody: JSON.stringify({ + id: 'dlv_p', + eventId: 'evt_change_1', + event: 'payments.webhook.received', + createdOn: '2026-10-12T08:00:00Z', + accountId: 'acc_1', + projectId: 'pr_1', + triggerId: 'trg_p', + data: fact, + }), + headers: {}, + }); + + expect(result.handled).toBe(true); + expect(handler.mock.calls[0]?.[0]).toEqual(fact); + const event = handler.mock.calls[0]?.[1]; + expect(event.name).toBe('payments.webhook.received'); + expect(event.metadata.integrationId).toBe('int_stripe'); + expect(event.metadata.payment).toEqual({ + provider: 'Stripe', + providerEventId: 'evt_1', + eventName: 'payment_intent.succeeded', + }); + }); + it('lifts schema + record id onto event.metadata for db events', async () => { const doc = { id: 'rec_1', email: 'a@b.io' }; const receiver = new NorbixWebhookReceiver();