Skip to content

Commit ddd6f6e

Browse files
Release v3.21.1 (#106)
* Releasing v3.21.1 * Updated the test * docs: use WebhookEventType enum instead of hardcoded strings in README webhook * Version Bump * Minor class name refactor --------- Co-authored-by: cb-karthikp <karthikp@chargebee.com>
1 parent 1a9b106 commit ddd6f6e

9 files changed

Lines changed: 251 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
### v3.21.1 (2026-02-20)
2+
* * *
3+
4+
### Core Changes:
5+
6+
- Added new core utility method.
7+
18
### v3.21.0 (2026-02-11)
29
* * *
310

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ increment-patch:
3030
@$(MAKE) update-version VERSION=$(NEW_VERSION)
3131
@echo "Version bumped from $(CURRENT) to $(NEW_VERSION)"
3232

33-
test:
34-
@echo "Test is not written currently for this module."
33+
test: install
34+
npm test
3535

3636
format:
3737
npm run prettier

README.md

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ The simplest way to handle webhooks is using the `webhooks` property on your ini
207207
```typescript
208208
import express from 'express';
209209
import Chargebee, {
210+
WebhookEventType,
210211
AuthenticationError,
211212
PayloadValidationError,
212213
PayloadParseError,
@@ -221,7 +222,7 @@ const app = express();
221222
app.use(express.json());
222223

223224
// ⚠️ Register listeners once at startup, not inside request handlers
224-
chargebee.webhooks.on('subscription_created', async ({ event, response }) => {
225+
chargebee.webhooks.on(WebhookEventType.SubscriptionCreated, async ({ event, response }) => {
225226
console.log(`Subscription created: ${event.id}`);
226227
const subscription = event.content.subscription;
227228
console.log(`Customer: ${subscription.customer_id}`);
@@ -265,6 +266,7 @@ For more control or multiple webhook endpoints, use `chargebee.webhooks.createHa
265266
```typescript
266267
import express, { Request, Response } from 'express';
267268
import Chargebee, {
269+
WebhookEventType,
268270
basicAuthValidator,
269271
AuthenticationError,
270272
PayloadValidationError,
@@ -288,15 +290,15 @@ handler.requestValidator = basicAuthValidator((username, password) => {
288290
});
289291

290292
// ⚠️ Register event listeners once at startup, not inside request handlers
291-
handler.on('subscription_created', async ({ event, response }) => {
293+
handler.on(WebhookEventType.SubscriptionCreated, async ({ event, response }) => {
292294
console.log(`Subscription created: ${event.id}`);
293295
const subscription = event.content.subscription;
294296
console.log(`Customer: ${subscription.customer_id}`);
295297
console.log(`Plan: ${subscription.plan_id}`);
296298
response?.status(200).send('OK');
297299
});
298300

299-
handler.on('payment_succeeded', async ({ event, response }) => {
301+
handler.on(WebhookEventType.PaymentSucceeded, async ({ event, response }) => {
300302
console.log(`Payment succeeded: ${event.id}`);
301303
const transaction = event.content.transaction;
302304
const customer = event.content.customer;
@@ -333,31 +335,35 @@ For more control, you can parse webhook events manually:
333335

334336
```typescript
335337
import express from 'express';
336-
import Chargebee, { type WebhookEvent } from 'chargebee';
338+
import Chargebee, { type WebhookEvent, WebhookEventType } from 'chargebee';
337339

338340
const app = express();
339341
app.use(express.json());
340342

341343
app.post('/chargebee/webhooks', async (req, res) => {
342344
try {
343345
const event = req.body as WebhookEvent;
344-
346+
345347
switch (event.event_type) {
346-
case 'subscription_created':
347-
// Access event content with proper typing
348-
const subscription = event.content.subscription;
348+
case WebhookEventType.SubscriptionCreated: {
349+
// Cast to specific event type for proper content typing
350+
const typedEvent = event as WebhookEvent<WebhookEventType.SubscriptionCreated>;
351+
const subscription = typedEvent.content.subscription;
349352
console.log('Subscription created:', subscription.id);
350353
break;
351-
352-
case 'payment_succeeded':
353-
const transaction = event.content.transaction;
354+
}
355+
356+
case WebhookEventType.PaymentSucceeded: {
357+
const typedEvent = event as WebhookEvent<WebhookEventType.PaymentSucceeded>;
358+
const transaction = typedEvent.content.transaction;
354359
console.log('Payment succeeded:', transaction.amount);
355360
break;
356-
361+
}
362+
357363
default:
358364
console.log('Unhandled event type:', event.event_type);
359365
}
360-
366+
361367
res.status(200).send('OK');
362368
} catch (err) {
363369
console.error('Error processing webhook:', err);
@@ -375,7 +381,7 @@ app.listen(8080);
375381
**Respond with 200** to acknowledge receipt:
376382

377383
```typescript
378-
handler.on('subscription_created', async ({ event, response }) => {
384+
handler.on(WebhookEventType.SubscriptionCreated, async ({ event, response }) => {
379385
await provisionAccess(event.content.subscription);
380386
response?.status(200).json({ received: true });
381387
});
@@ -384,7 +390,7 @@ handler.on('subscription_created', async ({ event, response }) => {
384390
**Respond with 5xx** so Chargebee retries on failure:
385391

386392
```typescript
387-
handler.on('payment_succeeded', async ({ event, response }) => {
393+
handler.on(WebhookEventType.PaymentSucceeded, async ({ event, response }) => {
388394
try {
389395
await recordPayment(event.content.transaction);
390396
response?.status(200).send('OK');
@@ -397,7 +403,7 @@ handler.on('payment_succeeded', async ({ event, response }) => {
397403
**Access request context** (headers, middleware data):
398404

399405
```typescript
400-
handler.on('customer_created', async ({ event, request, response }) => {
406+
handler.on(WebhookEventType.CustomerCreated, async ({ event, request, response }) => {
401407
const tenantId = (request as any)?.tenant?.id;
402408
await createCustomerForTenant(tenantId, event.content.customer);
403409
response?.status(200).send('OK');

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.21.0
1+
3.21.1

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "chargebee",
3-
"version": "3.21.0",
3+
"version": "3.21.1",
44
"description": "A library for integrating with Chargebee.",
55
"scripts": {
66
"prepack": "npm install && npm run build",

src/createChargebee.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ export const CreateChargebee = (httpClient: HttpClientInterface) => {
2929

3030
// Initialize webhooks handler with auto-configured Basic Auth (if env vars are set)
3131
const handler = createDefaultHandler();
32-
32+
this.__clientIdentifier = (serviceName: string) => {
33+
extend(true, this._env, { userAgentSuffix: serviceName });
34+
};
3335
// Create webhooks namespace with handler methods + createHandler factory
3436
this.webhooks = Object.assign(handler, {
3537
createHandler<ReqT = unknown, ResT = unknown>(

src/environment.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const Environment = {
99
hostSuffix: '.chargebee.com',
1010
apiPath: '/api/v2',
1111
timeout: DEFAULT_TIME_OUT,
12-
clientVersion: 'v3.21.0',
12+
clientVersion: 'v3.21.1',
1313
port: DEFAULT_PORT,
1414
timemachineWaitInMillis: DEFAULT_TIME_MACHINE_WAIT,
1515
exportWaitInMillis: DEFAULT_EXPORT_WAIT,

test/requestWrapper.test.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { expect } from 'chai';
2+
import { CreateChargebee } from '../src/createChargebee.js';
3+
import { Environment } from '../src/environment.js';
4+
5+
let capturedRequests: Request[] = [];
6+
let responseFactory: ((attempt: number) => Response) | null = null;
7+
let callCount = 0;
8+
9+
const mockHttpClient = {
10+
makeApiRequest: async (request: Request): Promise<Response> => {
11+
capturedRequests.push(request.clone());
12+
const attempt = callCount++;
13+
if (responseFactory) {
14+
return responseFactory(attempt);
15+
}
16+
return new Response(JSON.stringify({ list: [], next_offset: null }), {
17+
status: 200,
18+
headers: { 'Content-Type': 'application/json' },
19+
});
20+
},
21+
};
22+
23+
const Chargebee = CreateChargebee(mockHttpClient);
24+
25+
function createChargebee(conf: Record<string, any> = {}) {
26+
return new (Chargebee as any)({
27+
site: 'test-site',
28+
apiKey: 'test-api-key',
29+
...conf,
30+
});
31+
}
32+
33+
beforeEach(() => {
34+
capturedRequests = [];
35+
responseFactory = null;
36+
callCount = 0;
37+
});
38+
39+
describe('RequestWrapper - request headers', () => {
40+
describe('User-Agent header', () => {
41+
it('should set User-Agent to Chargebee-NodeJs-Client with clientVersion only when __clientIdentifier() is not called', async () => {
42+
const chargebee = createChargebee();
43+
await chargebee.customer.list();
44+
45+
const userAgent = capturedRequests[0].headers.get('User-Agent');
46+
expect(userAgent).to.equal(
47+
`Chargebee-NodeJs-Client ${Environment.clientVersion}`,
48+
);
49+
});
50+
51+
it('should append service name with semicolon after calling chargebee.__clientIdentifier()', async () => {
52+
const chargebee = createChargebee();
53+
chargebee.__clientIdentifier('local-test-suffix');
54+
55+
await chargebee.customer.list();
56+
57+
const userAgent = capturedRequests[0].headers.get('User-Agent');
58+
expect(userAgent).to.equal(
59+
`Chargebee-NodeJs-Client ${Environment.clientVersion};local-test-suffix`,
60+
);
61+
});
62+
63+
it('should reflect updated service name if chargebee.__clientIdentifier() is called again', async () => {
64+
const chargebee = createChargebee();
65+
chargebee.__clientIdentifier('first-service');
66+
chargebee.__clientIdentifier('second-service');
67+
68+
await chargebee.customer.list();
69+
70+
const userAgent = capturedRequests[0].headers.get('User-Agent');
71+
expect(userAgent).to.equal(
72+
`Chargebee-NodeJs-Client ${Environment.clientVersion};second-service`,
73+
);
74+
});
75+
});
76+
77+
describe('Authorization header', () => {
78+
it('should set Authorization as Basic base64(apiKey:)', async () => {
79+
const chargebee = createChargebee({ apiKey: 'test-key-123' });
80+
await chargebee.customer.list();
81+
82+
const expected =
83+
'Basic ' + Buffer.from('test-key-123:').toString('base64');
84+
expect(capturedRequests[0].headers.get('Authorization')).to.equal(
85+
expected,
86+
);
87+
});
88+
89+
it('should include the trailing colon in the base64-encoded value', async () => {
90+
const chargebee = createChargebee({ apiKey: 'my_secret_key' });
91+
await chargebee.customer.list();
92+
93+
const raw = capturedRequests[0].headers
94+
.get('Authorization')!
95+
.replace('Basic ', '');
96+
const decoded = Buffer.from(raw, 'base64').toString('utf-8');
97+
expect(decoded).to.equal('my_secret_key:');
98+
});
99+
});
100+
101+
describe('Accept header', () => {
102+
it('should set Accept to application/json', async () => {
103+
const chargebee = createChargebee();
104+
await chargebee.customer.list();
105+
106+
expect(capturedRequests[0].headers.get('Accept')).to.equal(
107+
'application/json',
108+
);
109+
});
110+
});
111+
112+
describe('Content-Type header', () => {
113+
it('should set Content-Type to application/x-www-form-urlencoded for POST requests', async () => {
114+
responseFactory = () =>
115+
new Response(JSON.stringify({ customer: { id: 'cust_123' } }), {
116+
status: 200,
117+
headers: { 'Content-Type': 'application/json' },
118+
});
119+
120+
const chargebee = createChargebee();
121+
await chargebee.customer.create({ first_name: 'John' });
122+
123+
expect(capturedRequests[0].headers.get('Content-Type')).to.equal(
124+
'application/x-www-form-urlencoded; charset=utf-8',
125+
);
126+
});
127+
128+
it('should set Content-Type to application/x-www-form-urlencoded for GET requests', async () => {
129+
const chargebee = createChargebee();
130+
await chargebee.customer.list();
131+
132+
expect(capturedRequests[0].headers.get('Content-Type')).to.equal(
133+
'application/x-www-form-urlencoded; charset=utf-8',
134+
);
135+
});
136+
});
137+
138+
describe('Lang-Version header', () => {
139+
it('should set Lang-Version to the current Node.js process.version', async () => {
140+
const chargebee = createChargebee();
141+
await chargebee.customer.list();
142+
143+
expect(capturedRequests[0].headers.get('Lang-Version')).to.equal(
144+
process.version,
145+
);
146+
});
147+
});
148+
149+
describe('X-CB-Retry-Attempt header', () => {
150+
it('should NOT include X-CB-Retry-Attempt on the first attempt', async () => {
151+
const chargebee = createChargebee();
152+
await chargebee.customer.list();
153+
154+
expect(
155+
capturedRequests[0].headers.get('X-CB-Retry-Attempt'),
156+
).to.be.null;
157+
});
158+
159+
it('should set X-CB-Retry-Attempt to "1" on the first retry', async () => {
160+
responseFactory = (attempt) => {
161+
if (attempt === 0) {
162+
return new Response(
163+
JSON.stringify({ http_status_code: 500, message: 'server error' }),
164+
{ status: 500, headers: { 'Content-Type': 'application/json' } },
165+
);
166+
}
167+
return new Response(JSON.stringify({ list: [] }), {
168+
status: 200,
169+
headers: { 'Content-Type': 'application/json' },
170+
});
171+
};
172+
173+
const chargebee = createChargebee({
174+
retryConfig: { enabled: true, maxRetries: 2, delayMs: 0, retryOn: [500] },
175+
});
176+
await chargebee.customer.list();
177+
178+
expect(capturedRequests.length).to.equal(2);
179+
expect(
180+
capturedRequests[0].headers.get('X-CB-Retry-Attempt'),
181+
).to.be.null;
182+
expect(capturedRequests[1].headers.get('X-CB-Retry-Attempt')).to.equal(
183+
'1',
184+
);
185+
});
186+
187+
it('should increment X-CB-Retry-Attempt on each subsequent retry', async () => {
188+
responseFactory = (attempt) => {
189+
if (attempt < 2) {
190+
return new Response(
191+
JSON.stringify({ http_status_code: 500, message: 'server error' }),
192+
{ status: 500, headers: { 'Content-Type': 'application/json' } },
193+
);
194+
}
195+
return new Response(JSON.stringify({ list: [] }), {
196+
status: 200,
197+
headers: { 'Content-Type': 'application/json' },
198+
});
199+
};
200+
201+
const chargebee = createChargebee({
202+
retryConfig: { enabled: true, maxRetries: 3, delayMs: 0, retryOn: [500] },
203+
});
204+
await chargebee.customer.list();
205+
206+
expect(capturedRequests.length).to.equal(3);
207+
expect(capturedRequests[0].headers.get('X-CB-Retry-Attempt')).to.be.null;
208+
expect(capturedRequests[1].headers.get('X-CB-Retry-Attempt')).to.equal('1');
209+
expect(capturedRequests[2].headers.get('X-CB-Retry-Attempt')).to.equal('2');
210+
});
211+
});
212+
});

0 commit comments

Comments
 (0)