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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [5.0.0] 2026-09-10

### Breaking

- `SearchResult<T>` now describes the whole search envelope, so `page`, `total_pages`, and `total_results` are optional. Cursor pages only report the totals on the first request of the sequence, and a search with no matches reports `page: 0`, so the fields can be absent and dereferencing them requires narrowing.
- `CursorSearchResult<T>` was removed: cursor responses are `SearchResult<T>` too, with `previous_cursor` and `next_cursor` as optional fields. The `list()` overloads that selected the result type are gone with it.

### Added

- `CursorSearchParams` and `PageSearchParams` to type search params, and `totals_are_capped`, `previous_cursor`, and `next_cursor` on `SearchResult<T>`.

### Fixed

- Serialize nested query params with the bracket notation the API expects. List/search calls passing an object value (for example `date: { gte, lt }` on `invoices.list`, `receipts.list`, `customers.list`, etc.) used to send `date=[object Object]` and fail; they now send `date[gte]=...&date[lt]=...`. Array values now expand to repeated keys (`status=a&status=b`) instead of being comma-joined or sent as `status[]=a`, matching the API contract and the other official SDKs.

## [4.21.0] 2026-09-04

### Added
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "facturapi",
"version": "4.21.0",
"version": "5.0.0",
"description": "SDK oficial de Facturapi para Node.js y navegadores. Integra facturación electrónica en México (CFDI) de forma simple y obtén una perspectiva fiscal completa de tu operación, con búsquedas indexadas, envío de documentos y trazabilidad.",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
Expand Down
2 changes: 1 addition & 1 deletion src/resources/customers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default class Customers {
* @param params Search parameters
* @returns List of customers
*/
list(params: Record<string, any>): Promise<SearchResult<Customer>> {
list(params?: Record<string, any> | null): Promise<SearchResult<Customer>> {
if (!params) params = {};
return this.client.get('/customers', { params: params });
}
Expand Down
5 changes: 4 additions & 1 deletion src/resources/products.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { Product, SearchResult } from '../types';
import {
Product,
SearchResult
} from '../types';
import { WrapperClient } from '../wrapper';

export default class Products {
Expand Down
26 changes: 23 additions & 3 deletions src/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,32 @@ export interface Address {
}

export interface SearchResult<T> {
page: number;
total_pages: number;
total_results: number;
/** Page number. Absent in cursor searches and when the search has no matches. */
page?: number;
/** Total pages derived from the (possibly capped) total. Absent in cursor searches. */
total_pages?: number;
/**
* Total matching results. Capped (approximate) when `totals_are_capped` is
* true, and only reported on the first request of a cursor sequence.
*/
total_results?: number;
/** True when total_results is capped at the maximum search count. */
totals_are_capped?: boolean;
/** Cursor to the previous slice (cursor searches only). */
previous_cursor?: string | null;
/** Cursor to the next slice (cursor searches only). */
next_cursor?: string | null;
data: T[];
}

/** Params that select page pagination (the default). */
export type PageSearchParams = ({ pagination?: 'page' } | { page: number }) &
Record<string, any>;

/** Params that select cursor pagination (page mode is the default). */
export type CursorSearchParams = ({ pagination: 'cursor' } | { after: string } | { before: string }) &
Record<string, any>;

export interface InvoiceItemPart {
quantity: number;
product_key: string;
Expand Down
60 changes: 57 additions & 3 deletions src/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,61 @@ const responseHeadersToObject = (headers: Headers): Record<string, string> => {
return result;
};

const isPlainRecord = (value: object): boolean => {
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
};

/**
* Flattens a params object into `[key, value]` pairs suitable for
* `URLSearchParams`. Plain objects expand to the bracket notation the API
* documents (`date[gte]=...`, the `deepObject` style) and arrays expand to
* repeated keys (`status=a&status=b`, the OpenAPI default `form` + `explode`),
* so every official SDK sends the same encoding. `null` and `undefined` values
* and empty collections are skipped, mirroring how query params were
* serialized before the Fetch API migration. Other object values (`URL`,
* `RegExp`, custom instances) keep their previous string conversion.
*/
const buildQueryString = (params: Record<string, unknown>): string => {
const pairs: Array<[string, string]> = [];
const append = (value: unknown, key: string) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
if (value.length === 0) {
return;
}
for (const item of value) {
append(item, key);
}
return;
}
if (typeof value === 'object') {
if (value instanceof Date) {
pairs.push([key, value.toISOString()]);
return;
}
if (isPlainRecord(value)) {
const entries = Object.entries(value);
if (entries.length === 0) {
return;
}
for (const [subKey, subValue] of entries) {
append(subValue, `${key}[${subKey}]`);
}
return;
}
}
pairs.push([key, String(value)]);
};
for (const [key, value] of Object.entries(params)) {
append(value, key);
}
return pairs.length ? new URLSearchParams(pairs).toString() : '';
};


const stringFrom = (value: unknown): string | undefined =>
typeof value === 'string' ? value : undefined;

Expand Down Expand Up @@ -217,9 +272,8 @@ export const createWrapper = (
},
) {
const { params, body, formData, ...restOptions } = options || {};
const queryString = params
? '?' + new URLSearchParams(params).toString()
: '';
const serializedQuery = params ? buildQueryString(params) : '';
const queryString = serializedQuery ? `?${serializedQuery}` : '';
const requestHeaders = new Headers(defaultHeaders);
if (!formData) {
requestHeaders.set('Content-Type', 'application/json');
Expand Down
26 changes: 26 additions & 0 deletions test-d/runtime-types.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { expectAssignable, expectType, expectError } from 'tsd';
import Facturapi, {
BinaryDownload,
CursorSearchParams,
PageSearchParams,
FacturapiError,
Invoice,
InvoiceItem,
InvoiceType,
IssuingType,
Expand Down Expand Up @@ -70,3 +73,26 @@ expectType<string | undefined>(apiError.path);
expectType<string | undefined>(apiError.location);
expectType<string | undefined>(apiError.logId);
expectType<Record<string, string>>(apiError.headers);

// Pagination params document the two modes; both return the same envelope.
expectAssignable<PageSearchParams>({ page: 2 });
expectAssignable<CursorSearchParams>({ pagination: 'cursor', limit: 50 });
expectType<Promise<SearchResult<Invoice>>>(
client.invoices.list({ page: 2, limit: 50 }),
);
expectType<Promise<SearchResult<Invoice>>>(
client.invoices.list({ pagination: 'cursor', limit: 50 }),
);
expectType<Promise<SearchResult<Invoice>>>(
client.invoices.list({ after: 'cursor-token' }),
);
const looseParams: Record<string, any> = { page: 2 };
expectType<Promise<SearchResult<Invoice>>>(client.invoices.list(looseParams));
expectType<Promise<SearchResult<Invoice>>>(client.invoices.list());
// Totals and cursors are optional because cursor pages omit them.
expectType<Promise<number | undefined>>(
client.invoices.list({ page: 1 }).then((result) => result.total_results),
);
expectType<Promise<string | null | undefined>>(
client.invoices.list({ after: 'token' }).then((result) => result.next_cursor),
);
138 changes: 138 additions & 0 deletions test/node/query-params.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { afterEach, describe, expect, it, vi } from 'vitest'

import Facturapi from '../../src'

const originalFetch = globalThis.fetch

function createClient() {
const client = new Facturapi('sk_test_123')
client.BASE_URL = 'https://api.test.local/v2'
return client
}

afterEach(() => {
globalThis.fetch = originalFetch
vi.restoreAllMocks()
})

describe('query param serialization', () => {
it('serializes a nested date range object with bracket notation', async () => {
const client = createClient()

globalThis.fetch = vi.fn(async (url) => {
expect(url).toBe(
'https://api.test.local/v2/invoices?limit=100&q=laboratorio+ramos&date%5Bgte%5D=2026-01-01&date%5Blt%5D=2026-02-01',
)
return new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}) as typeof fetch

await client.invoices.list({
limit: 100,
q: 'laboratorio ramos',
date: { gte: '2026-01-01', lt: '2026-02-01' },
})
})

it('serializes arrays as repeated keys', async () => {
const client = createClient()

globalThis.fetch = vi.fn(async (url) => {
expect(url).toBe(
'https://api.test.local/v2/invoices?status=valid&status=canceled',
)
return new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}) as typeof fetch

await client.invoices.list({ status: ['valid', 'canceled'] })
})

it('skips null, undefined, and empty collections', async () => {
const client = createClient()

globalThis.fetch = vi.fn(async (url) => {
expect(url).toBe('https://api.test.local/v2/invoices?page=2')
return new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}) as typeof fetch

await client.invoices.list({
page: 2,
q: null,
date: undefined,
status: [],
empty: {},
})
})

it('serializes Date values as ISO 8601 strings', async () => {
const client = createClient()

globalThis.fetch = vi.fn(async (url) => {
expect(url).toBe(
'https://api.test.local/v2/invoices?date%5Bgte%5D=2026-01-01T00%3A00%3A00.000Z',
)
return new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}) as typeof fetch

await client.invoices.list({
date: { gte: new Date('2026-01-01T00:00:00.000Z') },
})
})

it('keeps flat params encoded exactly as before', async () => {
const client = createClient()

globalThis.fetch = vi.fn(async (url) => {
expect(url).toBe(
'https://api.test.local/v2/organizations/domain-check?domain=empresa-demo',
)
return new Response(JSON.stringify({ available: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}) as typeof fetch

await client.organizations.checkDomainIsAvailable({
domain: 'empresa-demo',
})
})

it('does not append a query delimiter when every value is omitted', async () => {
const client = createClient()

globalThis.fetch = vi.fn(async (url) => {
expect(url).toBe('https://api.test.local/v2/invoices')
return new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}) as typeof fetch

await client.invoices.list({ status: [], q: null, date: undefined })
})

it('keeps string conversion for non-plain object values', async () => {
const client = createClient()

globalThis.fetch = vi.fn(async (url) => {
expect(new URLSearchParams(url.split('?')[1]).get('q')).toBe('/walmart/i')
return new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}) as typeof fetch

await client.invoices.list({ q: /walmart/i as unknown as string })
})
})
4 changes: 2 additions & 2 deletions test/node/runtime-compat.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,12 +509,12 @@ describe('runtime compatibility (node)', () => {
).rejects.toThrow(/Unsupported file input type/)
})

it('serializes query params consistently with URLSearchParams semantics', async () => {
it('serializes flat params with URLSearchParams encoding and arrays with repeated keys', async () => {
const client = createClient()

globalThis.fetch = vi.fn(async (url) => {
expect(url).toBe(
'https://api.test.local/v2/invoices?search=a+b&page=2&active=true&empty=&tags=x%2Cy',
'https://api.test.local/v2/invoices?search=a+b&page=2&active=true&empty=&tags=x&tags=y',
)
return new Response(
JSON.stringify({
Expand Down