Skip to content
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
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
243 changes: 243 additions & 0 deletions test/util.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
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 contribute nothing for a sub-resource with no fields', () => {
expect(encoded({ card: {}, 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"}',
);
});

// meta_data is a json key on every endpoint that accepts it, so sending an
// empty object is how a caller wipes the stored document.
it('should send an empty object so the field can be cleared', () => {
expect(encoded({ meta_data: {}, id: 'cust_1' }, { meta_data: 0 })).to.equal(
'meta_data={}&id=cust_1',
);
});

it('should send an empty object for a nested json key too', () => {
expect(
encoded(
{ subscription_items: [{ billing_address: {} }] },
{ billing_address: 1 },
),
).to.equal('subscription_items[billing_address][0]={}');
});

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