Skip to content

Commit 4ce62e9

Browse files
authored
Merge pull request #314 from Smartdevs17/feat-webhooks-fixed
Feat: implement subscription webhooks
2 parents 049c83d + 451b5b5 commit 4ce62e9

12 files changed

Lines changed: 2036 additions & 0 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import {
2+
WebhookDeliveryService,
3+
buildWebhookPayload,
4+
signWebhookPayload,
5+
verifyWebhookSignature,
6+
} from '../webhook';
7+
import type {
8+
WebhookEventInput,
9+
WebhookPlanSnapshot,
10+
WebhookSubscriptionSnapshot,
11+
} from '../../../src/types/webhook';
12+
13+
const makeSubscription = (overrides: Partial<WebhookSubscriptionSnapshot> = {}): WebhookSubscriptionSnapshot => ({
14+
id: 'sub_1',
15+
planId: 'plan_1',
16+
subscriberId: 'user_1',
17+
status: 'active',
18+
startedAt: 1_700_000_000,
19+
lastChargedAt: 1_700_000_000,
20+
nextChargeAt: 1_700_086_400,
21+
totalPaid: 500,
22+
totalGasSpent: 10,
23+
chargeCount: 1,
24+
pausedAt: 0,
25+
pauseDuration: 0,
26+
refundRequestedAmount: 0,
27+
...overrides,
28+
});
29+
30+
const makePlan = (overrides: Partial<WebhookPlanSnapshot> = {}): WebhookPlanSnapshot => ({
31+
id: 'plan_1',
32+
merchantId: 'merchant_1',
33+
name: 'Pro',
34+
price: 500,
35+
token: 'USDC',
36+
interval: 'monthly',
37+
active: true,
38+
subscriberCount: 1,
39+
createdAt: 1_700_000_000,
40+
...overrides,
41+
});
42+
43+
const makeInput = (overrides: Partial<WebhookEventInput> = {}): WebhookEventInput => ({
44+
webhookId: 'whk_1',
45+
merchantId: 'merchant_1',
46+
eventType: 'subscription.charged',
47+
subscription: makeSubscription(),
48+
plan: makePlan(),
49+
previousStatus: 'active',
50+
currentStatus: 'active',
51+
occurredAt: 1_700_000_100,
52+
...overrides,
53+
});
54+
55+
describe('WebhookDeliveryService', () => {
56+
it('signs and verifies webhook payloads', () => {
57+
const payload = buildWebhookPayload(makeInput());
58+
const signature = signWebhookPayload(payload, 'secret');
59+
60+
expect(verifyWebhookSignature(signature, payload, 'secret')).toBe(true);
61+
expect(verifyWebhookSignature(signature, payload, 'different-secret')).toBe(false);
62+
});
63+
64+
it('delivers with exponential backoff until success', async () => {
65+
const fetchImpl = jest
66+
.fn()
67+
.mockRejectedValueOnce(new Error('network down'))
68+
.mockResolvedValueOnce({ ok: true, status: 200 });
69+
const sleepImpl = jest.fn().mockResolvedValue(undefined);
70+
const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch, sleepImpl });
71+
72+
const webhook = service.registerWebhook({
73+
merchantId: 'merchant_1',
74+
url: 'https://example.com/webhook',
75+
events: ['subscription.charged'],
76+
secretKey: 'secret',
77+
retryPolicy: {
78+
maxRetries: 3,
79+
initialDelayMs: 10,
80+
maxDelayMs: 20,
81+
backoffFactor: 2,
82+
},
83+
});
84+
85+
const result = await service.deliverEvent(makeInput({ webhookId: webhook.id }));
86+
87+
expect(result?.delivery.status).toBe('delivered');
88+
expect(result?.delivery.attempts).toBe(2);
89+
expect(fetchImpl).toHaveBeenCalledTimes(2);
90+
expect(sleepImpl).toHaveBeenCalledWith(10);
91+
});
92+
93+
it('fails fast for payloads over 1MB', async () => {
94+
const fetchImpl = jest.fn();
95+
const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch });
96+
97+
const webhook = service.registerWebhook({
98+
merchantId: 'merchant_1',
99+
url: 'https://example.com/webhook',
100+
events: ['subscription.charged'],
101+
secretKey: 'secret',
102+
});
103+
104+
const giantSubscription = makeSubscription({
105+
totalPaid: 500,
106+
status: 'active',
107+
// Inflate the payload by using a large subscriber identifier.
108+
subscriberId: 'x'.repeat(1_050_000),
109+
});
110+
111+
const result = await service.deliverEvent(
112+
makeInput({ webhookId: webhook.id, subscription: giantSubscription })
113+
);
114+
115+
expect(result?.delivery.status).toBe('failed');
116+
expect(fetchImpl).not.toHaveBeenCalled();
117+
});
118+
119+
it('supports manual retry after a failed delivery', async () => {
120+
const fetchImpl = jest
121+
.fn()
122+
.mockRejectedValueOnce(new Error('down'))
123+
.mockResolvedValueOnce({ ok: true, status: 200 });
124+
const sleepImpl = jest.fn().mockResolvedValue(undefined);
125+
const service = new WebhookDeliveryService({ fetchImpl: fetchImpl as typeof fetch, sleepImpl });
126+
127+
const webhook = service.registerWebhook({
128+
merchantId: 'merchant_1',
129+
url: 'https://example.com/webhook',
130+
events: ['subscription.charged'],
131+
secretKey: 'secret',
132+
retryPolicy: {
133+
maxRetries: 0,
134+
initialDelayMs: 10,
135+
maxDelayMs: 10,
136+
backoffFactor: 2,
137+
},
138+
});
139+
140+
const first = await service.deliverEvent(makeInput({ webhookId: webhook.id }));
141+
expect(first?.delivery.status).toBe('failed');
142+
143+
const retry = await service.retryWebhookDelivery(first!.delivery.id);
144+
expect(retry.delivery.status).toBe('delivered');
145+
expect(retry.delivery.attempts).toBeGreaterThanOrEqual(1);
146+
});
147+
});

backend/services/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,16 @@ export type {
77
ExportFormat,
88
RetentionPolicy,
99
} from './auditTypes';
10+
export {
11+
WebhookDeliveryService,
12+
webhookDeliveryService,
13+
buildWebhookPayload,
14+
signWebhookPayload,
15+
verifyWebhookSignature,
16+
isWebhookEventAllowed,
17+
} from './webhook';
18+
export type {
19+
RegisterWebhookInput,
20+
WebhookDeliveryResult,
21+
WebhookEventInput,
22+
} from './webhook';

0 commit comments

Comments
 (0)