Skip to content

Commit 9d441c5

Browse files
authored
Support unknown webhook events (#85)
* feat: implicitly support legacy webhooks * test: add generic event test * fix: update transform types
1 parent 9c3064b commit 9d441c5

File tree

12 files changed

+225
-7
lines changed

12 files changed

+225
-7
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ When we make [non-breaking changes](https://developer.paddle.com/api-reference/a
1212

1313
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.
1414

15+
## 2.1.3 - 2024-11-29
16+
17+
### Changed
18+
19+
- `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
20+
1521
## 2.1.2 - 2024-11-26
1622

1723
### Fixed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@paddle/paddle-node-sdk",
3-
"version": "2.1.2",
3+
"version": "2.1.3",
44
"description": "A Node.js SDK that you can use to integrate Paddle Billing with applications written in server-side JavaScript.",
55
"main": "dist/cjs/index.cjs.node.js",
66
"module": "dist/esm/index.esm.node.js",
@@ -68,5 +68,6 @@
6868
"import": "./dist/esm/index.esm.node.js",
6969
"require": "./dist/cjs/index.cjs.node.js"
7070
}
71-
}
71+
},
72+
"dependencies": {}
7273
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// Invoice paid is a legacy/unsupported event which is implicitly handled through GenericEvent
2+
3+
import { IEventsResponse } from '../../../types/index.js';
4+
5+
export const InvoicePaidMock: IEventsResponse<object> = {
6+
event_id: 'evt_01jdw4vq5a26w8mpfc59mez047',
7+
event_type: 'invoice.paid',
8+
occurred_at: '2024-11-29T14:23:08.971054Z',
9+
notification_id: 'ntf_01h90nmerv7vrn93f97j5v72p7',
10+
data: {
11+
id: 'inv_01jdw4vk9fr1n6smpbhykm6ha9',
12+
items: [
13+
{
14+
price: {
15+
product_id: 'pro_01gv5dvjjx0nmydxa2pb9trdcq',
16+
unit_price: {
17+
amount: '1000',
18+
currency_code: 'GBP',
19+
},
20+
},
21+
quantity: 1,
22+
},
23+
],
24+
due_at: '2024-11-30T14:23:07.865592Z',
25+
status: 'paid',
26+
details: {
27+
totals: {
28+
tax: '167',
29+
total: '1000',
30+
subtotal: '833',
31+
},
32+
line_items: [
33+
{
34+
totals: {
35+
tax: '0',
36+
total: '1000',
37+
subtotal: '1000',
38+
},
39+
product: {
40+
id: 'pro_01gv5dvjjx0nmydxa2pb9trdcq',
41+
name: 'AT Test Product',
42+
status: 'active',
43+
image_url: null,
44+
description: 'Exmaple',
45+
tax_category: 'standard',
46+
},
47+
quantity: 1,
48+
tax_rate: '0',
49+
unit_totals: {
50+
tax: '0',
51+
total: '1000',
52+
subtotal: '1000',
53+
},
54+
},
55+
],
56+
},
57+
paid_at: '2024-11-29T14:23:05.561011761Z',
58+
checkout: null,
59+
issued_at: '2024-11-29T14:23:07.865592Z',
60+
address_id: 'add_01jaav7fx9ew7w6293cxjdkrp7',
61+
created_at: '2024-11-29T14:23:05.007735Z',
62+
updated_at: '2024-11-29T14:23:05.007735Z',
63+
business_id: 'biz_01jaav8zw7anv2egarn5vz7xhr',
64+
custom_data: [],
65+
customer_id: 'ctm_01gv5gb258na82skxd7ng7ha3r',
66+
currency_code: 'GBP',
67+
billing_period: {
68+
type: 'billing',
69+
ends_at: '2024-11-30',
70+
starts_at: '2024-11-29',
71+
},
72+
invoice_number: '296-844420',
73+
transaction_id: 'txn_01jdw4vgdq62e0b6x8dqsm4ycn',
74+
billing_details: {
75+
payment_terms: {
76+
interval: 'day',
77+
frequency: 1,
78+
},
79+
enable_checkout: true,
80+
purchase_order_number: null,
81+
additional_information: null,
82+
},
83+
},
84+
};
85+
86+
export const InvoicePaidMockExpectation = {
87+
data: {
88+
addressId: 'add_01jaav7fx9ew7w6293cxjdkrp7',
89+
billingDetails: {
90+
additionalInformation: null,
91+
enableCheckout: true,
92+
paymentTerms: {
93+
frequency: 1,
94+
interval: 'day',
95+
},
96+
purchaseOrderNumber: null,
97+
},
98+
billingPeriod: {
99+
endsAt: '2024-11-30',
100+
startsAt: '2024-11-29',
101+
type: 'billing',
102+
},
103+
businessId: 'biz_01jaav8zw7anv2egarn5vz7xhr',
104+
checkout: null,
105+
createdAt: '2024-11-29T14:23:05.007735Z',
106+
currencyCode: 'GBP',
107+
customData: [],
108+
customerId: 'ctm_01gv5gb258na82skxd7ng7ha3r',
109+
details: {
110+
lineItems: [
111+
{
112+
product: {
113+
description: 'Exmaple',
114+
id: 'pro_01gv5dvjjx0nmydxa2pb9trdcq',
115+
imageUrl: null,
116+
name: 'AT Test Product',
117+
status: 'active',
118+
taxCategory: 'standard',
119+
},
120+
quantity: 1,
121+
taxRate: '0',
122+
totals: {
123+
subtotal: '1000',
124+
tax: '0',
125+
total: '1000',
126+
},
127+
unitTotals: {
128+
subtotal: '1000',
129+
tax: '0',
130+
total: '1000',
131+
},
132+
},
133+
],
134+
totals: {
135+
subtotal: '833',
136+
tax: '167',
137+
total: '1000',
138+
},
139+
},
140+
dueAt: '2024-11-30T14:23:07.865592Z',
141+
id: 'inv_01jdw4vk9fr1n6smpbhykm6ha9',
142+
invoiceNumber: '296-844420',
143+
issuedAt: '2024-11-29T14:23:07.865592Z',
144+
items: [
145+
{
146+
price: {
147+
productId: 'pro_01gv5dvjjx0nmydxa2pb9trdcq',
148+
unitPrice: {
149+
amount: '1000',
150+
currencyCode: 'GBP',
151+
},
152+
},
153+
quantity: 1,
154+
},
155+
],
156+
paidAt: '2024-11-29T14:23:05.561011761Z',
157+
status: 'paid',
158+
transactionId: 'txn_01jdw4vgdq62e0b6x8dqsm4ycn',
159+
updatedAt: '2024-11-29T14:23:05.007735Z',
160+
},
161+
eventId: 'evt_01jdw4vq5a26w8mpfc59mez047',
162+
eventType: 'invoice.paid',
163+
notificationId: 'ntf_01h90nmerv7vrn93f97j5v72p7',
164+
occurredAt: '2024-11-29T14:23:08.971054Z',
165+
};

src/__tests__/notifications/notifications-parser.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ import {
114114
TransactionUpdatedMock,
115115
TransactionUpdatedMockExpectation,
116116
} from '../mocks/notifications/transaction-updated.mock.js';
117+
import { InvoicePaidMock, InvoicePaidMockExpectation } from '../mocks/notifications/invoice-paid.mock.js';
117118
import { IEvents, IEventsResponse } from '../../types/index.js';
118119
import { Webhooks } from '../../notifications/index.js';
119120

@@ -163,6 +164,8 @@ describe('Notifications Parser', () => {
163164
[TransactionPaymentFailedMock.event_type, TransactionPaymentFailedMock, TransactionPaymentFailedMockExpectation],
164165
[TransactionReadyMock.event_type, TransactionReadyMock, TransactionReadyMockExpectation],
165166
[TransactionUpdatedMock.event_type, TransactionUpdatedMock, TransactionUpdatedMockExpectation],
167+
// Generic Event
168+
[InvoicePaidMock.event_type, InvoicePaidMock, InvoicePaidMockExpectation],
166169
])('validate %s ', (_eventType: string, eventMock: IEventsResponse, expectedValue: any = {}) => {
167170
expect(Webhooks.fromJson(eventMock as IEvents)).toEqual(expectedValue);
168171
});

src/entities/events/event-collection.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import { type IEvents, type IEventsResponse } from '../../types/index.js';
88
import { Collection } from '../../internal/base/index.js';
99
import { type EventEntity, Webhooks } from '../../notifications/index.js';
1010

11-
export class EventCollection extends Collection<IEventsResponse, EventEntity | null> {
12-
override fromJson(data: IEvents): EventEntity | null {
11+
export class EventCollection extends Collection<IEventsResponse, EventEntity> {
12+
override fromJson(data: IEvents): EventEntity {
1313
return Webhooks.fromJson(data);
1414
}
1515
}

src/entities/notifications/notification.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export class Notification {
1212
public readonly id: string;
1313
public readonly type: IEventName;
1414
public readonly status: NotificationStatus;
15-
public readonly payload: EventEntity | null;
15+
public readonly payload: EventEntity;
1616
public readonly occurredAt: string;
1717
public readonly deliveredAt: null | string;
1818
public readonly replayedAt: null | string;

src/internal/base/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './base-resource.js';
22
export * from './query-parameters.js';
33
export * from './path-parameters.js';
44
export * from './collection.js';
5+
export * from './transform.js';

src/internal/base/transform.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
function toCamelCase(str: string) {
2+
return str.toLowerCase().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
3+
}
4+
5+
export function convertKeysToCamelCase(obj: object): object {
6+
// Handle null or primitive values
7+
if (obj === null || typeof obj !== 'object') {
8+
return obj;
9+
}
10+
11+
// Handle arrays
12+
if (Array.isArray(obj)) {
13+
return obj.map(convertKeysToCamelCase);
14+
}
15+
16+
// Handle objects
17+
const converted: Record<string, unknown> = {};
18+
for (const [key, value] of Object.entries(obj)) {
19+
// Convert the key to camelCase
20+
const camelCaseKey = toCamelCase(key);
21+
22+
// Recursively convert nested objects and arrays
23+
converted[camelCaseKey] = convertKeysToCamelCase(value);
24+
}
25+
26+
return converted;
27+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Event } from '../../../entities/events/event.js';
2+
import { convertKeysToCamelCase } from '../../../internal/base/index.js';
3+
import { type IEventsResponse } from '../../../types/index.js';
4+
5+
export class GenericEvent extends Event {
6+
public override readonly data: object;
7+
8+
constructor(response: IEventsResponse<object>) {
9+
super(response);
10+
this.data = convertKeysToCamelCase(response.data);
11+
}
12+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './generic-event.js';

src/notifications/events/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from './adjustment/index.js';
99
export * from './business/index.js';
1010
export * from './customer/index.js';
1111
export * from './discount/index.js';
12+
export * from './generic/index.js';
1213
export * from './payment-method/index.js';
1314
export * from './payout/index.js';
1415
export * from './price/index.js';

src/notifications/helpers/webhooks.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
DiscountCreatedEvent,
1717
DiscountImportedEvent,
1818
DiscountUpdatedEvent,
19+
GenericEvent,
1920
PaymentMethodDeletedEvent,
2021
PaymentMethodSavedEvent,
2122
PayoutCreatedEvent,
@@ -65,7 +66,7 @@ export class Webhooks {
6566
return await new WebhooksValidator().isValidSignature(requestBody, secretKey, signature);
6667
}
6768

68-
static fromJson(data: IEvents): EventEntity | null {
69+
static fromJson(data: IEvents): EventEntity {
6970
switch (data.event_type) {
7071
case EventName.AddressCreated:
7172
return new AddressCreatedEvent(data);
@@ -158,7 +159,7 @@ export class Webhooks {
158159
default:
159160
// @ts-expect-error event_type did not match any handled events
160161
Logger.log(`Unknown event_type ${data.event_type}`);
161-
return null;
162+
return new GenericEvent(data) as EventEntity;
162163
}
163164
}
164165
}

0 commit comments

Comments
 (0)