Skip to content

Commit

Permalink
Support unknown webhook events (#85)
Browse files Browse the repository at this point in the history
* feat: implicitly support legacy webhooks

* test: add generic event test

* fix: update transform types
  • Loading branch information
danbillson authored Dec 2, 2024
1 parent 9c3064b commit 9d441c5
Show file tree
Hide file tree
Showing 12 changed files with 225 additions and 7 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ When we make [non-breaking changes](https://developer.paddle.com/api-reference/a

This means when upgrading minor versions of the SDK, you may notice type errors. You can safely ignore these or fix by adding additional type guards.

## 2.1.3 - 2024-11-29

### Changed

- `paddle.webhooks.unmarshal` will now return an event for unhandled event types instead of `null` this is only possible for legacy/no longer supported events or for new events that have not been added to the sdk yet

## 2.1.2 - 2024-11-26

### Fixed
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@paddle/paddle-node-sdk",
"version": "2.1.2",
"version": "2.1.3",
"description": "A Node.js SDK that you can use to integrate Paddle Billing with applications written in server-side JavaScript.",
"main": "dist/cjs/index.cjs.node.js",
"module": "dist/esm/index.esm.node.js",
Expand Down Expand Up @@ -68,5 +68,6 @@
"import": "./dist/esm/index.esm.node.js",
"require": "./dist/cjs/index.cjs.node.js"
}
}
},
"dependencies": {}
}
165 changes: 165 additions & 0 deletions src/__tests__/mocks/notifications/invoice-paid.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Invoice paid is a legacy/unsupported event which is implicitly handled through GenericEvent

import { IEventsResponse } from '../../../types/index.js';

export const InvoicePaidMock: IEventsResponse<object> = {
event_id: 'evt_01jdw4vq5a26w8mpfc59mez047',
event_type: 'invoice.paid',
occurred_at: '2024-11-29T14:23:08.971054Z',
notification_id: 'ntf_01h90nmerv7vrn93f97j5v72p7',
data: {
id: 'inv_01jdw4vk9fr1n6smpbhykm6ha9',
items: [
{
price: {
product_id: 'pro_01gv5dvjjx0nmydxa2pb9trdcq',
unit_price: {
amount: '1000',
currency_code: 'GBP',
},
},
quantity: 1,
},
],
due_at: '2024-11-30T14:23:07.865592Z',
status: 'paid',
details: {
totals: {
tax: '167',
total: '1000',
subtotal: '833',
},
line_items: [
{
totals: {
tax: '0',
total: '1000',
subtotal: '1000',
},
product: {
id: 'pro_01gv5dvjjx0nmydxa2pb9trdcq',
name: 'AT Test Product',
status: 'active',
image_url: null,
description: 'Exmaple',
tax_category: 'standard',
},
quantity: 1,
tax_rate: '0',
unit_totals: {
tax: '0',
total: '1000',
subtotal: '1000',
},
},
],
},
paid_at: '2024-11-29T14:23:05.561011761Z',
checkout: null,
issued_at: '2024-11-29T14:23:07.865592Z',
address_id: 'add_01jaav7fx9ew7w6293cxjdkrp7',
created_at: '2024-11-29T14:23:05.007735Z',
updated_at: '2024-11-29T14:23:05.007735Z',
business_id: 'biz_01jaav8zw7anv2egarn5vz7xhr',
custom_data: [],
customer_id: 'ctm_01gv5gb258na82skxd7ng7ha3r',
currency_code: 'GBP',
billing_period: {
type: 'billing',
ends_at: '2024-11-30',
starts_at: '2024-11-29',
},
invoice_number: '296-844420',
transaction_id: 'txn_01jdw4vgdq62e0b6x8dqsm4ycn',
billing_details: {
payment_terms: {
interval: 'day',
frequency: 1,
},
enable_checkout: true,
purchase_order_number: null,
additional_information: null,
},
},
};

export const InvoicePaidMockExpectation = {
data: {
addressId: 'add_01jaav7fx9ew7w6293cxjdkrp7',
billingDetails: {
additionalInformation: null,
enableCheckout: true,
paymentTerms: {
frequency: 1,
interval: 'day',
},
purchaseOrderNumber: null,
},
billingPeriod: {
endsAt: '2024-11-30',
startsAt: '2024-11-29',
type: 'billing',
},
businessId: 'biz_01jaav8zw7anv2egarn5vz7xhr',
checkout: null,
createdAt: '2024-11-29T14:23:05.007735Z',
currencyCode: 'GBP',
customData: [],
customerId: 'ctm_01gv5gb258na82skxd7ng7ha3r',
details: {
lineItems: [
{
product: {
description: 'Exmaple',
id: 'pro_01gv5dvjjx0nmydxa2pb9trdcq',
imageUrl: null,
name: 'AT Test Product',
status: 'active',
taxCategory: 'standard',
},
quantity: 1,
taxRate: '0',
totals: {
subtotal: '1000',
tax: '0',
total: '1000',
},
unitTotals: {
subtotal: '1000',
tax: '0',
total: '1000',
},
},
],
totals: {
subtotal: '833',
tax: '167',
total: '1000',
},
},
dueAt: '2024-11-30T14:23:07.865592Z',
id: 'inv_01jdw4vk9fr1n6smpbhykm6ha9',
invoiceNumber: '296-844420',
issuedAt: '2024-11-29T14:23:07.865592Z',
items: [
{
price: {
productId: 'pro_01gv5dvjjx0nmydxa2pb9trdcq',
unitPrice: {
amount: '1000',
currencyCode: 'GBP',
},
},
quantity: 1,
},
],
paidAt: '2024-11-29T14:23:05.561011761Z',
status: 'paid',
transactionId: 'txn_01jdw4vgdq62e0b6x8dqsm4ycn',
updatedAt: '2024-11-29T14:23:05.007735Z',
},
eventId: 'evt_01jdw4vq5a26w8mpfc59mez047',
eventType: 'invoice.paid',
notificationId: 'ntf_01h90nmerv7vrn93f97j5v72p7',
occurredAt: '2024-11-29T14:23:08.971054Z',
};
3 changes: 3 additions & 0 deletions src/__tests__/notifications/notifications-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ import {
TransactionUpdatedMock,
TransactionUpdatedMockExpectation,
} from '../mocks/notifications/transaction-updated.mock.js';
import { InvoicePaidMock, InvoicePaidMockExpectation } from '../mocks/notifications/invoice-paid.mock.js';
import { IEvents, IEventsResponse } from '../../types/index.js';
import { Webhooks } from '../../notifications/index.js';

