diff --git a/CHANGELOG.md b/CHANGELOG.md index d1043fc..377fab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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` was removed: cursor responses are `SearchResult` 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`. + +### 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 diff --git a/package.json b/package.json index 958a804..2e1a23d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/resources/customers.ts b/src/resources/customers.ts index dff307d..c714584 100644 --- a/src/resources/customers.ts +++ b/src/resources/customers.ts @@ -30,7 +30,7 @@ export default class Customers { * @param params Search parameters * @returns List of customers */ - list(params: Record): Promise> { + list(params?: Record | null): Promise> { if (!params) params = {}; return this.client.get('/customers', { params: params }); } diff --git a/src/resources/products.ts b/src/resources/products.ts index 839995b..e20afa5 100644 --- a/src/resources/products.ts +++ b/src/resources/products.ts @@ -1,4 +1,7 @@ -import { Product, SearchResult } from '../types'; +import { + Product, + SearchResult +} from '../types'; import { WrapperClient } from '../wrapper'; export default class Products { diff --git a/src/types/common.ts b/src/types/common.ts index 867bab9..3748a68 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -13,12 +13,32 @@ export interface Address { } export interface SearchResult { - 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; + +/** Params that select cursor pagination (page mode is the default). */ +export type CursorSearchParams = ({ pagination: 'cursor' } | { after: string } | { before: string }) & + Record; + export interface InvoiceItemPart { quantity: number; product_key: string; diff --git a/src/wrapper.ts b/src/wrapper.ts index 7a6ec75..6dc4070 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -83,6 +83,61 @@ const responseHeadersToObject = (headers: Headers): Record => { 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 => { + 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; @@ -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'); diff --git a/test-d/runtime-types.test-d.ts b/test-d/runtime-types.test-d.ts index bb10f90..0ff48aa 100644 --- a/test-d/runtime-types.test-d.ts +++ b/test-d/runtime-types.test-d.ts @@ -1,7 +1,10 @@ import { expectAssignable, expectType, expectError } from 'tsd'; import Facturapi, { BinaryDownload, + CursorSearchParams, + PageSearchParams, FacturapiError, + Invoice, InvoiceItem, InvoiceType, IssuingType, @@ -70,3 +73,26 @@ expectType(apiError.path); expectType(apiError.location); expectType(apiError.logId); expectType>(apiError.headers); + +// Pagination params document the two modes; both return the same envelope. +expectAssignable({ page: 2 }); +expectAssignable({ pagination: 'cursor', limit: 50 }); +expectType>>( + client.invoices.list({ page: 2, limit: 50 }), +); +expectType>>( + client.invoices.list({ pagination: 'cursor', limit: 50 }), +); +expectType>>( + client.invoices.list({ after: 'cursor-token' }), +); +const looseParams: Record = { page: 2 }; +expectType>>(client.invoices.list(looseParams)); +expectType>>(client.invoices.list()); +// Totals and cursors are optional because cursor pages omit them. +expectType>( + client.invoices.list({ page: 1 }).then((result) => result.total_results), +); +expectType>( + client.invoices.list({ after: 'token' }).then((result) => result.next_cursor), +); diff --git a/test/node/query-params.node.test.ts b/test/node/query-params.node.test.ts new file mode 100644 index 0000000..a9bcd66 --- /dev/null +++ b/test/node/query-params.node.test.ts @@ -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('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/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 }) + }) +}) diff --git a/test/node/runtime-compat.node.test.ts b/test/node/runtime-compat.node.test.ts index 0e5f97f..19dee8e 100644 --- a/test/node/runtime-compat.node.test.ts +++ b/test/node/runtime-compat.node.test.ts @@ -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({