diff --git a/CHANGELOG.md b/CHANGELOG.md index e34a823..061d410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +- Explore product/service categories and resolve category paths from full product keys. Product searches can filter by category prefix. + - Search Mexican postal codes, countries, tax regimes, and CFDI uses through `catalogs`, and retrieve postal-code details including neighborhoods. Postal-code searches accept numeric prefixes or neighborhood, locality/city, municipality, and state names, ignore case and diacritics, and support page and cursor pagination. Every postal-code result includes all its neighborhoods. ## [6.0.0] Unreleased diff --git a/README.md b/README.md index 6253e0c..b65b992 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,21 @@ const uses = await facturapi.catalogs.searchInvoiceUses({ q: 'gastos' }); La búsqueda postal acepta el código completo o un prefijo numérico, o el nombre de una colonia, localidad/ciudad, municipio o estado, y un filtro opcional `state` con la clave de tres letras. Las búsquedas por nombre ignoran mayúsculas y diacríticos, priorizan coincidencias exactas y devuelven cada código postal una sola vez. Cada resultado incluye todas sus colonias en `neighborhoods`, con clave y nombre; el detalle devuelve el mismo objeto. Los códigos postales admiten páginas o cursores (`pagination`, `after`, `before`); conserva los filtros al avanzar. Países, regímenes y usos usan `page` y `limit` (1–100); las claves de catálogo permanecen como strings para conservar ceros iniciales. El régimen y el uso CFDI deben ser declarados por el receptor; consultar el catálogo no los determina. +### Categorías de productos y servicios + +```ts +const roots = await facturapi.catalogs.listProductCategories(); +const division = await facturapi.catalogs.getProductCategory('43'); +const productPath = await facturapi.catalogs.getProductCategory('43211507'); +const products = await facturapi.catalogs.searchProducts({ + category: '432115', + pagination: 'cursor', + limit: 10, +}); +``` + +Las raíces son `products` y `services`. Consulta prefijos de 2, 4 o 6 dígitos para obtener una categoría, sus ancestros y sus hijas; una clase también incluye productos paginados. Una clave completa de 8 dígitos devuelve el producto y su ruta. No se eliminan ceros finales: `43211500` sigue siendo una clave de producto válida. `01010101` tiene `category: null`. El filtro `category` incluye descendientes y se puede combinar con `q`; las respuestas de productos conservan clave y descripción. + ## Qué puedes integrar | Necesitas… | Recurso o guía | diff --git a/openapi/source.json b/openapi/source.json index d4b04bf..f497311 100644 --- a/openapi/source.json +++ b/openapi/source.json @@ -1,5 +1,5 @@ { "repository": "FacturAPI/facturapi-docs", - "revision": "9e0fdd689647a1d60f8d9181038225dd04c12732", + "revision": "fb9921573b3d7c46a1d7f2c6e350c6861afe4cbf", "path": "website/openapi_v2.yaml" } diff --git a/scripts/sdk/resources.json b/scripts/sdk/resources.json index 2618a68..3697750 100644 --- a/scripts/sdk/resources.json +++ b/scripts/sdk/resources.json @@ -539,6 +539,8 @@ "params": false } }, + "listProductCategories": "listProductCategories", + "getProductCategory": "getProductCategory", "searchZipCodes": "searchZipCodes", "getZipCode": { "operation": "getZipCode", diff --git a/src/generated/dates.ts b/src/generated/dates.ts index 6a9063b..47a6499 100644 --- a/src/generated/dates.ts +++ b/src/generated/dates.ts @@ -418,6 +418,9 @@ export const componentDatePlans = { CustomerEditLinkCompletedEvent: 33, ApiEvent: 36, SignedDownloadUrl: 37, + ProductCategory: 0, + ProductCategoryList: 0, + ProductCategoryDetail: 0, CatalogEntry: 0, CatalogEntrySearchResult: 0, ZipCode: 0, @@ -807,6 +810,8 @@ export const operationDatePlans = { searchInvoiceUses: 0, searchZipCodes: 0, getZipCode: 0, + listProductCategories: 0, + getProductCategory: 0, searchProducts: 0, searchUnits: 0, onInvoiceGlobalInvoiceCreated: 0, diff --git a/src/generated/input.ts b/src/generated/input.ts index 2a995a3..9d64911 100644 --- a/src/generated/input.ts +++ b/src/generated/input.ts @@ -2160,6 +2160,46 @@ export interface paths { patch?: never; trace?: never; }; + '/catalogs/product-categories': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Listar categorías raíz de productos y servicios + * Devuelve las raíces Productos y Servicios. Consulta una raíz por su clave para explorar sus divisiones. + */ + get: operations['listProductCategories']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/catalogs/product-categories/{key}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Explorar una categoría o consultar la ruta de una clave + * Explora el árbol de categorías de productos y servicios del SAT. Las categorías usan claves de 2, 4 o 6 dígitos. Una clave completa de 8 dígitos devuelve el producto y su ruta; nunca se eliminan ceros finales. La clave especial 01010101 no tiene categoría. Consultar una clase de 6 dígitos incluye un resultado paginado de productos. Consultar una clave de 8 dígitos devuelve su ruta sin listar otros productos de la clase. + */ + get: operations['getProductCategory']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/catalogs/products': { parameters: { query?: never; @@ -2512,6 +2552,21 @@ export interface components { /** Nombre sugerido del archivo. */ filename: string; }; + ProductCategory: { + key: string; + description: string; + parent: string | null; + }; + ProductCategoryList: { + data: components['schemas']['ProductCategory'][]; + }; + ProductCategoryDetail: { + category: components['schemas']['ProductCategory'] | null; + ancestors: components['schemas']['ProductCategory'][]; + children: components['schemas']['ProductCategory'][]; + product?: components['schemas']['CatalogEntry']; + products?: components['schemas']['CatalogEntrySearchResult']; + }; CatalogEntry: { key: string; description: string; @@ -11519,16 +11574,80 @@ export interface operations { 500: components['responses']['UnexpectedError']; }; }; + listProductCategories: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ProductCategoryList']; + }; + }; + 401: components['responses']['Unauthenticated']; + 429: components['responses']['RateLimited']; + 500: components['responses']['UnexpectedError']; + }; + }; + getProductCategory: { + parameters: { + query?: { + /** Modo de paginación de la búsqueda. `page` (por defecto) o `cursor` (recomendado para listas grandes). */ + pagination?: components['parameters']['SearchPagination']; + /** Devuelve los resultados posteriores al cursor indicado. Solo con `pagination=cursor`; mutuamente excluyente con `before`. */ + after?: components['parameters']['SearchAfter']; + /** Devuelve los resultados anteriores al cursor indicado. Solo con `pagination=cursor`; mutuamente excluyente con `after`. */ + before?: components['parameters']['SearchBefore']; + /** Página de resultados a regresar, empezando desde la página 1. El máximo no es fijo; junto con `limit` debe caber dentro del tope de 3,000 resultados (con el `limit` por defecto de 100, la página máxima es 30). */ + page?: components['parameters']['SearchPage']; + /** Número del 1 al 100 que representa la cantidad máxima de resultados a regresar con motivos de paginación. */ + limit?: components['parameters']['SearchLimit']; + }; + header?: never; + path: { + /** Raíz, prefijo de categoría o clave completa de producto/servicio. */ + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ProductCategoryDetail']; + }; + }; + 400: components['responses']['BadRequest']; + 401: components['responses']['Unauthenticated']; + 404: components['responses']['NotFound']; + 429: components['responses']['RateLimited']; + 500: components['responses']['UnexpectedError']; + }; + }; searchProducts: { parameters: { query?: { + /** Prefijo de categoría de 2, 4 o 6 dígitos. Incluye descendientes y se combina con q. Sin q, lista los productos de la categoría. */ + category?: string; /** Modo de paginación de la búsqueda. `page` (por defecto) o `cursor` (recomendado para listas grandes). */ pagination?: components['parameters']['SearchPagination']; /** Devuelve los resultados posteriores al cursor indicado. Solo con `pagination=cursor`; mutuamente excluyente con `before`. */ after?: components['parameters']['SearchAfter']; /** Devuelve los resultados anteriores al cursor indicado. Solo con `pagination=cursor`; mutuamente excluyente con `after`. */ before?: components['parameters']['SearchBefore']; - /** Consulta. Texto a buscar en la descripción de la clasificación. */ + /** Busca prefijos de claves, descripciones, palabras similares y nombres de categorías, sin distinguir mayúsculas ni diacríticos. */ q?: string; /** Página de resultados a regresar, empezando desde la página 1. El máximo no es fijo; junto con `limit` debe caber dentro del tope de 3,000 resultados (con el `limit` por defecto de 100, la página máxima es 30). */ page?: components['parameters']['SearchPage']; diff --git a/src/generated/models.ts b/src/generated/models.ts index 223d4f9..9b56eb8 100644 --- a/src/generated/models.ts +++ b/src/generated/models.ts @@ -17,6 +17,9 @@ export type ReceiptStatusUpdatedEvent = export type CustomerEditLinkCompletedEvent = Output['schemas']['CustomerEditLinkCompletedEvent']; export type SignedDownloadUrl = Output['schemas']['SignedDownloadUrl']; +export type ProductCategory = Output['schemas']['ProductCategory']; +export type ProductCategoryList = Output['schemas']['ProductCategoryList']; +export type ProductCategoryDetail = Output['schemas']['ProductCategoryDetail']; export type CatalogEntry = Output['schemas']['CatalogEntry']; export type CatalogEntrySearchResult = Output['schemas']['CatalogEntrySearchResult']; diff --git a/src/generated/output.ts b/src/generated/output.ts index 748a014..6fec991 100644 --- a/src/generated/output.ts +++ b/src/generated/output.ts @@ -2172,6 +2172,46 @@ export interface paths { patch?: never; trace?: never; }; + '/catalogs/product-categories': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Listar categorías raíz de productos y servicios + * Devuelve las raíces Productos y Servicios. Consulta una raíz por su clave para explorar sus divisiones. + */ + get: operations['listProductCategories']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/catalogs/product-categories/{key}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Explorar una categoría o consultar la ruta de una clave + * Explora el árbol de categorías de productos y servicios del SAT. Las categorías usan claves de 2, 4 o 6 dígitos. Una clave completa de 8 dígitos devuelve el producto y su ruta; nunca se eliminan ceros finales. La clave especial 01010101 no tiene categoría. Consultar una clase de 6 dígitos incluye un resultado paginado de productos. Consultar una clave de 8 dígitos devuelve su ruta sin listar otros productos de la clase. + */ + get: operations['getProductCategory']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/catalogs/products': { parameters: { query?: never; @@ -2524,6 +2564,21 @@ export interface components { /** Nombre sugerido del archivo. */ filename: string; }; + ProductCategory: { + key: string; + description: string; + parent: string | null; + }; + ProductCategoryList: { + data: components['schemas']['ProductCategory'][]; + }; + ProductCategoryDetail: { + category: components['schemas']['ProductCategory'] | null; + ancestors: components['schemas']['ProductCategory'][]; + children: components['schemas']['ProductCategory'][]; + product?: components['schemas']['CatalogEntry']; + products?: components['schemas']['CatalogEntrySearchResult']; + }; CatalogEntry: { key: string; description: string; @@ -11535,16 +11590,80 @@ export interface operations { 500: components['responses']['UnexpectedError']; }; }; + listProductCategories: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ProductCategoryList']; + }; + }; + 401: components['responses']['Unauthenticated']; + 429: components['responses']['RateLimited']; + 500: components['responses']['UnexpectedError']; + }; + }; + getProductCategory: { + parameters: { + query?: { + /** Modo de paginación de la búsqueda. `page` (por defecto) o `cursor` (recomendado para listas grandes). */ + pagination?: components['parameters']['SearchPagination']; + /** Devuelve los resultados posteriores al cursor indicado. Solo con `pagination=cursor`; mutuamente excluyente con `before`. */ + after?: components['parameters']['SearchAfter']; + /** Devuelve los resultados anteriores al cursor indicado. Solo con `pagination=cursor`; mutuamente excluyente con `after`. */ + before?: components['parameters']['SearchBefore']; + /** Página de resultados a regresar, empezando desde la página 1. El máximo no es fijo; junto con `limit` debe caber dentro del tope de 3,000 resultados (con el `limit` por defecto de 100, la página máxima es 30). */ + page?: components['parameters']['SearchPage']; + /** Número del 1 al 100 que representa la cantidad máxima de resultados a regresar con motivos de paginación. */ + limit?: components['parameters']['SearchLimit']; + }; + header?: never; + path: { + /** Raíz, prefijo de categoría o clave completa de producto/servicio. */ + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ProductCategoryDetail']; + }; + }; + 400: components['responses']['BadRequest']; + 401: components['responses']['Unauthenticated']; + 404: components['responses']['NotFound']; + 429: components['responses']['RateLimited']; + 500: components['responses']['UnexpectedError']; + }; + }; searchProducts: { parameters: { query?: { + /** Prefijo de categoría de 2, 4 o 6 dígitos. Incluye descendientes y se combina con q. Sin q, lista los productos de la categoría. */ + category?: string; /** Modo de paginación de la búsqueda. `page` (por defecto) o `cursor` (recomendado para listas grandes). */ pagination?: components['parameters']['SearchPagination']; /** Devuelve los resultados posteriores al cursor indicado. Solo con `pagination=cursor`; mutuamente excluyente con `before`. */ after?: components['parameters']['SearchAfter']; /** Devuelve los resultados anteriores al cursor indicado. Solo con `pagination=cursor`; mutuamente excluyente con `after`. */ before?: components['parameters']['SearchBefore']; - /** Consulta. Texto a buscar en la descripción de la clasificación. */ + /** Busca prefijos de claves, descripciones, palabras similares y nombres de categorías, sin distinguir mayúsculas ni diacríticos. */ q?: string; /** Página de resultados a regresar, empezando desde la página 1. El máximo no es fijo; junto con `limit` debe caber dentro del tope de 3,000 resultados (con el `limit` por defecto de 100, la página máxima es 30). */ page?: components['parameters']['SearchPage']; diff --git a/src/tools/catalogs.ts b/src/tools/catalogs.ts index f5d5e7d..690989f 100644 --- a/src/tools/catalogs.ts +++ b/src/tools/catalogs.ts @@ -50,6 +50,45 @@ export default class Catalogs { ); } + /** + * Listar categorías raíz de productos y servicios + * + * Devuelve las raíces Productos y Servicios. Consulta una raíz por su clave para explorar sus divisiones. + * + * @returns OK + */ + listProductCategories(): Promise> { + return this.client.request>( + `/catalogs/product-categories`, + { method: 'GET', datePlan: operationDatePlans.listProductCategories }, + ); + } + + /** + * Explorar una categoría o consultar la ruta de una clave + * + * Explora el árbol de categorías de productos y servicios del SAT. Las categorías usan claves de 2, 4 o 6 dígitos. Una clave completa de 8 dígitos devuelve el producto y su ruta; nunca se eliminan ceros finales. La clave especial 01010101 no tiene categoría. Consultar una clase de 6 dígitos incluye un resultado paginado de productos. Consultar una clave de 8 dígitos devuelve su ruta sin listar otros productos de la clase. + * + * @param id - Raíz, prefijo de categoría o clave completa de producto/servicio. + * @param params - Parámetros de consulta. + * @returns OK + */ + getProductCategory( + id: string, + params?: OperationQuery<'getProductCategory'> | null, + ): Promise> { + if (!id) return Promise.reject(new Error('id is required')); + + return this.client.request>( + `/catalogs/product-categories/${encodeURIComponent(id)}`, + { + method: 'GET', + datePlan: operationDatePlans.getProductCategory, + params: params, + }, + ); + } + /** * Buscar códigos postales * diff --git a/test-d/runtime-types.test-d.ts b/test-d/runtime-types.test-d.ts index aac47bc..afbcb65 100644 --- a/test-d/runtime-types.test-d.ts +++ b/test-d/runtime-types.test-d.ts @@ -8,6 +8,8 @@ import Facturapi, { CatalogEntrySearchResult, ZipCodeSearchResult, ZipCodeDetail, + ProductCategoryList, + ProductCategoryDetail, BinaryDownload, ApiKeys, ApiEvent, @@ -807,3 +809,16 @@ client.catalogs.searchZipCodes({ }); client.catalogs.searchZipCodes({ q: '01', before: 'cursor', limit: 10 }); expectError(client.catalogs.searchZipCodes({ pagination: 'other' })); + +expectType>( + client.catalogs.listProductCategories(), +); +expectType>( + client.catalogs.getProductCategory('43211507', { + pagination: 'cursor', + limit: 5, + }), +); +client.catalogs.searchProducts({ category: '4321' }); +expectError(client.catalogs.getProductCategory(432115)); +expectError(client.catalogs.searchProducts({ category: 43 })); diff --git a/test/catalogs.shared.ts b/test/catalogs.shared.ts index 037b9f3..4d98b44 100644 --- a/test/catalogs.shared.ts +++ b/test/catalogs.shared.ts @@ -166,4 +166,41 @@ describe('address and fiscal catalogs', () => { expect.objectContaining({ method: 'GET' }), ); }); + it('explores categories and filters products without losing key strings', async () => { + const client = new Facturapi('sk_test_example'); + client.BASE_URL = 'https://api.example.com/v2'; + const category = { + key: '432115', + description: 'Computadores', + parent: '4321', + }; + const detail = { category, ancestors: [], children: [] }; + const fetch = vi.fn(async () => Response.json(detail)); + vi.stubGlobal('fetch', fetch); + expect( + await client.catalogs.getProductCategory('43211507', { + pagination: 'cursor', + after: 'next', + limit: 5, + }), + ).toEqual(detail); + expect(fetch).toHaveBeenLastCalledWith( + 'https://api.example.com/v2/catalogs/product-categories/43211507?pagination=cursor&after=next&limit=5', + expect.objectContaining({ method: 'GET' }), + ); + await client.catalogs.listProductCategories(); + expect(fetch).toHaveBeenLastCalledWith( + 'https://api.example.com/v2/catalogs/product-categories', + expect.objectContaining({ method: 'GET' }), + ); + await client.catalogs.searchProducts({ + category: '43', + q: 'cómputo', + page: 2, + }); + expect(fetch).toHaveBeenLastCalledWith( + 'https://api.example.com/v2/catalogs/products?category=43&q=c%C3%B3mputo&page=2', + expect.objectContaining({ method: 'GET' }), + ); + }); });