Expand Down Expand Up @@ -163,6 +164,8 @@ describe('Notifications Parser', () => {
[TransactionPaymentFailedMock.event_type, TransactionPaymentFailedMock, TransactionPaymentFailedMockExpectation],
[TransactionReadyMock.event_type, TransactionReadyMock, TransactionReadyMockExpectation],
[TransactionUpdatedMock.event_type, TransactionUpdatedMock, TransactionUpdatedMockExpectation],
// Generic Event
[InvoicePaidMock.event_type, InvoicePaidMock, InvoicePaidMockExpectation],
])('validate %s ', (_eventType: string, eventMock: IEventsResponse, expectedValue: any = {}) => {
expect(Webhooks.fromJson(eventMock as IEvents)).toEqual(expectedValue);
});
Expand Down
4 changes: 2 additions & 2 deletions src/entities/events/event-collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { type IEvents, type IEventsResponse } from '../../types/index.js';
import { Collection } from '../../internal/base/index.js';
import { type EventEntity, Webhooks } from '../../notifications/index.js';

export class EventCollection extends Collection<IEventsResponse, EventEntity | null> {
override fromJson(data: IEvents): EventEntity | null {
export class EventCollection extends Collection<IEventsResponse, EventEntity> {
override fromJson(data: IEvents): EventEntity {
return Webhooks.fromJson(data);
}
}
2 changes: 1 addition & 1 deletion src/entities/notifications/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class Notification {
public readonly id: string;
public readonly type: IEventName;
public readonly status: NotificationStatus;
public readonly payload: EventEntity | null;
public readonly payload: EventEntity;
public readonly occurredAt: string;
public readonly deliveredAt: null | string;
public readonly replayedAt: null | string;
Expand Down
1 change: 1 addition & 0 deletions src/internal/base/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './base-resource.js';
export * from './query-parameters.js';
export * from './path-parameters.js';
export * from './collection.js';
export * from './transform.js';
27 changes: 27 additions & 0 deletions src/internal/base/transform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
function toCamelCase(str: string) {
return str.toLowerCase().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}

export function convertKeysToCamelCase(obj: object): object {
// Handle null or primitive values
if (obj === null || typeof obj !== 'object') {
return obj;
}

// Handle arrays
if (Array.isArray(obj)) {
return obj.map(convertKeysToCamelCase);
}

// Handle objects
const converted: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
// Convert the key to camelCase
const camelCaseKey = toCamelCase(key);

// Recursively convert nested objects and arrays
converted[camelCaseKey] = convertKeysToCamelCase(value);
}

return converted;
}
12 changes: 12 additions & 0 deletions src/notifications/events/generic/generic-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Event } from '../../../entities/events/event.js';
import { convertKeysToCamelCase } from '../../../internal/base/index.js';
import { type IEventsResponse } from '../../../types/index.js';

export class GenericEvent extends Event {
public override readonly data: object;

constructor(response: IEventsResponse<object>) {
super(response);
this.data = convertKeysToCamelCase(response.data);
}
}
1 change: 1 addition & 0 deletions src/notifications/events/generic/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './generic-event.js';
1 change: 1 addition & 0 deletions src/notifications/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './adjustment/index.js';
export * from './business/index.js';
export * from './customer/index.js';
export * from './discount/index.js';
export * from './generic/index.js';
export * from './payment-method/index.js';
export * from './payout/index.js';
export * from './price/index.js';
Expand Down
5 changes: 3 additions & 2 deletions src/notifications/helpers/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
DiscountCreatedEvent,
DiscountImportedEvent,
DiscountUpdatedEvent,
GenericEvent,
PaymentMethodDeletedEvent,
PaymentMethodSavedEvent,
PayoutCreatedEvent,
Expand Down Expand Up @@ -65,7 +66,7 @@ export class Webhooks {
return await new WebhooksValidator().isValidSignature(requestBody, secretKey, signature);
}

static fromJson(data: IEvents): EventEntity | null {
static fromJson(data: IEvents): EventEntity {
switch (data.event_type) {
case EventName.AddressCreated:
return new AddressCreatedEvent(data);
Expand Down Expand Up @@ -158,7 +159,7 @@ export class Webhooks {
default:
// @ts-expect-error event_type did not match any handled events
Logger.log(`Unknown event_type ${data.event_type}`);
return null;
return new GenericEvent(data) as EventEntity;
}
}
}

0 comments on commit 9d441c5

Please sign in to comment.