From 7d6f5128a01ff11d21a74c5a46d798ba58c46a6e Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 14 Oct 2026 16:17:43 +0200 Subject: [PATCH 01/12] docs: correct organization series listing response to an array --- website/openapi_v2.en.yaml | 9 +++------ website/openapi_v2.yaml | 9 +++------ website/test/openapi-types.fixture.txt | 4 ++++ 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/website/openapi_v2.en.yaml b/website/openapi_v2.en.yaml index bb95cb952..a6ff09b04 100644 --- a/website/openapi_v2.en.yaml +++ b/website/openapi_v2.en.yaml @@ -10029,12 +10029,9 @@ paths: content: application/json: schema: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/OrganizationSeriesGroup" + type: array + items: + $ref: "#/components/schemas/OrganizationSeriesGroup" "400": $ref: "#/components/responses/BadRequest" diff --git a/website/openapi_v2.yaml b/website/openapi_v2.yaml index d63000f68..e5e6ce262 100644 --- a/website/openapi_v2.yaml +++ b/website/openapi_v2.yaml @@ -9987,12 +9987,9 @@ paths: content: application/json: schema: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/OrganizationSeriesGroup" + type: array + items: + $ref: "#/components/schemas/OrganizationSeriesGroup" "400": $ref: "#/components/responses/BadRequest" diff --git a/website/test/openapi-types.fixture.txt b/website/test/openapi-types.fixture.txt index cbb221ea5..e91c48260 100644 --- a/website/test/openapi-types.fixture.txt +++ b/website/test/openapi-types.fixture.txt @@ -248,3 +248,7 @@ void retentionTaxResponse; const nullUpdatedTaxSystem: components['schemas']['CustomerUpdateInput'] = { tax_system: null }; const clearedCustomerWithEditLink: components['schemas']['CustomerUpdateWithEditLinkInput'] = { legal_name: null, tax_id: null, tax_system: null, address: null }; + +const organizationSeriesList: operations['getSeriesGroup']['responses'][200]['content']['application/json'] = [{ series: 'A', next_folio: 1 }]; +// @ts-expect-error Series listing returns an array, without a data envelope. +const organizationSeriesEnvelope: operations['getSeriesGroup']['responses'][200]['content']['application/json'] = { data: [] }; From c70a45a6abc276097fc25911284004d39618c057 Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 14 Oct 2026 17:15:54 +0200 Subject: [PATCH 02/12] docs: preserve existing public response fields in schemas --- website/openapi_v2.en.yaml | 53 ++++++++++++++++++++++++++ website/openapi_v2.yaml | 53 ++++++++++++++++++++++++++ website/test/openapi-types.fixture.txt | 2 + 3 files changed, 108 insertions(+) diff --git a/website/openapi_v2.en.yaml b/website/openapi_v2.en.yaml index a6ff09b04..d9a4bf97b 100644 --- a/website/openapi_v2.en.yaml +++ b/website/openapi_v2.en.yaml @@ -13614,6 +13614,10 @@ components: type: number example: 0.16 description: Tax rate in decimal format. + amount: + type: number + readOnly: true + description: Calculated tax amount. base: type: number description: "Tax base. If omitted, it is calculated from the line item subtotal and tax factor. For the Cuota factor, the number of units is used." @@ -13684,6 +13688,12 @@ components: type: string description: "SAT FechaTimbrado: local date and time without a timezone offset. Preserved as text." example: "2026-09-17T06:59:16" + complement_string: + type: [string, 'null'] + description: Original string of the fiscal stamp. + rfc_provider_cert: + type: string + description: Tax ID of the provider that certified the document. sat_cert_number: type: string description: SAT certificate serial number used for stamping. @@ -13717,6 +13727,14 @@ components: items: type: string description: Customs entry numbers associated with this invoice item. Omitted when there are none. + third_party: + $ref: '#/components/schemas/ThirdPartyInput' + complement: + oneOf: + - type: string + format: xml + - $ref: '#/components/schemas/HidroYPetroComplementInput' + - $ref: '#/components/schemas/IeduComplementInput' parts: type: array items: @@ -17311,6 +17329,10 @@ components: type: string format: date-time description: Date and time when cancellation was requested, if applicable. + last_checked: + type: string + format: date-time + description: Date and time when the last cancellation status check started. It does not guarantee that the check completed successfully. canceled_at: type: string format: date-time @@ -18695,6 +18717,10 @@ components: - items - key properties: + api_version: + type: integer + readOnly: true + description: API version used to create the receipt. date: type: string format: date-time @@ -19276,6 +19302,9 @@ components: type: number minimum: 0 description: Amount of the tax withheld. + pago_provisional: + type: boolean + readOnly: true tipo_pago_ret: type: string enum: @@ -19540,6 +19569,10 @@ components: - type: object description: Fiscal address of the issuing organization. properties: + country: + type: string + minLength: 3 + maxLength: 3 state: type: string description: Name of the State or Federative Entity. @@ -19614,6 +19647,13 @@ components: type: string description: | Fiscal or Legal Name of the organization, *without* the corporate regime (e.g.: S.A. de C.V.). + tax_id: + type: string + support_email: + type: string + format: email + curp: + type: string tax_system: type: string example: '601' @@ -19636,6 +19676,19 @@ components: description: | Organization customization settings, which will be used to reflect the branding and PDF preferences of the organization. This data can be updated at any time. properties: + default_series: + type: object + properties: + I: + type: string + E: + type: string + P: + type: string + N: + type: string + T: + type: string has_logo: type: boolean description: Indicates if the organization already has a logo uploaded. diff --git a/website/openapi_v2.yaml b/website/openapi_v2.yaml index e5e6ce262..7b2be7f13 100644 --- a/website/openapi_v2.yaml +++ b/website/openapi_v2.yaml @@ -13566,6 +13566,10 @@ components: type: number example: 0.16 description: Tasa del impuesto en fracción decimal. + amount: + type: number + readOnly: true + description: Importe calculado del impuesto. base: type: number description: "Base del impuesto. Si se omite, se calcula a partir del subtotal del concepto y el factor del impuesto. Para el factor Cuota, se utiliza la cantidad de unidades." @@ -13636,6 +13640,12 @@ components: type: string description: "FechaTimbrado del SAT: fecha y hora local sin offset de zona horaria. Se conserva como texto." example: "2026-09-17T06:59:16" + complement_string: + type: [string, 'null'] + description: Cadena original del timbre fiscal digital. + rfc_provider_cert: + type: string + description: RFC del proveedor que certificó el comprobante. sat_cert_number: type: string description: Número de serie del certificado del SAT usado para timbrar. @@ -13668,6 +13678,14 @@ components: items: type: string description: Números de pedimento asociados a este concepto. Se omite cuando no existen. + third_party: + $ref: '#/components/schemas/ThirdPartyInput' + complement: + oneOf: + - type: string + format: xml + - $ref: '#/components/schemas/HidroYPetroComplementInput' + - $ref: '#/components/schemas/IeduComplementInput' parts: type: array items: @@ -17265,6 +17283,10 @@ components: type: string format: date-time description: Fecha y hora en que se solicitó la cancelación, cuando aplica. + last_checked: + type: string + format: date-time + description: Fecha y hora de inicio de la última consulta del estado de cancelación. No garantiza que la consulta haya terminado correctamente. canceled_at: type: string format: date-time @@ -18631,6 +18653,10 @@ components: - items - key properties: + api_version: + type: integer + readOnly: true + description: Versión de la API usada para crear el recibo. date: type: string format: date-time @@ -19188,6 +19214,9 @@ components: type: number minimum: 0 description: Importe del impuesto retenido + pago_provisional: + type: boolean + readOnly: true tipo_pago_ret: type: string enum: @@ -19441,6 +19470,10 @@ components: - type: object description: Domicilio fiscal de la organización emisora. properties: + country: + type: string + minLength: 3 + maxLength: 3 state: type: string description: Nombre del Estado o Entidad Federativa. @@ -19514,6 +19547,13 @@ components: type: string description: | Nombre Fiscal o Razón Social de la organización, *sin* el régimen societario (ej.: S.A. de C.V.). + tax_id: + type: string + support_email: + type: string + format: email + curp: + type: string tax_system: type: string example: '601' @@ -19537,6 +19577,19 @@ components: Configuración de personalización de la organización, que se utilizarán para reflejar el branding y las preferencias de PDFs de la organización. Estos datos se pueden actualizar en cualquier momento. properties: + default_series: + type: object + properties: + I: + type: string + E: + type: string + P: + type: string + N: + type: string + T: + type: string has_logo: type: boolean description: Indica si la organización ya tiene un logotipo cargado. diff --git a/website/test/openapi-types.fixture.txt b/website/test/openapi-types.fixture.txt index e91c48260..554d68d71 100644 --- a/website/test/openapi-types.fixture.txt +++ b/website/test/openapi-types.fixture.txt @@ -252,3 +252,5 @@ const clearedCustomerWithEditLink: components['schemas']['CustomerUpdateWithEdit const organizationSeriesList: operations['getSeriesGroup']['responses'][200]['content']['application/json'] = [{ series: 'A', next_folio: 1 }]; // @ts-expect-error Series listing returns an array, without a data envelope. const organizationSeriesEnvelope: operations['getSeriesGroup']['responses'][200]['content']['application/json'] = { data: [] }; + +const cancellationLastChecked: components['schemas']['InvoiceCancellation'] = { status: "none", last_checked: "2026-10-14T12:00:00Z" }; From f74cdbe914be15ec7a8137856e6924715361a44d Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 14 Oct 2026 17:22:16 +0200 Subject: [PATCH 03/12] docs: name the shared cancellation receipt download operation --- website/openapi_v2.en.yaml | 2 +- website/openapi_v2.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/openapi_v2.en.yaml b/website/openapi_v2.en.yaml index d9a4bf97b..9af8cc97a 100644 --- a/website/openapi_v2.en.yaml +++ b/website/openapi_v2.en.yaml @@ -4916,7 +4916,7 @@ paths: $ref: "#/components/responses/UnexpectedError" /invoices/{invoice_id}/cancellation_receipt/{format}: get: - operationId: downloadCancellationReceiptXml + operationId: downloadCancellationReceipt tags: - invoice summary: Cancellation receipt diff --git a/website/openapi_v2.yaml b/website/openapi_v2.yaml index 7b2be7f13..d5422731a 100644 --- a/website/openapi_v2.yaml +++ b/website/openapi_v2.yaml @@ -4888,7 +4888,7 @@ paths: $ref: "#/components/responses/UnexpectedError" /invoices/{invoice_id}/cancellation_receipt/{format}: get: - operationId: downloadCancellationReceiptXml + operationId: downloadCancellationReceipt tags: - invoice summary: Descargar acuse de cancelación From 272e2f1b44354c10ea34b744dd7b16addd88b944 Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 14 Oct 2026 21:53:43 +0200 Subject: [PATCH 04/12] docs: share invoice item schemas and fix cancellation links --- .../docs/guides/invoices/cancelaciones.mdx | 2 +- .../current/guides/invoices/cancelaciones.mdx | 2 +- website/openapi_v2.en.yaml | 39 ++++++++++++------- website/openapi_v2.yaml | 39 ++++++++++++------- website/test/openapi-types.fixture.txt | 8 ++++ 5 files changed, 58 insertions(+), 32 deletions(-) diff --git a/website/docs/guides/invoices/cancelaciones.mdx b/website/docs/guides/invoices/cancelaciones.mdx index 5da3717bb..6d55017d7 100644 --- a/website/docs/guides/invoices/cancelaciones.mdx +++ b/website/docs/guides/invoices/cancelaciones.mdx @@ -158,7 +158,7 @@ Facturapi consulta periódicamente el SAT y actualiza `cancellation_status`. Pue ## Acuse de cancelación y verificación -Cuando solicitas la cancelación mediante Facturapi, puedes descargar el acuse emitido por el SAT en XML o PDF desde el endpoint de [Cancellation receipt](/api/#tag/invoice/operation/downloadCancellationReceiptXml). El acuse demuestra el resultado inmediato de la solicitud, pero no necesariamente que el CFDI ya esté cancelado: confirma el desenlace con `status`, `cancellation_status` o `verification_url`. +Cuando solicitas la cancelación mediante Facturapi, puedes descargar el acuse emitido por el SAT en XML o PDF desde el endpoint de [Cancellation receipt](/api/#tag/invoice/operation/downloadCancellationReceipt). El acuse demuestra el resultado inmediato de la solicitud, pero no necesariamente que el CFDI ya esté cancelado: confirma el desenlace con `status`, `cancellation_status` o `verification_url`. Facturapi sólo puede recuperar acuses de solicitudes enviadas mediante su servicio. Para cancelaciones realizadas externamente, valida el estado actual del CFDI con `verification_url`. diff --git a/website/i18n/en/docusaurus-plugin-content-docs/current/guides/invoices/cancelaciones.mdx b/website/i18n/en/docusaurus-plugin-content-docs/current/guides/invoices/cancelaciones.mdx index 53cf66e3f..790112f84 100644 --- a/website/i18n/en/docusaurus-plugin-content-docs/current/guides/invoices/cancelaciones.mdx +++ b/website/i18n/en/docusaurus-plugin-content-docs/current/guides/invoices/cancelaciones.mdx @@ -144,7 +144,7 @@ Facturapi periodically checks the SAT and updates `cancellation_status`. You can ## Cancellation receipt and verification -When you submit the cancellation through Facturapi, you can download the SAT receipt in XML or PDF from the [Cancellation receipt](/api/#tag/invoice/operation/downloadCancellationReceiptXml) endpoint. The receipt proves the immediate result of the request, but does not necessarily mean that the CFDI is already canceled: confirm the outcome using `status`, `cancellation_status`, or `verification_url`. +When you submit the cancellation through Facturapi, you can download the SAT receipt in XML or PDF from the [Cancellation receipt](/api/#tag/invoice/operation/downloadCancellationReceipt) endpoint. The receipt proves the immediate result of the request, but does not necessarily mean that the CFDI is already canceled: confirm the outcome using `status`, `cancellation_status`, or `verification_url`. Facturapi can only retrieve receipts for requests submitted through its service. For cancellations performed externally, validate the current CFDI status using `verification_url`. diff --git a/website/openapi_v2.en.yaml b/website/openapi_v2.en.yaml index 9af8cc97a..5f297dc59 100644 --- a/website/openapi_v2.en.yaml +++ b/website/openapi_v2.en.yaml @@ -13728,19 +13728,19 @@ components: type: string description: Customs entry numbers associated with this invoice item. Omitted when there are none. third_party: - $ref: '#/components/schemas/ThirdPartyInput' + $ref: '#/components/schemas/ThirdParty' complement: oneOf: - type: string format: xml - - $ref: '#/components/schemas/HidroYPetroComplementInput' - - $ref: '#/components/schemas/IeduComplementInput' + - $ref: '#/components/schemas/HidroYPetroComplement' + - $ref: '#/components/schemas/IeduComplement' parts: type: array items: $ref: "#/components/schemas/Parts" description: Object with information about the parts conforming this item or product. - ThirdPartyInput: + ThirdParty: type: object description: | Object with information about the third-party contributor, on behalf of whom the operation is carried out. @@ -13765,6 +13765,9 @@ components: type: string description: Postal code of the third party. example: "01234" + ThirdPartyInput: + allOf: + - $ref: "#/components/schemas/ThirdParty" InvoiceItemInput: title: InvoiceItem required: @@ -13929,13 +13932,9 @@ components: type: string description: Postal code of the third party. example: "01234" - HidroYPetroComplementInput: + HidroYPetroComplement: title: HidroYPetroComplement type: object - required: - - tipo_permiso - - numero_permiso - - sub_producto_hyp properties: tipo_permiso: type: string @@ -14010,18 +14009,20 @@ components: - Diesel - Combustibles para barco - IeduComplementInput: + HidroYPetroComplementInput: + allOf: + - $ref: "#/components/schemas/HidroYPetroComplement" + required: + - tipo_permiso + - numero_permiso + - sub_producto_hyp + IeduComplement: title: IeduComplement type: object description: | Private Education Institutions concept-level complement version 1.0. Include it at the concept level in `items[].complement`. - required: - - nombreAlumno - - CURP - - nivelEducativo - - autRVOE properties: nombreAlumno: type: string @@ -14055,6 +14056,14 @@ components: description: RFC of the person making the payment when different from the person receiving the service. example: PELJ800101ABC + IeduComplementInput: + allOf: + - $ref: "#/components/schemas/IeduComplement" + required: + - nombreAlumno + - CURP + - nivelEducativo + - autRVOE CustomComplementData: title: string type: string diff --git a/website/openapi_v2.yaml b/website/openapi_v2.yaml index d5422731a..43ecea757 100644 --- a/website/openapi_v2.yaml +++ b/website/openapi_v2.yaml @@ -13679,19 +13679,19 @@ components: type: string description: Números de pedimento asociados a este concepto. Se omite cuando no existen. third_party: - $ref: '#/components/schemas/ThirdPartyInput' + $ref: '#/components/schemas/ThirdParty' complement: oneOf: - type: string format: xml - - $ref: '#/components/schemas/HidroYPetroComplementInput' - - $ref: '#/components/schemas/IeduComplementInput' + - $ref: '#/components/schemas/HidroYPetroComplement' + - $ref: '#/components/schemas/IeduComplement' parts: type: array items: $ref: "#/components/schemas/Parts" description: Objeto con información de las partes de la factura. - ThirdPartyInput: + ThirdParty: type: object description: | Objeto con información del contribuyente tercero, a cuenta del que se realiza la operación. @@ -13716,6 +13716,9 @@ components: type: string description: Código postal del tercero. example: "01234" + ThirdPartyInput: + allOf: + - $ref: "#/components/schemas/ThirdParty" InvoiceItemInput: title: InvoiceItem required: @@ -13880,13 +13883,9 @@ components: type: string description: Código postal del tercero. example: "01234" - HidroYPetroComplementInput: + HidroYPetroComplement: title: HidroYPetroComplement type: object - required: - - tipo_permiso - - numero_permiso - - sub_producto_hyp properties: tipo_permiso: type: string @@ -13961,18 +13960,20 @@ components: - Diesel - Combustibles para barco - IeduComplementInput: + HidroYPetroComplementInput: + allOf: + - $ref: "#/components/schemas/HidroYPetroComplement" + required: + - tipo_permiso + - numero_permiso + - sub_producto_hyp + IeduComplement: title: IeduComplement type: object description: | Complemento concepto de Instituciones Educativas Privadas versión 1.0. Se incluye a nivel concepto dentro de `items[].complement`. - required: - - nombreAlumno - - CURP - - nivelEducativo - - autRVOE properties: nombreAlumno: type: string @@ -14006,6 +14007,14 @@ components: description: RFC de quien realiza el pago cuando sea diferente a quien recibe el servicio. example: PELJ800101ABC + IeduComplementInput: + allOf: + - $ref: "#/components/schemas/IeduComplement" + required: + - nombreAlumno + - CURP + - nivelEducativo + - autRVOE CustomComplementData: title: string type: string diff --git a/website/test/openapi-types.fixture.txt b/website/test/openapi-types.fixture.txt index 554d68d71..df2bb978d 100644 --- a/website/test/openapi-types.fixture.txt +++ b/website/test/openapi-types.fixture.txt @@ -254,3 +254,11 @@ const organizationSeriesList: operations['getSeriesGroup']['responses'][200]['co const organizationSeriesEnvelope: operations['getSeriesGroup']['responses'][200]['content']['application/json'] = { data: [] }; const cancellationLastChecked: components['schemas']['InvoiceCancellation'] = { status: "none", last_checked: "2026-10-14T12:00:00Z" }; + +const sharedThirdParty: components['schemas']['ThirdParty'] = { tax_id: 'MIC920101HN7' }; +const invoiceThirdParty: components['schemas']['InvoiceItem']['third_party'] = sharedThirdParty; +// @ts-expect-error educational complement requests retain required student fields +const incompleteIeduInput: components['schemas']['IeduComplementInput'] = {}; +// @ts-expect-error hydrocarbon complement requests retain required permit fields +const incompleteHydrocarbonInput: components['schemas']['HidroYPetroComplementInput'] = {}; +void [invoiceThirdParty, incompleteIeduInput, incompleteHydrocarbonInput]; From a695cbc96401fad7043c2d5d77b96997841dfec2 Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 14 Oct 2026 22:17:24 +0200 Subject: [PATCH 05/12] docs: validate overlapping schema alternatives --- website/openapi_v2.en.yaml | 4 +-- website/openapi_v2.yaml | 4 +-- website/package.json | 1 + website/pnpm-lock.yaml | 3 ++ website/scripts/check-openapi.mjs | 55 +++++++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/website/openapi_v2.en.yaml b/website/openapi_v2.en.yaml index 5f297dc59..1a9bd9884 100644 --- a/website/openapi_v2.en.yaml +++ b/website/openapi_v2.en.yaml @@ -13730,7 +13730,7 @@ components: third_party: $ref: '#/components/schemas/ThirdParty' complement: - oneOf: + anyOf: - type: string format: xml - $ref: '#/components/schemas/HidroYPetroComplement' @@ -15337,7 +15337,7 @@ components: - $ref: "#/components/schemas/ComercioExteriorPropietario" - $ref: "#/components/schemas/CustomerComercioExterior" Receptor: - oneOf: + anyOf: - $ref: "#/components/schemas/ComercioExteriorReceptor" - $ref: "#/components/schemas/CustomerComercioExterior" Destinatario: diff --git a/website/openapi_v2.yaml b/website/openapi_v2.yaml index 43ecea757..81b2b50e3 100644 --- a/website/openapi_v2.yaml +++ b/website/openapi_v2.yaml @@ -13681,7 +13681,7 @@ components: third_party: $ref: '#/components/schemas/ThirdParty' complement: - oneOf: + anyOf: - type: string format: xml - $ref: '#/components/schemas/HidroYPetroComplement' @@ -15280,7 +15280,7 @@ components: - $ref: "#/components/schemas/ComercioExteriorPropietario" - $ref: "#/components/schemas/CustomerComercioExterior" Receptor: - oneOf: + anyOf: - $ref: "#/components/schemas/ComercioExteriorReceptor" - $ref: "#/components/schemas/CustomerComercioExterior" Destinatario: diff --git a/website/package.json b/website/package.json index 17ed999b3..f793076ba 100644 --- a/website/package.json +++ b/website/package.json @@ -36,6 +36,7 @@ "@types/react": "^19.1.8", "@types/react-helmet": "^6.1.11", "@types/react-router-dom": "^5.3.3", + "ajv": "^8.20.0", "js-yaml": "5.4.2", "openapi-typescript": "7.13.0", "typescript": "^5.8.3" diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml index 34b9c04de..d3e5f98c9 100644 --- a/website/pnpm-lock.yaml +++ b/website/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: '@types/react-router-dom': specifier: ^5.3.3 version: 5.3.3 + ajv: + specifier: ^8.20.0 + version: 8.20.0 js-yaml: specifier: 5.4.2 version: 5.4.2 diff --git a/website/scripts/check-openapi.mjs b/website/scripts/check-openapi.mjs index 7983d3d0a..7cd8894ae 100644 --- a/website/scripts/check-openapi.mjs +++ b/website/scripts/check-openapi.mjs @@ -7,6 +7,7 @@ import { execFileSync } from "node:child_process"; import * as yaml from "js-yaml"; import openapiTS, { astToString } from "openapi-typescript"; import ts from "typescript"; +import Ajv2020 from "ajv/dist/2020.js"; const root = new URL("../", import.meta.url); const contracts = []; @@ -39,6 +40,44 @@ function contract(value, key) { for (const filename of ["openapi_v2.yaml", "openapi_v2.en.yaml"]) { // Match the generator parser so unquoted numeric SAT codes fail validation. const spec = yaml.load(await readFile(new URL(filename, root), "utf8")); + const validateItemComplement = new Ajv2020({ + strict: false, + validateFormats: false, + }).compile({ + $ref: "#/components/schemas/InvoiceItem/properties/complement", + components: spec.components, + }); + for (const complement of [ + "", + { tipo_permiso: "PER01", numero_permiso: "123", sub_producto_hyp: "Diesel" }, + { + nombreAlumno: "Alumno", + CURP: "FULJ790823MWSWSZ23", + nivelEducativo: "Primaria", + autRVOE: "123", + }, + ]) { + assert( + validateItemComplement(complement), + `Valid invoice item complement rejected: ${JSON.stringify(validateItemComplement.errors)}`, + ); + } + for (const complement of [null, 1, []]) + assert( + !validateItemComplement(complement), + "Invalid invoice item complement accepted", + ); + const validateForeignTradeReceiver = new Ajv2020({ + strict: false, + validateFormats: false, + }).compile({ + $ref: "#/components/schemas/ComercioExteriorDataProperties/properties/Receptor", + components: spec.components, + }); + assert( + validateForeignTradeReceiver({ NumRegIdTrib: "123456" }), + "Foreign trade receiver object must not be rejected by overlapping branches", + ); for (const name of Object.keys(spec.components.schemas)) assert(!/(InputProperties|RequiredProperties)$/.test(name), `Use an Input schema with its own required fields: ${name}`); @@ -49,6 +88,22 @@ for (const filename of ["openapi_v2.yaml", "openapi_v2.en.yaml"]) { } function walk(value, path = [], references) { if (!value || typeof value !== "object") return; + if (path[0] === "components" && path[1] === "schemas" && value.oneOf) { + const validate = new Ajv2020({ + strict: false, + validateFormats: false, + }).compile({ + ...value, + components: spec.components, + }); + validate({}); + assert( + !validate.errors?.some( + error => error.keyword === "oneOf" && error.params.passingSchemas?.length > 1, + ), + `Overlapping oneOf branches accept the same empty object: ${path.join(".")}`, + ); + } assert( !Object.hasOwn(value, "<<"), `YAML merge keys are not supported: ${path.join(".")}`, From 6d8a3af087c70565fe97e325b2d73e77f242f0fa Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 14 Oct 2026 22:39:47 +0200 Subject: [PATCH 06/12] docs: align nullable fields and series response contracts --- website/openapi_v2.en.yaml | 159 ++++++++++++++++--------- website/openapi_v2.yaml | 159 ++++++++++++++++--------- website/scripts/check-openapi.mjs | 19 +++ website/test/openapi-types.fixture.txt | 23 ++++ 4 files changed, 246 insertions(+), 114 deletions(-) diff --git a/website/openapi_v2.en.yaml b/website/openapi_v2.en.yaml index 1a9bd9884..0964988ed 100644 --- a/website/openapi_v2.en.yaml +++ b/website/openapi_v2.en.yaml @@ -6628,10 +6628,10 @@ paths: schema: type: string format: binary - "204": - description: No eligible receipts found for the provided keys "400": $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthenticated" "500": @@ -10133,7 +10133,9 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/OrganizationSeriesGroup" + type: array + items: + $ref: "#/components/schemas/OrganizationSeriesGroup" "400": $ref: "#/components/responses/BadRequest" "401": @@ -10232,7 +10234,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/OkResponse" + $ref: "#/components/schemas/Organization" "400": $ref: "#/components/responses/BadRequest" "401": @@ -10335,7 +10337,9 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/OrganizationSeriesGroup" + type: array + items: + $ref: "#/components/schemas/OrganizationSeriesGroup" "400": $ref: "#/components/responses/BadRequest" "401": @@ -10418,7 +10422,9 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/OrganizationSeriesGroup" + type: array + items: + $ref: "#/components/schemas/OrganizationSeriesDeletion" "400": $ref: "#/components/responses/BadRequest" "401": @@ -13583,7 +13589,7 @@ components: example: 0.1 description: Tax rate in decimal format. base: - type: number + type: [number, 'null'] description: "Tax base. If omitted, the full subtotal of the line item is used." type: type: string @@ -13619,7 +13625,7 @@ components: readOnly: true description: Calculated tax amount. base: - type: number + type: [number, 'null'] description: "Tax base. If omitted, it is calculated from the line item subtotal and tax factor. For the Cuota factor, the number of units is used." type: type: string @@ -16054,6 +16060,37 @@ components: format: url example: http://www.sat.gob.mx/sitio_interet/cfd/iedu/iedu.xsd description: URL of the XSD validation schema. + Address: + allOf: + - $ref: '#/components/schemas/CommonAddressProperties' + properties: + state: + type: string + country: + type: string + minLength: 3 + maxLength: 3 + CustomerAddress: + type: [object, 'null'] + properties: + street: + type: [string, 'null'] + exterior: + type: [string, 'null'] + interior: + type: [string, 'null'] + neighborhood: + type: [string, 'null'] + city: + type: [string, 'null'] + municipality: + type: [string, 'null'] + zip: + type: [string, 'null'] + state: + type: [string, 'null'] + country: + type: [string, 'null'] CommonAddressProperties: type: object properties: @@ -16264,25 +16301,12 @@ components: - type: object properties: address: - allOf: - - $ref: "#/components/schemas/CommonAddressProperties" - - type: object - description: Fiscal address. - properties: - state: - type: string - description: If the country is Mexico ("MEX"), it contains the name of the State or Federative Entity. For foreigners, it contains the State code according to the standard [ISO 3166-2](https://en.wikipedia.org/wiki/ISO_3166-2), which you can consult in our [State Catalog](https://dashboard.facturapi.io/catalogs/state). - example: Sonora - country: - type: string - description: Country code according to the standard [ISO 3166-1 alpha-3](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-3), from the [Country Catalog](https://dashboard.facturapi.io/catalogs/country). - example: MEX - default: MEX + $ref: '#/components/schemas/CustomerAddress' CustomerCommonProperties: type: object properties: legal_name: - type: string + type: [string, 'null'] description: | Legal name or business name of the customer. *without* the corporate regime (e.g.: S.A. de C.V.). example: Dunder Mifflin @@ -16512,8 +16536,8 @@ components: state: type: string country: - type: string - const: MEX + type: [string, 'null'] + enum: [MEX, '', null] default: MEX required: - zip @@ -16736,6 +16760,9 @@ components: items: $ref: "#/components/schemas/Product" ProductCreateInput: + properties: + unit_name: + type: string allOf: - $ref: "#/components/schemas/ProductCommonProperties" required: [description, product_key, price] @@ -16743,7 +16770,15 @@ components: allOf: - $ref: "#/components/schemas/ProductCommonProperties" required: [description, product_key, price] + properties: + unit_name: + type: [string, 'null'] + default: Elemento + description: Name of the unit of measure used to express the quantity. It must be related to the unit key `unit_key`. ProductUpdateInput: + properties: + unit_name: + type: string allOf: - $ref: "#/components/schemas/ProductCommonProperties" ProductCommonProperties: @@ -16825,10 +16860,6 @@ components: Key of the unit of measure, from the SAT catalog. The default value `"H87"` (element) is the key to represent a piece or sales unit (pencil, notebook, television, etc). If the unit of your product is kilograms, liters, hours, or any other unit, we provide a convenient way to find the key using our [key search tool](https://dashboard.facturapi.io/catalogs/unit). - unit_name: - type: string - default: Elemento - description: Name of the unit of measure used to express the quantity. It must be related to the unit key `unit_key`. sku: type: string description: Internal identifier designated by the company. It can have any value. @@ -17114,25 +17145,19 @@ components: description: ID of the `customer` object related to the invoice, in case it has not been deleted. example: 58e93bd8e86eb318b0197456 legal_name: - type: string + type: [string, 'null'] description: | Legal name or business name of the customer. *without* the corporate regime (e.g.: S.A. de C.V.). example: Dunder Mifflin tax_id: - type: string + type: [string, 'null'] description: | In Mexican clients, it contains the customer's RFC. For foreigners, it is optional and represents the tax identification number, that is, the equivalent to the RFC in the customer's country. example: ABC101010111 address: - type: object - properties: - country: - type: string - format: ISO 3166-1 alpha-3 - description: Country code according to the standard [ISO 3166-1 alpha-3](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-3). - example: MEX - zip: - type: string + $ref: '#/components/schemas/Address' + phone: + type: [string, 'null'] tax_system: type: - string @@ -17148,7 +17173,6 @@ components: RelatedDocumentInput: required: - relationship - - related allOf: - $ref: '#/components/schemas/RelatedDocument' RelatedDocument: @@ -17417,7 +17441,7 @@ components: description: Issue date in ISO8601 format. May be null for drafts. address: allOf: - - $ref: '#/components/schemas/CommonAddressProperties' + - $ref: '#/components/schemas/Address' - type: object description: Address where the invoice was issued. properties: @@ -18726,6 +18750,8 @@ components: - items - key properties: + address: + $ref: '#/components/schemas/Address' api_version: type: integer readOnly: true @@ -18766,7 +18792,7 @@ components: example: 356.78 description: Total amount of the operation invoice: - type: string + type: [string, 'null'] example: 614496b471d422de4b6cfcc4 description: ID of the associated invoice, if invoiced. customer: @@ -19195,7 +19221,7 @@ components: Current status of the document. example: valid verification_url: - type: string + type: [string, 'null'] format: uri description: URL to verify the status of the retention on the SAT portal. This link is the same as the one that appears in the QR code on the retention PDF. example: https://verificacfdi.facturaelectronica.sat.gob.mx/default.aspx?id=45BEC0CA-5F1E-491E-9417-698EA48C382A&re=AAA010101AAA&rr=ABC101010111&tt=345.600000&fe=bWApPw== @@ -19206,7 +19232,7 @@ components: example: Retención description: Type of document. uuid: - type: string + type: [string, 'null'] format: uuid description: | Fiscal folio of the retention, assigned by the SAT. @@ -19231,7 +19257,7 @@ components: - fecha_exp properties: cve_retenc: - type: string + type: [string, 'null'] example: "01" description: | Key of the retention or payment information according to the SAT catalog. @@ -19250,7 +19276,7 @@ components: type: string description: Alphanumeric identifier for internal control of the company and without fiscal relevance. periodo: - type: object + type: [object, 'null'] description: Information about the retention period. properties: mes_ini: @@ -19267,7 +19293,7 @@ components: type: integer description: Fiscal year in which the retention was made. totales: - type: object + type: [object, 'null'] description: Information about the total of retentions made in the corresponding period. properties: monto_tot_operacion: @@ -19329,11 +19355,11 @@ components: - `03`: Definitive ISR Platforms payment - `04`: Provisional ISR payment external_id: - type: string + type: [string, 'null'] description: | Optional identifier that you can use to relate this retention to your records and later search by this number. Facturapi does not validate that this field is unique. idempotency_key: - type: string + type: [string, 'null'] description: | Unique identifier that you can use to avoid duplicates when retrying a request. It can be any text string, as long as it is unique for each document. @@ -19351,8 +19377,10 @@ components: description: | In case you need to include more information in the PDF, this field allows you to insert HTML code with your own content. addenda: - type: string - format: xml + type: array + items: + type: string + format: xml description: XML code with the Addenda that needs to be added to the invoice. namespaces: type: array @@ -19563,8 +19591,10 @@ components: description: | In case you need to include more information in the PDF, this field allows you to insert HTML code with your own content. addenda: - type: string - format: xml + type: array + items: + type: string + format: xml description: XML code with the Addenda that needs to be added to the invoice. namespaces: type: array @@ -20262,18 +20292,18 @@ components: type: object required: - series - - next_folio - - next_folio_test properties: series: type: string - maxLength: 55 + maxLength: 25 description: Series name next_folio: type: integer + default: 1 description: Folio number that will be assigned to the next invoice in the Live environment (and will automatically increase with each new invoice). next_folio_test: type: integer + default: 1 description: Folio number that will be assigned to the next invoice in the Test environment (and will automatically increase with each new invoice). OrganizationSeriesUpdateInput: type: object @@ -20306,6 +20336,21 @@ components: minLength: 1 maxLength: 25 description: Series name. + OrganizationSeriesDeletion: + type: [object, 'null'] + properties: + series: + type: string + livemode: + type: boolean + next_folio: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time OrganizationSeriesGroup: title: Objeto Series type: object diff --git a/website/openapi_v2.yaml b/website/openapi_v2.yaml index 81b2b50e3..2d1e8b118 100644 --- a/website/openapi_v2.yaml +++ b/website/openapi_v2.yaml @@ -6591,10 +6591,10 @@ paths: schema: type: string format: binary - "204": - description: No se encontraron recibos elegibles para las keys enviadas "400": $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthenticated" "500": @@ -10091,7 +10091,9 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/OrganizationSeriesGroup" + type: array + items: + $ref: "#/components/schemas/OrganizationSeriesGroup" "400": $ref: "#/components/responses/BadRequest" "401": @@ -10190,7 +10192,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/OkResponse" + $ref: "#/components/schemas/Organization" "400": $ref: "#/components/responses/BadRequest" "401": @@ -10293,7 +10295,9 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/OrganizationSeriesGroup" + type: array + items: + $ref: "#/components/schemas/OrganizationSeriesGroup" "400": $ref: "#/components/responses/BadRequest" "401": @@ -10376,7 +10380,9 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/OrganizationSeriesGroup" + type: array + items: + $ref: "#/components/schemas/OrganizationSeriesDeletion" "400": $ref: "#/components/responses/BadRequest" "401": @@ -13535,7 +13541,7 @@ components: example: 0.1 description: Tasa del impuesto en fracción decimal. base: - type: number + type: [number, 'null'] description: "Base del impuesto. Si se omite, se utiliza el subtotal completo del concepto." type: type: string @@ -13571,7 +13577,7 @@ components: readOnly: true description: Importe calculado del impuesto. base: - type: number + type: [number, 'null'] description: "Base del impuesto. Si se omite, se calcula a partir del subtotal del concepto y el factor del impuesto. Para el factor Cuota, se utiliza la cantidad de unidades." type: type: string @@ -16051,6 +16057,37 @@ components: example: http://www.sat.gob.mx/sitio_interet/cfd/iedu/iedu.xsd description: Dirección URL del esquema de validación XSD. + Address: + allOf: + - $ref: '#/components/schemas/CommonAddressProperties' + properties: + state: + type: string + country: + type: string + minLength: 3 + maxLength: 3 + CustomerAddress: + type: [object, 'null'] + properties: + street: + type: [string, 'null'] + exterior: + type: [string, 'null'] + interior: + type: [string, 'null'] + neighborhood: + type: [string, 'null'] + city: + type: [string, 'null'] + municipality: + type: [string, 'null'] + zip: + type: [string, 'null'] + state: + type: [string, 'null'] + country: + type: [string, 'null'] CommonAddressProperties: type: object properties: @@ -16266,25 +16303,12 @@ components: - type: object properties: address: - allOf: - - $ref: "#/components/schemas/CommonAddressProperties" - - type: object - description: Domicilio fiscal. - properties: - state: - type: string - description: Si el país es México ("MEX"), contiene el nombre del Estado o Entidad Federativa. Para extranjeros contiene el código de Estado de acuerdo al estándar [ISO 3166-2](https://en.wikipedia.org/wiki/ISO_3166-2), que puedes consultar en nuestro [Catálogo de Estados](https://dashboard.facturapi.io/catalogs/state). - example: Sonora - country: - type: string - description: Código de país acorde al estándar [ISO 3166-1 alpha-3](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-3), del [Catálogo de Países](https://dashboard.facturapi.io/catalogs/country). - example: MEX - default: MEX + $ref: '#/components/schemas/CustomerAddress' CustomerCommonProperties: type: object properties: legal_name: - type: string + type: [string, 'null'] description: | Nombre Fiscal o Razón Social del cliente. *sin* el régimen societario (ej.: S.A. de C.V.). example: Dunder Mifflin @@ -16512,8 +16536,8 @@ components: state: type: string country: - type: string - const: MEX + type: [string, 'null'] + enum: [MEX, '', null] default: MEX required: - zip @@ -16731,6 +16755,9 @@ components: items: $ref: "#/components/schemas/Product" ProductCreateInput: + properties: + unit_name: + type: string allOf: - $ref: "#/components/schemas/ProductCommonProperties" required: [description, product_key, price] @@ -16738,7 +16765,15 @@ components: allOf: - $ref: "#/components/schemas/ProductCommonProperties" required: [description, product_key, price] + properties: + unit_name: + type: [string, 'null'] + default: Elemento + description: Palabra que representa la unidad de medida de tu producto. Debe estar relacionada con la clave de unidad `unit_key`. ProductUpdateInput: + properties: + unit_name: + type: string allOf: - $ref: "#/components/schemas/ProductCommonProperties" ProductCommonProperties: @@ -16818,10 +16853,6 @@ components: description: | Clave de unidad de medida, del catálogo del SAT. El valor por default `"H87"` (elemento) es la clave para representar una pieza o unidad de venta (lápiz, cuaderno, televisión, etc). Si la unidad de tu producto es kilogramos, litros, horas u otra unidad, puedes encontrar la clave utilizando nuestra [herramienta de búsqueda de claves](https://dashboard.facturapi.io/catalogs/unit). - unit_name: - type: string - default: Elemento - description: Palabra que representa la unidad de medida de tu producto. Debe estar relacionada con la clave de unidad `unit_key`. sku: type: string description: Identificador de uso interno designado por la empresa. Puede tener cualquier valor. @@ -17070,24 +17101,18 @@ components: description: ID del objeto `customer` relacionado a la factura, en caso de no haber sido eliminado example: 58e93bd8e86eb318b0197456 legal_name: - type: string + type: [string, 'null'] description: | Nombre Fiscal o Razón Social del cliente, *sin* incluir el régimen societario (ej.: S.A. de C.V.). example: Dunder Mifflin tax_id: - type: string + type: [string, 'null'] description: RFC del cliente. example: ABC101010111 address: - type: object - properties: - country: - type: string - format: ISO 3166-1 alpha-3 - description: Código de País acorde al estándar ISO 3166-1 alpha-3, del Catálogo de Países. - example: MEX - zip: - type: string + $ref: '#/components/schemas/Address' + phone: + type: [string, 'null'] tax_system: type: - string @@ -17103,7 +17128,6 @@ components: RelatedDocumentInput: required: - relationship - - related allOf: - $ref: '#/components/schemas/RelatedDocument' RelatedDocument: @@ -17371,7 +17395,7 @@ components: description: Fecha de expedición en formato ISO8601. Puede ser null en borradores. address: allOf: - - $ref: '#/components/schemas/CommonAddressProperties' + - $ref: '#/components/schemas/Address' - type: object description: Domicilio de expedición de la factura. properties: @@ -18662,6 +18686,8 @@ components: - items - key properties: + address: + $ref: '#/components/schemas/Address' api_version: type: integer readOnly: true @@ -18699,7 +18725,7 @@ components: example: 356.78 description: Monto total de la operación invoice: - type: string + type: [string, 'null'] example: 614496b471d422de4b6cfcc4 description: ID de la factura asociada, en caso de estar facturado. customer: @@ -19114,7 +19140,7 @@ components: Estado actual de la retención. example: valid verification_url: - type: string + type: [string, 'null'] format: uri description: Dirección URL para verificar el estado de la retención en el portal del SAT. Este link es el mismo que aparece en el código QR, en el PDF de la retención. example: https://verificacfdi.facturaelectronica.sat.gob.mx/default.aspx?id=45BEC0CA-5F1E-491E-9417-698EA48C382A&re=AAA010101AAA&rr=ABC101010111&tt=345.600000&fe=bWApPw== @@ -19125,7 +19151,7 @@ components: example: Retención description: Tipo de comprobante. uuid: - type: string + type: [string, 'null'] format: uuid description: Folio fiscal de la retención, asignado por el SAT. example: 39c85a3f-275b-4341-b259-e8971d9f8a94 @@ -19149,7 +19175,7 @@ components: - fecha_exp properties: cve_retenc: - type: string + type: [string, 'null'] example: "01" description: Clave de la retención o información de pagos de acuerdo al catálogo del SAT. fecha_exp: @@ -19166,7 +19192,7 @@ components: type: string description: Identificador alfanumérico para control interno de la empresa y sin relevancia fiscal. periodo: - type: object + type: [object, 'null'] description: Información sobre el periodo de la retención. properties: mes_ini: @@ -19183,7 +19209,7 @@ components: type: integer description: Año o ejercicio fiscal en que se realizó la retención. totales: - type: object + type: [object, 'null'] description: Información sobre el total de retenciones efectuadas en el periodo correspondiente. properties: monto_tot_operacion: @@ -19239,10 +19265,10 @@ components: - `03`: Pago definitivo ISR Plataformas - `04`: Pago provisional ISR external_id: - type: string + type: [string, 'null'] description: Identificador opcional que puedes usar para relacionar esta retención con tus registros y poder hacer búsquedas usando este identificador. Facturapi no valida que este campo sea único. idempotency_key: - type: string + type: [string, 'null'] description: | Identificador único que puedes usar para evitar duplicados al reintentar una petición. Puede ser cualquier cadena de texto, mientras sea única para cada documento. Si se deja en blanco, no se tomará en cuenta. @@ -19259,8 +19285,10 @@ components: format: html description: En caso de que necesites incluir más información en el PDF, este campo te permite insertar código HTML con tu propio contenido. addenda: - type: string - format: xml + type: array + items: + type: string + format: xml description: Código XML con la Addenda que se necesite agregar a la factura. namespaces: type: array @@ -19464,8 +19492,10 @@ components: format: html description: En caso de que necesites incluir más información en el PDF, este campo te permite insertar código HTML con tu propio contenido. addenda: - type: string - format: xml + type: array + items: + type: string + format: xml description: Código XML con la Addenda que se necesite agregar a la factura. namespaces: type: array @@ -20160,18 +20190,18 @@ components: type: object required: - series - - next_folio - - next_folio_test properties: series: type: string - maxLength: 55 + maxLength: 25 description: Nombre de la serie. next_folio: type: integer + default: 1 description: Número de folio que se asignará a la siguiente factura en ambiente Live (y que se incrementará automáticamente por cada nueva factura). next_folio_test: type: integer + default: 1 description: Número de folio que se asignará a la siguiente factura en ambiente Test (y que se incrementará automáticamente por cada nueva factura). OrganizationSeriesUpdateInput: type: object @@ -20204,6 +20234,21 @@ components: minLength: 1 maxLength: 25 description: Nombre de la serie. + OrganizationSeriesDeletion: + type: [object, 'null'] + properties: + series: + type: string + livemode: + type: boolean + next_folio: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time OrganizationSeriesGroup: title: Objeto Series type: object diff --git a/website/scripts/check-openapi.mjs b/website/scripts/check-openapi.mjs index 7cd8894ae..265a0f581 100644 --- a/website/scripts/check-openapi.mjs +++ b/website/scripts/check-openapi.mjs @@ -78,6 +78,25 @@ for (const filename of ["openapi_v2.yaml", "openapi_v2.en.yaml"]) { validateForeignTradeReceiver({ NumRegIdTrib: "123456" }), "Foreign trade receiver object must not be rejected by overlapping branches", ); + for (const [schema, value, expected] of [ + ["BaseTax", { type: "IVA", rate: 0.16, base: null }, true], + ["LocalTax", { type: "ISH", rate: 0.03, base: null }, true], + ["CustomerInfo", { legal_name: null, tax_id: null, phone: null }, true], + ["ProductProperties", { description: "Example", product_key: "60131324", price: 100, unit_name: null }, true], + ["ProductCreateInput", { description: "Example", product_key: "60131324", price: 100, unit_name: null }, false], + ["CustomerNationalAddressInput", { zip: "83200", country: null }, true], + ["RelatedDocumentInput", { relationship: "01", documents: ["39c85a3f-275b-4341-b259-e8971d9f8a94"] }, true], + ["OrganizationSeriesCreateInput", { series: "A" }, true], + ["CustomerProperties", { legal_name: null, address: null }, true], + ["RetentionProperties", { fecha_exp: null, cve_retenc: null, periodo: null, totales: null, addenda: [] }, true], + ["RetentionUpdateInput", { addenda: [""] }, true], + ]) { + const validate = new Ajv2020({ strict: false, validateFormats: false }).compile({ + $ref: `#/components/schemas/${schema}`, + components: spec.components, + }); + assert.equal(validate(value), expected, `Unexpected contract validation for ${schema}`); + } for (const name of Object.keys(spec.components.schemas)) assert(!/(InputProperties|RequiredProperties)$/.test(name), `Use an Input schema with its own required fields: ${name}`); diff --git a/website/test/openapi-types.fixture.txt b/website/test/openapi-types.fixture.txt index df2bb978d..4b3f91c45 100644 --- a/website/test/openapi-types.fixture.txt +++ b/website/test/openapi-types.fixture.txt @@ -262,3 +262,26 @@ const incompleteIeduInput: components['schemas']['IeduComplementInput'] = {}; // @ts-expect-error hydrocarbon complement requests retain required permit fields const incompleteHydrocarbonInput: components['schemas']['HidroYPetroComplementInput'] = {}; void [invoiceThirdParty, incompleteIeduInput, incompleteHydrocarbonInput]; + +const nullTaxBase: components['schemas']['BaseTax'] = { type: 'IVA', rate: 0.16, base: null }; +const nullLocalTaxBase: components['schemas']['LocalTax'] = { type: 'ISH', rate: 0.03, base: null }; +const incompleteInvoiceCustomer: components['schemas']['CustomerInfo'] = { legal_name: null, tax_id: null, phone: null }; +const uninvoicedReceipt: components['schemas']['ReceiptProperties']['invoice'] = null; +const responseProductWithNullUnit: components['schemas']['ProductProperties'] = { description: 'Example', product_key: '60131324', price: 100, unit_name: null }; +// @ts-expect-error product creation still requires a non-null unit name when supplied +const inputProductWithNullUnit: components['schemas']['ProductCreateInput'] = { description: 'Example', product_key: '60131324', price: 100, unit_name: null }; +const completeResponseAddress: components['schemas']['Address'] = { state: 'Sonora', country: 'MEX' }; +void [nullTaxBase, nullLocalTaxBase, incompleteInvoiceCustomer, uninvoicedReceipt, responseProductWithNullUnit, inputProductWithNullUnit, completeResponseAddress]; +const nationalAddressWithDefaultCountry: components['schemas']['CustomerNationalAddressInput'] = { zip: '83200', country: null }; +const relatedInvoiceByUuid: components['schemas']['RelatedDocumentInput'] = { relationship: '01', documents: ['39c85a3f-275b-4341-b259-e8971d9f8a94'] }; +const seriesWithDefaultFolios: components['schemas']['OrganizationSeriesCreateInput'] = { series: 'A' }; +const receiptAddress: components['schemas']['ReceiptProperties']['address'] = { country: 'MEX', state: 'Sonora' }; +const invoiceCustomerAddress: components['schemas']['CustomerInfo']['address'] = { city: 'Huatabampo', street: 'Example' }; +void [nationalAddressWithDefaultCountry, relatedInvoiceByUuid, seriesWithDefaultFolios, receiptAddress, invoiceCustomerAddress]; +const clearedCustomerResponse: components['schemas']['CustomerProperties'] = { legal_name: null, address: null }; +const emptyRetentionResponse: components['schemas']['RetentionProperties'] = { fecha_exp: null, cve_retenc: null, periodo: null, totales: null, addenda: [] }; +const retentionWithAddenda: components['schemas']['RetentionUpdateInput'] = { addenda: [''] }; +const createdSeriesResponse: operations['createSeriesGroup']['responses'][200]['content']['application/json'] = [{ series: 'A', next_folio: 1, next_folio_test: 1 }]; +const updatedSeriesResponse: operations['updateSeriesGroup']['responses'][200]['content']['application/json'] = createdSeriesResponse; +const deletedSeriesResponse: operations['deleteSeriesGroup']['responses'][200]['content']['application/json'] = [{ series: 'A', livemode: true, next_folio: 1 }, null]; +void [clearedCustomerResponse, emptyRetentionResponse, retentionWithAddenda, createdSeriesResponse, updatedSeriesResponse, deletedSeriesResponse]; From 0557d7e748a2fbea9d014a6a1f688acfb95d2137 Mon Sep 17 00:00:00 2001 From: javorosas Date: Wed, 14 Oct 2026 22:41:19 +0200 Subject: [PATCH 07/12] docs: include deleted drafts in cancellation responses --- website/openapi_v2.en.yaml | 4 +++- website/openapi_v2.yaml | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/website/openapi_v2.en.yaml b/website/openapi_v2.en.yaml index 0964988ed..436522302 100644 --- a/website/openapi_v2.en.yaml +++ b/website/openapi_v2.en.yaml @@ -4096,7 +4096,9 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Invoice" + anyOf: + - $ref: "#/components/schemas/Invoice" + - $ref: "#/components/schemas/InvoiceDraft" "400": $ref: "#/components/responses/BadRequest" "401": diff --git a/website/openapi_v2.yaml b/website/openapi_v2.yaml index 2d1e8b118..7dcfcb58c 100644 --- a/website/openapi_v2.yaml +++ b/website/openapi_v2.yaml @@ -4070,7 +4070,9 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Invoice" + anyOf: + - $ref: "#/components/schemas/Invoice" + - $ref: "#/components/schemas/InvoiceDraft" "400": $ref: "#/components/responses/BadRequest" "401": From 1950daeab456647495eadea040354747121cc9a8 Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 15 Oct 2026 00:41:30 +0200 Subject: [PATCH 08/12] docs: describe blank customer update fields --- website/openapi_v2.en.yaml | 24 +++++++++++++++++------- website/openapi_v2.yaml | 24 +++++++++++++++++------- website/test/openapi-types.fixture.txt | 2 ++ 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/website/openapi_v2.en.yaml b/website/openapi_v2.en.yaml index 436522302..b35fcde5a 100644 --- a/website/openapi_v2.en.yaml +++ b/website/openapi_v2.en.yaml @@ -16343,6 +16343,15 @@ components: type: string description: Default CFDI use for the customer. example: G01 + CustomerEmailInput: &customer_email_input + type: [string, 'null'] + format: email + CustomerEmailUpdateInput: + anyOf: + - $ref: '#/components/schemas/CustomerEmailInput' + - type: string + pattern: '^\s*$' + description: On update, null, an empty string, or whitespace-only text clears the email address. CustomerEditableInput: type: object properties: @@ -16352,10 +16361,8 @@ components: Legal name or business name of the customer. *without* the corporate regime (e.g.: S.A. de C.V.). example: Dunder Mifflin tax_id: *customer_tax_id - email: &customer_email_input - type: [string, 'null'] - format: email - description: Customer email address. On update, null clears the email address. + email: + $ref: '#/components/schemas/CustomerEmailUpdateInput' phone: *customer_phone external_id: &customer_external_id_input type: [string, 'null'] @@ -16393,6 +16400,7 @@ components: example: Huatabampo zip: type: string + minLength: 1 description: Postal code example: '86500' state: &customer_address_state_input @@ -16402,7 +16410,7 @@ components: default: MEX description: ISO 3166-1 alpha-3. CustomerUpdateInput: - description: On update without createEditLink=true, omitted fields retain their value. A null value clears fields that accept null; an optional field can be omitted without necessarily accepting null. Null is not allowed for legal_name, tax_id, tax_system, or address. + description: On update without createEditLink=true, omitted fields retain their value. A null value clears fields that accept null; an optional field can be omitted without necessarily accepting null. Null is not allowed for legal_name, tax_id, tax_system, or address. For email, phone, and the optional address fields street, exterior, interior, neighborhood, city, municipality, and state, empty or whitespace-only strings normalize to null. Empty postal codes are not accepted. allOf: - $ref: '#/components/schemas/CustomerEditableInput' - type: object @@ -16423,12 +16431,13 @@ components: country: type: string CustomerUpdateWithEditLinkInput: - description: With createEditLink=true, omitted fields retain their value and null clears the existing value, including fiscal fields. Non-null values must be valid. + description: With createEditLink=true, omitted fields retain their value and null clears the existing value, including fiscal fields. Non-null values must be valid. For email, phone, and the optional address fields street, exterior, interior, neighborhood, city, municipality, and state, empty or whitespace-only strings normalize to null. Empty postal codes are not accepted. type: object properties: legal_name: *customer_legal_name_input tax_id: *customer_tax_id - email: *customer_email_input + email: + $ref: '#/components/schemas/CustomerEmailUpdateInput' phone: *customer_phone external_id: *customer_external_id_input default_invoice_use: *customer_default_invoice_use_input @@ -16446,6 +16455,7 @@ components: country: *customer_address_country_input zip: type: [string, 'null'] + minLength: 1 description: Postal code example: '86500' CancellationQueryInput: diff --git a/website/openapi_v2.yaml b/website/openapi_v2.yaml index 7dcfcb58c..600a04f8a 100644 --- a/website/openapi_v2.yaml +++ b/website/openapi_v2.yaml @@ -16343,6 +16343,15 @@ components: type: string description: Uso de CFDI por defecto. example: G01 + CustomerEmailInput: &customer_email_input + type: [string, 'null'] + format: email + CustomerEmailUpdateInput: + anyOf: + - $ref: '#/components/schemas/CustomerEmailInput' + - type: string + pattern: '^\s*$' + description: En una actualización, null, una cadena vacía o sólo espacios eliminan el correo. CustomerEditableInput: type: object properties: @@ -16352,10 +16361,8 @@ components: Nombre Fiscal o Razón Social del cliente. *sin* el régimen societario (ej.: S.A. de C.V.). example: Dunder Mifflin tax_id: *customer_tax_id - email: &customer_email_input - type: [string, 'null'] - format: email - description: Correo electrónico del cliente. En una actualización, null elimina el correo. + email: + $ref: '#/components/schemas/CustomerEmailUpdateInput' phone: *customer_phone external_id: &customer_external_id_input type: [string, 'null'] @@ -16393,6 +16400,7 @@ components: example: Huatabampo zip: type: string + minLength: 1 description: Código postal example: '86500' state: &customer_address_state_input @@ -16402,7 +16410,7 @@ components: default: MEX description: ISO 3166-1 alpha-3. CustomerUpdateInput: - description: En una actualización sin createEditLink=true, los campos omitidos conservan su valor. Un valor null borra los campos que admiten null; un campo opcional puede omitirse sin que necesariamente admita null. No se permite null en legal_name, tax_id, tax_system ni address. + description: En una actualización sin createEditLink=true, los campos omitidos conservan su valor. Un valor null borra los campos que admiten null; un campo opcional puede omitirse sin que necesariamente admita null. No se permite null en legal_name, tax_id, tax_system ni address. En email, phone y los campos opcionales street, exterior, interior, neighborhood, city, municipality y state del domicilio, una cadena vacía o sólo espacios se normaliza a null. El código postal vacío no se acepta. allOf: - $ref: '#/components/schemas/CustomerEditableInput' - type: object @@ -16423,12 +16431,13 @@ components: country: type: string CustomerUpdateWithEditLinkInput: - description: Con createEditLink=true, los campos omitidos conservan su valor y null borra el valor existente, incluidos los datos fiscales. Los valores no nulos deben ser válidos. + description: Con createEditLink=true, los campos omitidos conservan su valor y null borra el valor existente, incluidos los datos fiscales. Los valores no nulos deben ser válidos. En email, phone y los campos opcionales street, exterior, interior, neighborhood, city, municipality y state del domicilio, una cadena vacía o sólo espacios se normaliza a null. El código postal vacío no se acepta. type: object properties: legal_name: *customer_legal_name_input tax_id: *customer_tax_id - email: *customer_email_input + email: + $ref: '#/components/schemas/CustomerEmailUpdateInput' phone: *customer_phone external_id: *customer_external_id_input default_invoice_use: *customer_default_invoice_use_input @@ -16446,6 +16455,7 @@ components: country: *customer_address_country_input zip: type: [string, 'null'] + minLength: 1 description: Código postal example: '86500' CancellationQueryInput: diff --git a/website/test/openapi-types.fixture.txt b/website/test/openapi-types.fixture.txt index 4b3f91c45..04ff229c5 100644 --- a/website/test/openapi-types.fixture.txt +++ b/website/test/openapi-types.fixture.txt @@ -285,3 +285,5 @@ const createdSeriesResponse: operations['createSeriesGroup']['responses'][200][' const updatedSeriesResponse: operations['updateSeriesGroup']['responses'][200]['content']['application/json'] = createdSeriesResponse; const deletedSeriesResponse: operations['deleteSeriesGroup']['responses'][200]['content']['application/json'] = [{ series: 'A', livemode: true, next_folio: 1 }, null]; void [clearedCustomerResponse, emptyRetentionResponse, retentionWithAddenda, createdSeriesResponse, updatedSeriesResponse, deletedSeriesResponse]; +const blankCustomerUpdate: components['schemas']['CustomerUpdateInput'] = { email: ' ', phone: '', address: { street: ' ', zip: '77535' } }; +void blankCustomerUpdate; From 4888fa9d657fffaf804d5a32768e997c74bf4818 Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 15 Oct 2026 16:43:42 +0200 Subject: [PATCH 09/12] docs: distinguish invoice responses by status --- website/openapi_v2.en.yaml | 109 ++++++++++++++++++++----- website/openapi_v2.yaml | 109 ++++++++++++++++++++----- website/test/openapi-types.fixture.txt | 11 +++ 3 files changed, 185 insertions(+), 44 deletions(-) diff --git a/website/openapi_v2.en.yaml b/website/openapi_v2.en.yaml index b35fcde5a..33561d54d 100644 --- a/website/openapi_v2.en.yaml +++ b/website/openapi_v2.en.yaml @@ -17359,17 +17359,60 @@ components: items: $ref: "#/components/schemas/InvoiceZipRequest" Invoice: - title: Invoice object + title: Invoice + oneOf: + - $ref: '#/components/schemas/InvoiceStamped' + - $ref: '#/components/schemas/InvoiceDraft' + - $ref: '#/components/schemas/InvoicePending' + - $ref: '#/components/schemas/InvoiceFailed' + InvoiceStamped: + title: Stamped allOf: - - $ref: "#/components/schemas/ResourceAutoGeneratedProps" - - $ref: "#/components/schemas/InvoiceProperties" + - $ref: '#/components/schemas/ResourceAutoGeneratedProps' + - $ref: '#/components/schemas/InvoiceCommonProperties' + - type: object + required: [date, status, uuid, stamp] + properties: + status: + type: string + enum: [valid, canceled] + date: + type: string + format: date-time + uuid: + type: string + format: uuid + stamp: + allOf: + - $ref: '#/components/schemas/Stamp' + required: [date, sat_signature, sat_cert_number, signature, complement_string] + InvoicePending: + title: Pending + allOf: + - $ref: '#/components/schemas/ResourceAutoGeneratedProps' + - $ref: '#/components/schemas/InvoiceProperties' + - type: object + properties: + status: + type: string + const: pending + InvoiceFailed: + title: Failed + allOf: + - $ref: '#/components/schemas/ResourceAutoGeneratedProps' + - $ref: '#/components/schemas/InvoiceProperties' + - type: object + properties: + status: + type: string + const: failed InvoiceCancellation: type: object description: Information about the invoice cancellation request. required: [status] properties: status: - $ref: '#/components/schemas/InvoiceProperties/properties/cancellation_status' + $ref: '#/components/schemas/InvoiceCommonProperties/properties/cancellation_status' requested_at: type: string format: date-time @@ -17377,7 +17420,7 @@ components: last_checked: type: string format: date-time - description: Date and time when the last cancellation status check started. It does not guarantee that the check completed successfully. + description: Date and time when the last cancellation status check started. Omitted if no check has been started. It does not guarantee that the check completed successfully. canceled_at: type: string format: date-time @@ -17406,8 +17449,29 @@ components: items: $ref: "#/components/schemas/Invoice" InvoiceProperties: + allOf: + - $ref: '#/components/schemas/InvoiceCommonProperties' + - type: object + required: [date, status] + properties: + date: + type: + - string + - 'null' + format: date-time + description: Issue date in ISO8601 format. May be null for drafts. + uuid: + type: [string, "null"] + format: uuid + description: Fiscal folio of the invoice, assigned by the SAT. For unstamped drafts it can be null or omitted. + example: 39c85a3f-275b-4341-b259-e8971d9f8a94 + stamp: + anyOf: + - $ref: '#/components/schemas/Stamp' + - type: 'null' + InvoiceCommonProperties: type: object - required: [date, status, cancellation_status, type, total, currency, exchange, items, amount_due] + required: [status, cancellation_status, type, total, currency, exchange, items, amount_due] properties: status: type: string @@ -17445,12 +17509,6 @@ components: format: uri description: URL to verify the status of the CFDI on the SAT portal. This link is the same as the one that appears in the QR code on the invoice PDF. example: https://verificacfdi.facturaelectronica.sat.gob.mx/default.aspx?id=45BEC0CA-5F1E-491E-9417-698EA48C382A&re=AAA010101AAA&rr=ABC101010111&tt=345.600000&fe=bWApPw== - date: - type: - - string - - 'null' - format: date-time - description: Issue date in ISO8601 format. May be null for drafts. address: allOf: - $ref: '#/components/schemas/Address' @@ -17479,11 +17537,6 @@ components: type: number description: Total amount invoiced. example: 10944.82 - uuid: - type: [string, "null"] - format: uuid - description: Fiscal folio of the invoice, assigned by the SAT. For unstamped drafts it can be null or omitted. - example: 39c85a3f-275b-4341-b259-e8971d9f8a94 folio_number: type: integer description: Autoincremental folio number for internal control and without fiscal relevance. @@ -17585,10 +17638,6 @@ components: description: Namespaces to insert in the root node of the invoice. Required for `addenda`. items: $ref: '#/components/schemas/NamespaceProperties' - stamp: - anyOf: - - $ref: '#/components/schemas/Stamp' - - type: 'null' organization: description: "ID of the organization this resource belongs to." type: @@ -17638,7 +17687,23 @@ components: type: integer InvoiceDraftProperties: allOf: - - $ref: '#/components/schemas/InvoiceProperties' + - $ref: '#/components/schemas/InvoiceCommonProperties' + - type: object + required: [date, status] + properties: + status: + type: string + const: draft + date: + type: + - string + - 'null' + format: date-time + description: Issue date in ISO8601 format. May be null for drafts. + uuid: + type: 'null' + stamp: + type: 'null' InvoiceableCommonInput: type: object properties: diff --git a/website/openapi_v2.yaml b/website/openapi_v2.yaml index 600a04f8a..5ada13355 100644 --- a/website/openapi_v2.yaml +++ b/website/openapi_v2.yaml @@ -17313,17 +17313,60 @@ components: items: $ref: "#/components/schemas/InvoiceZipRequest" Invoice: - title: Objeto Invoice + title: Invoice + oneOf: + - $ref: '#/components/schemas/InvoiceStamped' + - $ref: '#/components/schemas/InvoiceDraft' + - $ref: '#/components/schemas/InvoicePending' + - $ref: '#/components/schemas/InvoiceFailed' + InvoiceStamped: + title: Timbrada allOf: - - $ref: "#/components/schemas/ResourceAutoGeneratedProps" - - $ref: "#/components/schemas/InvoiceProperties" + - $ref: '#/components/schemas/ResourceAutoGeneratedProps' + - $ref: '#/components/schemas/InvoiceCommonProperties' + - type: object + required: [date, status, uuid, stamp] + properties: + status: + type: string + enum: [valid, canceled] + date: + type: string + format: date-time + uuid: + type: string + format: uuid + stamp: + allOf: + - $ref: '#/components/schemas/Stamp' + required: [date, sat_signature, sat_cert_number, signature, complement_string] + InvoicePending: + title: Pendiente + allOf: + - $ref: '#/components/schemas/ResourceAutoGeneratedProps' + - $ref: '#/components/schemas/InvoiceProperties' + - type: object + properties: + status: + type: string + const: pending + InvoiceFailed: + title: Fallida + allOf: + - $ref: '#/components/schemas/ResourceAutoGeneratedProps' + - $ref: '#/components/schemas/InvoiceProperties' + - type: object + properties: + status: + type: string + const: failed InvoiceCancellation: type: object description: Información de la solicitud de cancelación de la factura. required: [status] properties: status: - $ref: '#/components/schemas/InvoiceProperties/properties/cancellation_status' + $ref: '#/components/schemas/InvoiceCommonProperties/properties/cancellation_status' requested_at: type: string format: date-time @@ -17331,7 +17374,7 @@ components: last_checked: type: string format: date-time - description: Fecha y hora de inicio de la última consulta del estado de cancelación. No garantiza que la consulta haya terminado correctamente. + description: Fecha y hora de inicio de la última consulta del estado de cancelación. Se omite si nunca se ha iniciado una consulta. No garantiza que la consulta haya terminado correctamente. canceled_at: type: string format: date-time @@ -17360,8 +17403,29 @@ components: items: $ref: "#/components/schemas/Invoice" InvoiceProperties: + allOf: + - $ref: '#/components/schemas/InvoiceCommonProperties' + - type: object + required: [date, status] + properties: + date: + type: + - string + - 'null' + format: date-time + description: Fecha de expedición en formato ISO8601. Puede ser null en borradores. + uuid: + type: [string, "null"] + format: uuid + description: Folio fiscal de la factura, asignado por el SAT. En borradores sin timbrar puede ser null o estar omitido. + example: 39c85a3f-275b-4341-b259-e8971d9f8a94 + stamp: + anyOf: + - $ref: '#/components/schemas/Stamp' + - type: 'null' + InvoiceCommonProperties: type: object - required: [date, status, cancellation_status, type, total, currency, exchange, items, amount_due] + required: [status, cancellation_status, type, total, currency, exchange, items, amount_due] properties: status: type: string @@ -17399,12 +17463,6 @@ components: format: uri description: Dirección URL para verificar el estado del CFDI en el portal del SAT. Este link es el mismo que aparece en el código QR, en el PDF de la factura. example: https://verificacfdi.facturaelectronica.sat.gob.mx/default.aspx?id=45BEC0CA-5F1E-491E-9417-698EA48C382A&re=AAA010101AAA&rr=ABC101010111&tt=345.600000&fe=bWApPw== - date: - type: - - string - - 'null' - format: date-time - description: Fecha de expedición en formato ISO8601. Puede ser null en borradores. address: allOf: - $ref: '#/components/schemas/Address' @@ -17433,11 +17491,6 @@ components: type: number description: Monto total facturado. example: 10944.82 - uuid: - type: [string, "null"] - format: uuid - description: Folio fiscal de la factura, asignado por el SAT. En borradores sin timbrar puede ser null o estar omitido. - example: 39c85a3f-275b-4341-b259-e8971d9f8a94 folio_number: type: integer description: Número de folio autoincremental para control interno y sin validez fiscal. @@ -17539,10 +17592,6 @@ components: description: Namespaces a insertar en el nodo raíz de la factura. Requerido en `addenda`. items: $ref: '#/components/schemas/NamespaceProperties' - stamp: - anyOf: - - $ref: '#/components/schemas/Stamp' - - type: 'null' organization: description: "ID de la organización a la que pertenece este recurso." type: @@ -17592,7 +17641,23 @@ components: type: integer InvoiceDraftProperties: allOf: - - $ref: '#/components/schemas/InvoiceProperties' + - $ref: '#/components/schemas/InvoiceCommonProperties' + - type: object + required: [date, status] + properties: + status: + type: string + const: draft + date: + type: + - string + - 'null' + format: date-time + description: Fecha de expedición en formato ISO8601. Puede ser null en borradores. + uuid: + type: 'null' + stamp: + type: 'null' InvoiceableCommonInput: type: object properties: diff --git a/website/test/openapi-types.fixture.txt b/website/test/openapi-types.fixture.txt index 04ff229c5..68e44e2e0 100644 --- a/website/test/openapi-types.fixture.txt +++ b/website/test/openapi-types.fixture.txt @@ -166,6 +166,17 @@ const createdInvoiceDate: string | null | undefined = createdInvoiceResponse.dat const createdInvoiceStamp: components["schemas"]["Stamp"] | null | undefined = createdInvoiceResponse.stamp; void createdInvoiceDate; void createdInvoiceStamp; +if (createdInvoiceResponse.status === 'valid' || createdInvoiceResponse.status === 'canceled') { + const stampedFields: [string, string, string, string, string, string | null] = [ + createdInvoiceResponse.uuid, + createdInvoiceResponse.stamp.date, + createdInvoiceResponse.stamp.signature, + createdInvoiceResponse.stamp.sat_signature, + createdInvoiceResponse.stamp.sat_cert_number, + createdInvoiceResponse.stamp.complement_string, + ]; + void stampedFields; +} // A payment summary can be reused directly in a payment complement. declare const paymentSummary: operations["getInvoicePaymentSummary"]["responses"][200]["content"]["application/json"]; From 265bca515de035fffcceffa38b6adb493694820b Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 15 Oct 2026 17:11:40 +0200 Subject: [PATCH 10/12] docs: require stamped invoice complement strings --- website/openapi_v2.en.yaml | 3 +++ website/openapi_v2.yaml | 3 +++ website/test/openapi-types.fixture.txt | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/website/openapi_v2.en.yaml b/website/openapi_v2.en.yaml index 33561d54d..624984761 100644 --- a/website/openapi_v2.en.yaml +++ b/website/openapi_v2.en.yaml @@ -17386,6 +17386,9 @@ components: allOf: - $ref: '#/components/schemas/Stamp' required: [date, sat_signature, sat_cert_number, signature, complement_string] + properties: + complement_string: + type: string InvoicePending: title: Pending allOf: diff --git a/website/openapi_v2.yaml b/website/openapi_v2.yaml index 5ada13355..8fc2302a4 100644 --- a/website/openapi_v2.yaml +++ b/website/openapi_v2.yaml @@ -17340,6 +17340,9 @@ components: allOf: - $ref: '#/components/schemas/Stamp' required: [date, sat_signature, sat_cert_number, signature, complement_string] + properties: + complement_string: + type: string InvoicePending: title: Pendiente allOf: diff --git a/website/test/openapi-types.fixture.txt b/website/test/openapi-types.fixture.txt index 68e44e2e0..61dfa46d5 100644 --- a/website/test/openapi-types.fixture.txt +++ b/website/test/openapi-types.fixture.txt @@ -167,7 +167,7 @@ const createdInvoiceStamp: components["schemas"]["Stamp"] | null | undefined = c void createdInvoiceDate; void createdInvoiceStamp; if (createdInvoiceResponse.status === 'valid' || createdInvoiceResponse.status === 'canceled') { - const stampedFields: [string, string, string, string, string, string | null] = [ + const stampedFields: [string, string, string, string, string, string] = [ createdInvoiceResponse.uuid, createdInvoiceResponse.stamp.date, createdInvoiceResponse.stamp.signature, From 3fc5385d6d0f5b0d0ac19b9864537a2683442f03 Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 15 Oct 2026 17:22:13 +0200 Subject: [PATCH 11/12] docs: clarify product and retention fields and receiver inputs --- website/openapi_v2.en.yaml | 32 +++++++++++++++++++------- website/openapi_v2.yaml | 32 +++++++++++++++++++------- website/scripts/check-openapi.mjs | 23 +++++++++++++++--- website/test/openapi-types.fixture.txt | 3 +++ 4 files changed, 71 insertions(+), 19 deletions(-) diff --git a/website/openapi_v2.en.yaml b/website/openapi_v2.en.yaml index 624984761..c7ff4dc5e 100644 --- a/website/openapi_v2.en.yaml +++ b/website/openapi_v2.en.yaml @@ -15278,7 +15278,7 @@ components: CartaPorteDataInput: allOf: - $ref: "#/components/schemas/CartaPorteDataProperties" - ComercioExteriorDataProperties: + ComercioExteriorCommonProperties: type: object title: ComercioExterior description: Foreign Trade complement version 2.0. @@ -15344,10 +15344,6 @@ components: oneOf: - $ref: "#/components/schemas/ComercioExteriorPropietario" - $ref: "#/components/schemas/CustomerComercioExterior" - Receptor: - anyOf: - - $ref: "#/components/schemas/ComercioExteriorReceptor" - - $ref: "#/components/schemas/CustomerComercioExterior" Destinatario: type: array items: @@ -15356,9 +15352,25 @@ components: - $ref: "#/components/schemas/CustomerComercioExterior" Mercancias: $ref: "#/components/schemas/ComercioExteriorMercancias" + ComercioExteriorDataProperties: + allOf: + - $ref: '#/components/schemas/ComercioExteriorCommonProperties' + properties: + Receptor: + anyOf: + - $ref: "#/components/schemas/ComercioExteriorReceptor" + - $ref: "#/components/schemas/CustomerComercioExterior" ComercioExteriorDataInput: allOf: - - $ref: "#/components/schemas/ComercioExteriorDataProperties" + - $ref: '#/components/schemas/ComercioExteriorCommonProperties' + properties: + Receptor: + anyOf: + - $ref: "#/components/schemas/ComercioExteriorReceptor" + - $ref: "#/components/schemas/CustomerComercioExterior" + - type: string + pattern: '^[a-fA-F0-9]{24}$' + description: ID of an existing customer. ComercioExteriorDomicilio: type: object required: @@ -16775,6 +16787,8 @@ components: properties: unit_name: type: string + maxLength: 20 + description: Word representing the unit of measure of your product. It must be related to the unit key `unit_key`. allOf: - $ref: "#/components/schemas/ProductCommonProperties" required: [description, product_key, price] @@ -16791,6 +16805,8 @@ components: properties: unit_name: type: string + maxLength: 20 + description: Word representing the unit of measure of your product. It must be related to the unit key `unit_key`. allOf: - $ref: "#/components/schemas/ProductCommonProperties" ProductCommonProperties: @@ -19461,7 +19477,7 @@ components: items: type: string format: xml - description: XML code with the Addenda that needs to be added to the invoice. + description: List of XML fragments to include as addendas in the retention document. namespaces: type: array description: Namespaces to insert in the root node of the invoice. Required for `addenda`. @@ -19675,7 +19691,7 @@ components: items: type: string format: xml - description: XML code with the Addenda that needs to be added to the invoice. + description: List of XML fragments to include as addendas in the retention document. namespaces: type: array description: Namespaces to insert in the root node of the invoice. Required for `addenda`. diff --git a/website/openapi_v2.yaml b/website/openapi_v2.yaml index 8fc2302a4..2d4558ca5 100644 --- a/website/openapi_v2.yaml +++ b/website/openapi_v2.yaml @@ -15221,7 +15221,7 @@ components: CartaPorteDataInput: allOf: - $ref: "#/components/schemas/CartaPorteDataProperties" - ComercioExteriorDataProperties: + ComercioExteriorCommonProperties: type: object title: ComercioExterior description: Complemento Comercio Exterior versión 2.0. @@ -15287,10 +15287,6 @@ components: oneOf: - $ref: "#/components/schemas/ComercioExteriorPropietario" - $ref: "#/components/schemas/CustomerComercioExterior" - Receptor: - anyOf: - - $ref: "#/components/schemas/ComercioExteriorReceptor" - - $ref: "#/components/schemas/CustomerComercioExterior" Destinatario: type: array items: @@ -15299,9 +15295,25 @@ components: - $ref: "#/components/schemas/CustomerComercioExterior" Mercancias: $ref: "#/components/schemas/ComercioExteriorMercancias" + ComercioExteriorDataProperties: + allOf: + - $ref: '#/components/schemas/ComercioExteriorCommonProperties' + properties: + Receptor: + anyOf: + - $ref: "#/components/schemas/ComercioExteriorReceptor" + - $ref: "#/components/schemas/CustomerComercioExterior" ComercioExteriorDataInput: allOf: - - $ref: "#/components/schemas/ComercioExteriorDataProperties" + - $ref: '#/components/schemas/ComercioExteriorCommonProperties' + properties: + Receptor: + anyOf: + - $ref: "#/components/schemas/ComercioExteriorReceptor" + - $ref: "#/components/schemas/CustomerComercioExterior" + - type: string + pattern: '^[a-fA-F0-9]{24}$' + description: ID de un cliente existente. ComercioExteriorDomicilio: type: object required: @@ -16770,6 +16782,8 @@ components: properties: unit_name: type: string + maxLength: 20 + description: Palabra que representa la unidad de medida de tu producto. Debe estar relacionada con la clave de unidad `unit_key`. allOf: - $ref: "#/components/schemas/ProductCommonProperties" required: [description, product_key, price] @@ -16786,6 +16800,8 @@ components: properties: unit_name: type: string + maxLength: 20 + description: Palabra que representa la unidad de medida de tu producto. Debe estar relacionada con la clave de unidad `unit_key`. allOf: - $ref: "#/components/schemas/ProductCommonProperties" ProductCommonProperties: @@ -19369,7 +19385,7 @@ components: items: type: string format: xml - description: Código XML con la Addenda que se necesite agregar a la factura. + description: Lista de fragmentos XML que se incluirán como addendas en la retención. namespaces: type: array description: Namespaces a insertar en el nodo raíz de la factura. Requerido en `addenda`. @@ -19576,7 +19592,7 @@ components: items: type: string format: xml - description: Código XML con la Addenda que se necesite agregar a la factura. + description: Lista de fragmentos XML que se incluirán como addendas en la retención. namespaces: type: array description: Namespaces a insertar en el nodo raíz de la factura. Requerido en `addenda`. diff --git a/website/scripts/check-openapi.mjs b/website/scripts/check-openapi.mjs index 265a0f581..398840d22 100644 --- a/website/scripts/check-openapi.mjs +++ b/website/scripts/check-openapi.mjs @@ -40,6 +40,18 @@ function contract(value, key) { for (const filename of ["openapi_v2.yaml", "openapi_v2.en.yaml"]) { // Match the generator parser so unquoted numeric SAT codes fail validation. const spec = yaml.load(await readFile(new URL(filename, root), "utf8")); + const ancestors = new WeakSet(); + const visited = new WeakSet(); + function rejectAnchorCycles(value) { + if (!value || typeof value !== "object") return; + assert(!ancestors.has(value), "Cyclic YAML anchors are not supported"); + if (visited.has(value)) return; + ancestors.add(value); + for (const child of Object.values(value)) rejectAnchorCycles(child); + ancestors.delete(value); + visited.add(value); + } + rejectAnchorCycles(spec); const validateItemComplement = new Ajv2020({ strict: false, validateFormats: false, @@ -78,6 +90,7 @@ for (const filename of ["openapi_v2.yaml", "openapi_v2.en.yaml"]) { validateForeignTradeReceiver({ NumRegIdTrib: "123456" }), "Foreign trade receiver object must not be rejected by overlapping branches", ); + // The sibling components object supplies the local $ref namespace to AJV. for (const [schema, value, expected] of [ ["BaseTax", { type: "IVA", rate: 0.16, base: null }, true], ["LocalTax", { type: "ISH", rate: 0.03, base: null }, true], @@ -101,9 +114,12 @@ for (const filename of ["openapi_v2.yaml", "openapi_v2.en.yaml"]) { assert(!/(InputProperties|RequiredProperties)$/.test(name), `Use an Input schema with its own required fields: ${name}`); for (const [name, body] of Object.entries(spec.components.requestBodies)) { - const reference = body.content?.["application/json"]?.schema?.$ref; - if (reference) assert(reference.endsWith("Input"), - `Request body schemas must end in Input: ${name}`); + for (const media of Object.values(body.content || {})) { + for (const schema of [media.schema, ...(media.schema?.oneOf || []), ...(media.schema?.anyOf || [])]) { + if (schema?.$ref) assert(schema.$ref.endsWith("Input"), + `Named request body schemas must end in Input: ${name}`); + } + } } function walk(value, path = [], references) { if (!value || typeof value !== "object") return; @@ -115,6 +131,7 @@ for (const filename of ["openapi_v2.yaml", "openapi_v2.en.yaml"]) { ...value, components: spec.components, }); + // This bounded check catches empty-object overlap, not every possible overlap. validate({}); assert( !validate.errors?.some( diff --git a/website/test/openapi-types.fixture.txt b/website/test/openapi-types.fixture.txt index 61dfa46d5..17ea3f898 100644 --- a/website/test/openapi-types.fixture.txt +++ b/website/test/openapi-types.fixture.txt @@ -298,3 +298,6 @@ const deletedSeriesResponse: operations['deleteSeriesGroup']['responses'][200][' void [clearedCustomerResponse, emptyRetentionResponse, retentionWithAddenda, createdSeriesResponse, updatedSeriesResponse, deletedSeriesResponse]; const blankCustomerUpdate: components['schemas']['CustomerUpdateInput'] = { email: ' ', phone: '', address: { street: ' ', zip: '77535' } }; void blankCustomerUpdate; + +const foreignTradeCustomerId: NonNullable = '507f1f77bcf86cd799439011'; +void foreignTradeCustomerId; From 3c7c69c3062de4aabbe879f7ab9736855669faaa Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 15 Oct 2026 18:32:09 +0200 Subject: [PATCH 12/12] fix: allow null email in customer responses --- website/openapi_v2.en.yaml | 2 +- website/openapi_v2.yaml | 2 +- website/test/openapi-types.fixture.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/website/openapi_v2.en.yaml b/website/openapi_v2.en.yaml index c7ff4dc5e..24e107210 100644 --- a/website/openapi_v2.en.yaml +++ b/website/openapi_v2.en.yaml @@ -16341,7 +16341,7 @@ components: description: | Required for national clients. Key of the customer's tax regime, from the [Tax Regime Catalog](#tax-regime). email: - type: string + type: [string, 'null'] format: email description: Email address to which to send the generated invoices. example: email@example.com diff --git a/website/openapi_v2.yaml b/website/openapi_v2.yaml index 2d4558ca5..7e0dc190c 100644 --- a/website/openapi_v2.yaml +++ b/website/openapi_v2.yaml @@ -16341,7 +16341,7 @@ components: minLength: 3 description: Requerido para clientes nacionales. Clave del régimen fiscal del cliente, del catálogo de [Regímenes Fiscales](#r%C3%A9gimen-fiscal). email: - type: string + type: [string, 'null'] format: email description: Dirección de correo electrónico al cual enviar las facturas generadas. example: email@example.com diff --git a/website/test/openapi-types.fixture.txt b/website/test/openapi-types.fixture.txt index 17ea3f898..21d386c4f 100644 --- a/website/test/openapi-types.fixture.txt +++ b/website/test/openapi-types.fixture.txt @@ -289,7 +289,7 @@ const seriesWithDefaultFolios: components['schemas']['OrganizationSeriesCreateIn const receiptAddress: components['schemas']['ReceiptProperties']['address'] = { country: 'MEX', state: 'Sonora' }; const invoiceCustomerAddress: components['schemas']['CustomerInfo']['address'] = { city: 'Huatabampo', street: 'Example' }; void [nationalAddressWithDefaultCountry, relatedInvoiceByUuid, seriesWithDefaultFolios, receiptAddress, invoiceCustomerAddress]; -const clearedCustomerResponse: components['schemas']['CustomerProperties'] = { legal_name: null, address: null }; +const clearedCustomerResponse: components['schemas']['CustomerProperties'] = { email: null, legal_name: null, address: null }; const emptyRetentionResponse: components['schemas']['RetentionProperties'] = { fecha_exp: null, cve_retenc: null, periodo: null, totales: null, addenda: [] }; const retentionWithAddenda: components['schemas']['RetentionUpdateInput'] = { addenda: [''] }; const createdSeriesResponse: operations['createSeriesGroup']['responses'][200]['content']['application/json'] = [{ series: 'A', next_folio: 1, next_folio_test: 1 }];