Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support unknown webhook events #85

Merged
merged 3 commits into from
Dec 2, 2024
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
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;
}
}
}