Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
### v3.30.1 (2026-08-11)
* * *
### Bug Fixes:
- Filter operators `in`, `not_in` and `between` are now sent as a single form field on POST requests, matching the behaviour on list requests. Filters on export operations, such as `subscription[updated_at][between]`, were index-encoded and therefore ignored by the API.

### v3.30.0 (2026-07-30)
* * *
### New Resources:
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.30.0
3.30.1
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "chargebee",
"version": "3.30.0",
"version": "3.30.1",
"description": "A library for integrating with Chargebee.",
"scripts": {
"prepack": "npm install && npm run build",
Expand Down
2 changes: 1 addition & 1 deletion src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const Environment = {
hostSuffix: '.chargebee.com',
apiPath: '/api/v2',
timeout: DEFAULT_TIME_OUT,
clientVersion: 'v3.30.0',
clientVersion: 'v3.30.1',
port: DEFAULT_PORT,
timemachineWaitInMillis: DEFAULT_TIME_MACHINE_WAIT,
exportWaitInMillis: DEFAULT_EXPORT_WAIT,
Expand Down
16 changes: 14 additions & 2 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,12 @@ export function getApiURL(
(urlSuffix !== null ? urlSuffix : '')
);
}
// Filter operators whose value is the whole array, sent as a single field
// (e.g. updated_at[between]=[1704067200,1717199999]).
const arrayOperators: string[] = ['in', 'not_in', 'between'];

export function serialize(paramObj: any) {
let key: string, value: string | Object;
let array_ops: string[] = ['in', 'not_in', 'between'];
for (key in paramObj) {
value = paramObj[key];
if (typeof value === 'object' && isObject(value)) {
Expand All @@ -149,7 +152,7 @@ export function serialize(paramObj: any) {
for (child_key in value) {
key = key + '[' + child_key + ']';
paramObj[key] = (value as any)[child_key];
if (array_ops.includes(child_key)) {
if (arrayOperators.includes(child_key)) {
paramObj[key] = JSON.stringify((value as any)[child_key]);
}
}
Expand Down Expand Up @@ -211,6 +214,15 @@ export function encodeParams(
);
}
serialized.push(encodeURIComponent(key) + '=' + attrVal);
} else if (isArray(value) && arrayOperators.includes(originalKey)) {
// An empty filter is not a filter, so leave it out of the request.
if (value.length > 0) {
serialized.push(
encodeURIComponent(key) +
'=' +
encodeURIComponent(JSON.stringify(value)),
);
}
} else if (
isArray(value) &&
!(jsonKeys && jsonKeys[originalKey] === level)
Expand Down
23 changes: 23 additions & 0 deletions test/requestWrapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,29 @@ describe('RequestWrapper - request body', () => {
expect(body).to.not.equal('');
expect(body).to.include('first_name=John');
});

it('should send filter array operators as a single field, not indexed entries', async () => {
responseFactory = () =>
new Response(JSON.stringify({ export: { id: 'export_123' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});

const chargebee = createChargebee();
await chargebee.export.subscriptions({
subscription: {
updated_at: { between: [1704067200, 1717199999] },
id: { in: ['sub_1', 'sub_2'] },
},
});

const body = decodeURIComponent(await capturedRequests[0].text());
expect(body).to.include(
'subscription[updated_at][between]=[1704067200,1717199999]',
);
expect(body).to.include('subscription[id][in]=["sub_1","sub_2"]');
expect(body).to.not.include('[between][0]');
});
});
});

Expand Down
226 changes: 226 additions & 0 deletions test/util.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { expect } from 'chai';
import { encodeListParams, encodeParams, serialize } from '../src/util.js';

// Bracket notation is easier to read decoded; the escaping itself is asserted
// separately in "value escaping".
function encoded(paramObj: any, jsonKeys?: any): string {
return decodeURIComponent(
encodeParams(paramObj, undefined, undefined, undefined, jsonKeys),
);
}

describe('encodeParams - scalars', () => {
it('should encode simple attributes', () => {
expect(
encoded({ first_name: 'John', last_name: 'Doe', locale: 'fr-CA' }),
).to.equal('first_name=John&last_name=Doe&locale=fr-CA');
});

it('should encode numbers and booleans', () => {
expect(
encoded({ quantity: 1, unit_price: 0, auto_collection: true }),
).to.equal('quantity=1&unit_price=0&auto_collection=true');
});

it('should escape reserved characters and encode spaces as +', () => {
expect(encodeParams({ first_name: 'John Doe' })).to.equal(
'first_name=John+Doe',
);
expect(encodeParams({ email: 'john+cb@test.com' })).to.equal(
'email=john%2Bcb%40test.com',
);
expect(encodeParams({ note: 'a&b=c' })).to.equal('note=a%26b%3Dc');
});

it('should keep empty strings but drop null and undefined', () => {
expect(encoded({ a: '', b: null, c: undefined, d: 'x' })).to.equal(
'a=&d=x',
);
});
});

describe('encodeParams - nested objects', () => {
it('should encode a sub-resource as bracket notation', () => {
expect(
encoded({ billing_address: { city: 'Walnut', state: 'California' } }),
).to.equal(
'billing_address[city]=Walnut&billing_address[state]=California',
);
});

it('should encode arbitrarily deep nesting', () => {
expect(encoded({ ramp: { effective_from: { on: 1704067200 } } })).to.equal(
'ramp[effective_from][on]=1704067200',
);
});

it('should drop empty objects', () => {
expect(encoded({ meta_data: {}, a: 'x' })).to.equal('a=x');
});
});

describe('encodeParams - arrays', () => {
it('should index-encode an array of primitives', () => {
expect(encoded({ coupon_ids: ['FIFTYOFF', 'TENOFF'] })).to.equal(
'coupon_ids[0]=FIFTYOFF&coupon_ids[1]=TENOFF',
);
});

it('should drop empty arrays', () => {
expect(encoded({ coupon_ids: [], a: 'x' })).to.equal('a=x');
});

it('should index an array of sub-resources by field, then position', () => {
expect(
encoded({
subscription_items: [
{ item_price_id: 'day-pass-USD', unit_price: 100 },
{ item_price_id: 'basic-USD', quantity: 1 },
],
}),
).to.equal(
'subscription_items[item_price_id][0]=day-pass-USD&subscription_items[unit_price][0]=100&subscription_items[item_price_id][1]=basic-USD&subscription_items[quantity][1]=1',
);
});

it('should index an array nested inside an array of sub-resources', () => {
expect(
encoded({
item_constraints: [
{ constraint: 'specific', item_price_ids: ['basic', 'pro'] },
],
}),
).to.equal(
'item_constraints[constraint][0]=specific&item_constraints[item_price_ids][0][0]=basic&item_constraints[item_price_ids][0][1]=pro',
);
});
});

describe('encodeParams - jsonKeys', () => {
it('should JSON-encode a key registered at the matching level', () => {
expect(encoded({ meta_data: { plan: 'pro' } }, { meta_data: 0 })).to.equal(
'meta_data={"plan":"pro"}',
);
});

it('should pass an already stringified value through untouched', () => {
expect(encoded({ meta_data: '{"plan":"pro"}' }, { meta_data: 0 })).to.equal(
'meta_data={"plan":"pro"}',
);
});

it('should JSON-encode a nested key registered at its own level', () => {
expect(
encoded(
{ item_constraints: [{ item_price_ids: ['basic'] }] },
{ item_price_ids: 1 },
),
).to.equal('item_constraints[item_price_ids][0]=["basic"]');
});

it('should fall back to bracket notation when the level does not match', () => {
expect(encoded({ meta_data: { plan: 'pro' } }, { meta_data: 1 })).to.equal(
'meta_data[plan]=pro',
);
});

it('should encode a null value as empty', () => {
expect(encoded({ meta_data: null }, { meta_data: 0 })).to.equal(
'meta_data=',
);
});
});

describe('encodeParams - filter array operators', () => {
it('should send between as a single field for a nested filter', () => {
expect(
encoded({
export_type: 'import_friendly_data',
ramp: { effective_from: { between: [1704067200, 1717199999] } },
}),
).to.equal(
'export_type=import_friendly_data&ramp[effective_from][between]=[1704067200,1717199999]',
);
});

it('should send between as a single field for a top-level filter', () => {
expect(
encoded({ updated_at: { between: [1704067200, 1717199999] } }),
).to.equal('updated_at[between]=[1704067200,1717199999]');
});

it('should send in and not_in as single fields', () => {
expect(
encoded({
subscription: {
id: { in: ['sub_1', 'sub_2'] },
status: { not_in: ['cancelled'] },
},
}),
).to.equal(
'subscription[id][in]=["sub_1","sub_2"]&subscription[status][not_in]=["cancelled"]',
);
});

it('should leave other operators on the same filter alone', () => {
expect(
encoded({
subscription: {
updated_at: { after: 1704067200, between: [1704067200, 1717199999] },
},
}),
).to.equal(
'subscription[updated_at][after]=1704067200&subscription[updated_at][between]=[1704067200,1717199999]',
);
});

it('should pass an already stringified operator value through untouched', () => {
expect(
encoded({ updated_at: { between: '[1704067200,1717199999]' } }),
).to.equal('updated_at[between]=[1704067200,1717199999]');
});

it('should not touch a scalar under an operator key', () => {
expect(encoded({ subscription: { id: { in: 'sub_1' } } })).to.equal(
'subscription[id][in]=sub_1',
);
});

it('should omit an operator whose array is empty', () => {
expect(encoded({ updated_at: { between: [] }, limit: 5 })).to.equal(
'limit=5',
);
});
});

describe('serialize - GET query string', () => {
it('should produce the same filter encoding as the request body', () => {
const params = {
ramp: { effective_from: { between: [1704067200, 1717199999] } },
};
const query = decodeURIComponent(encodeParams(serialize(params)));

expect(query).to.equal(
'ramp[effective_from][between]=[1704067200,1717199999]',
);
});
Comment thread
cb-alish marked this conversation as resolved.

it('should flatten sub-resources into bracket notation', () => {
expect(serialize({ billing_address: { city: 'Walnut' } })).to.deep.equal({
'billing_address[city]': 'Walnut',
});
});
});

describe('encodeListParams', () => {
it('should JSON-encode array values and leave scalars alone', () => {
expect(
decodeURIComponent(
encodeListParams({
'updated_at[between]': [1704067200, 1717199999],
limit: 5,
}),
),
).to.equal('updated_at[between]=[1704067200,1717199999]&limit=5');
});
});
Loading