diff --git a/src/tools/webhooks.ts b/src/tools/webhooks.ts index 66731e1..4e42e4e 100644 --- a/src/tools/webhooks.ts +++ b/src/tools/webhooks.ts @@ -4,7 +4,7 @@ import { ApiEvent, ApiEventType, } from '../types'; -import { WrapperClient } from '../wrapper'; +import { deserializeResponseDates, WrapperClient } from '../wrapper'; function hasBuffer(): boolean { return typeof Buffer !== 'undefined'; @@ -149,7 +149,7 @@ export default class Webhooks { if (!isValid) { throw new Error('Invalid signature'); } - return JSON.parse(payloadString) as ApiEvent; + return deserializeResponseDates(JSON.parse(payloadString)) as ApiEvent; } } @@ -177,7 +177,7 @@ export default class Webhooks { if (!isValid) { throw new Error('Invalid signature'); } - return JSON.parse(payloadString) as ApiEvent; + return deserializeResponseDates(JSON.parse(payloadString)) as ApiEvent; } // Fallback for runtimes without local crypto support (e.g. some RN setups) diff --git a/src/types/common.ts b/src/types/common.ts index c1d01a8..59c4d32 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -152,7 +152,7 @@ export type BinaryInput = */ export interface SignedDownloadUrl { url: string; - expires_at: string; + expires_at: Date; content_type: string; filename: string; } diff --git a/src/types/customer.ts b/src/types/customer.ts index e4aeb67..50bf9c0 100644 --- a/src/types/customer.ts +++ b/src/types/customer.ts @@ -25,7 +25,7 @@ export interface Customer { address: Address; external_id?: string; default_invoice_use?: InvoiceUse; - sat_validated_at?: Date; + sat_validated_at?: Date | null; edit_link?: string; - edit_link_expires_at?: Date; + edit_link_expires_at?: Date | null; } diff --git a/src/types/invoice.ts b/src/types/invoice.ts index 46a9e63..e626df0 100644 --- a/src/types/invoice.ts +++ b/src/types/invoice.ts @@ -35,7 +35,7 @@ export interface Invoice { organization: string; livemode: boolean; created_at: Date; - date: Date; + date: Date | null; issuer_type: IssuingType; type: InvoiceType; status: InvoiceStatus; @@ -58,9 +58,11 @@ export interface Invoice { verification_url?: string | null; verification_carta_porte?: string | null; cancellation_status: CancellationStatus; + canceled_at?: Date | null; external_id?: string | null; idempotency_key?: string | null; stamp?: { + /** SAT FechaTimbrado: Mexico City local time without a timezone offset, not a UTC instant. */ date: string; sat_signature: string; sat_cert_number: string; @@ -75,7 +77,7 @@ export interface Invoice { export?: string | null; global?: GlobalInfo | null; cancellation?: { - requested_at: Date; + requested_at?: Date | null; status: CancellationStatus; last_checked: Date; motive: string; diff --git a/src/types/organization.ts b/src/types/organization.ts index fd90841..d030495 100644 --- a/src/types/organization.ts +++ b/src/types/organization.ts @@ -15,7 +15,7 @@ export interface OrganizationDefaultSeriesUpdateInput { export interface ApiKeys { id: string; first_12: string; - created_at: string; + created_at: Date; } export interface OrganizationUserAccess { @@ -26,19 +26,19 @@ export interface OrganizationUserAccess { role_name: string | null; organization: string; operations: string[]; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } export interface OrganizationInvite { id: string; - created_at: string; + created_at: Date; email: string; organization_name: string; role: string | null; role_name: string | null; roles: string[]; - expires_at: string | null; + expires_at: Date | null; } export interface OrganizationInviteCreateInput { @@ -58,8 +58,8 @@ export interface OrganizationTeamRole { organization: string | null; operations: string[]; used_by: number; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; created_by?: Record | null; updated_by?: Record | null; } @@ -165,5 +165,9 @@ export interface Organization { pending_plan_update: { plan: string; scheduled_for: Date; - }; + } | null; + pending_add_ons_update?: { + add_ons: string[]; + scheduled_for: Date; + } | null; } diff --git a/src/types/retention.ts b/src/types/retention.ts index 4d94233..ccc5e77 100644 --- a/src/types/retention.ts +++ b/src/types/retention.ts @@ -9,7 +9,7 @@ export interface Retention { status: InvoiceStatus; uuid: string; external_id?: string; - fecha_exp: Date; + fecha_exp: Date | null; cve_retenc: string; folio_int?: string; desc_retenc?: string; @@ -37,6 +37,7 @@ export interface Retention { addenda?: string[]; cancellation_receipt?: string; stamp?: { + /** SAT FechaTimbrado: Mexico City local time without a timezone offset, not a UTC instant. */ date: string; sat_signature: string; sat_cert_number: string; diff --git a/src/wrapper.ts b/src/wrapper.ts index 6dc4070..3167c95 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -154,6 +154,53 @@ const statusFrom = (value: unknown, fallback: number): number => { return fallback; }; +// Keep this list aligned with date-valued response fields in src/types. +// Input-only payroll fields and SAT wall-clock strings are intentionally absent. +const responseDateFields = new Set([ + 'canceled_at', + 'created_at', + 'date', + 'edit_link_expires_at', + 'expires_at', + 'fecha_exp', + 'last_checked', + 'requested_at', + 'sat_validated_at', + 'scheduled_for', + 'updated_at', +]); + +// Only hydrate ISO dates; unrelated free-form strings may share these keys. +const isoDatePattern = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}))?$/; + +export const deserializeResponseDates = (value: any): any => { + if (Array.isArray(value)) { + return value.map(deserializeResponseDates); + } + if (!value || typeof value !== 'object' || !isPlainRecord(value)) { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => { + // These contain caller-owned values or SAT wall-clock text, not instants. + if (key === 'metadata' || key === 'stamp') { + return [key, nestedValue]; + } + if ( + responseDateFields.has(key) && + typeof nestedValue === 'string' && + isoDatePattern.test(nestedValue) + ) { + const date = new Date(nestedValue); + if (!Number.isNaN(date.getTime())) { + return [key, date]; + } + } + return [key, deserializeResponseDates(nestedValue)]; + }), + ); +}; + const responseInterceptor = async (response: Response) => { if (!response.ok) { const contentType = response.headers.get('content-type') || ''; @@ -239,7 +286,7 @@ const responseInterceptor = async (response: Response) => { return response.blob(); } } else if (contentType.includes('application/json')) { - return response.json(); + return deserializeResponseDates(await response.json()); } return response.text(); }; diff --git a/test-d/runtime-types.test-d.ts b/test-d/runtime-types.test-d.ts index bd5ab13..74fb621 100644 --- a/test-d/runtime-types.test-d.ts +++ b/test-d/runtime-types.test-d.ts @@ -1,7 +1,10 @@ import { expectAssignable, expectType, expectError } from 'tsd' import Facturapi, { BinaryDownload, + ApiKeys, + ApiEvent, CursorSearchParams, + Customer, PageSearchParams, FacturapiError, Invoice, @@ -9,9 +12,18 @@ import Facturapi, { InvoiceType, IssuingType, NodeLikeReadableStream, + Organization, + OrganizationInvite, + OrganizationTeamRole, + OrganizationUserAccess, + PagoComplementData, + Product, + Receipt, + Retention, SearchResult, SignedDownloadUrl, TaxFactor, + Webhook, ZipRequest, } from '../dist' @@ -46,7 +58,7 @@ expectType>( declare const signedDownloadUrl: SignedDownloadUrl expectType(signedDownloadUrl.url) -expectType(signedDownloadUrl.expires_at) +expectType(signedDownloadUrl.expires_at) expectType(signedDownloadUrl.content_type) expectType(signedDownloadUrl.filename) expectType>( @@ -109,6 +121,54 @@ expectAssignable(TaxFactor.EXENTO) declare const invoiceItem: InvoiceItem expectType(invoiceItem.property_tax_account) +declare const invoice: Invoice +expectType(invoice.created_at) +expectType(invoice.date) +expectType(invoice.canceled_at) +expectType(invoice.cancellation?.requested_at) +expectType(invoice.stamp?.date) + +declare const receipt: Receipt +expectType(receipt.created_at) +expectType(receipt.date) +expectType(receipt.expires_at) + +declare const customer: Customer +expectType(customer.created_at) +expectType(customer.sat_validated_at) +expectType(customer.edit_link_expires_at) +declare const product: Product +expectType(product.created_at) +declare const organization: Organization +expectType(organization.created_at) +expectType(organization.certificate.expires_at) +expectType(organization.pending_add_ons_update?.scheduled_for) +declare const webhook: Webhook +expectType(webhook.created_at) +declare const event: ApiEvent +expectType(event.created_at) +declare const payment: PagoComplementData +expectType(payment.date) +declare const zipRequest: ZipRequest +expectType(zipRequest.created_at) +expectType(zipRequest.updated_at) + +declare const retention: Retention +expectType(retention.fecha_exp) +expectType(retention.stamp?.date) + +declare const apiKey: ApiKeys +expectType(apiKey.created_at) +declare const access: OrganizationUserAccess +expectType(access.created_at) +expectType(access.updated_at) +declare const invite: OrganizationInvite +expectType(invite.created_at) +expectType(invite.expires_at) +declare const role: OrganizationTeamRole +expectType(role.created_at) +expectType(role.updated_at) + declare const apiError: FacturapiError expectType(apiError.status) expectType(apiError.code) diff --git a/test/node/runtime-compat.node.test.ts b/test/node/runtime-compat.node.test.ts index ab7f622..8f72afb 100644 --- a/test/node/runtime-compat.node.test.ts +++ b/test/node/runtime-compat.node.test.ts @@ -94,6 +94,19 @@ describe('runtime compatibility (node)', () => { JSON.stringify({ id: 'inv_123', object: 'invoice', + created_at: '2026-09-17T12:00:00.000Z', + date: '2026-09-17T11:00:00.000Z', + canceled_at: '2026-09-17T12:59:16.000Z', + cancellation: { + requested_at: '2026-09-17T12:59:16.000Z', + last_checked: '2026-09-17T13:00:00.000Z', + }, + stamp: { + date: '2026-09-17T06:59:16', + }, + metadata: { + date: '2026-09-17T12:00:00.000Z', + }, }), { status: 200, @@ -104,6 +117,17 @@ describe('runtime compatibility (node)', () => { const invoice = await client.invoices.retrieve('inv_123') expect(invoice.id).toBe('inv_123') + expect(invoice.created_at).toEqual(new Date('2026-09-17T12:00:00.000Z')) + expect(invoice.date).toEqual(new Date('2026-09-17T11:00:00.000Z')) + expect(invoice.canceled_at).toEqual(new Date('2026-09-17T12:59:16.000Z')) + expect(invoice.cancellation?.requested_at).toEqual( + new Date('2026-09-17T12:59:16.000Z'), + ) + expect(invoice.cancellation?.last_checked).toEqual( + new Date('2026-09-17T13:00:00.000Z'), + ) + expect(invoice.stamp?.date).toBe('2026-09-17T06:59:16') + expect((invoice as any).metadata.date).toBe('2026-09-17T12:00:00.000Z') }) it('checks domain availability via GET query params', async () => { @@ -131,6 +155,99 @@ describe('runtime compatibility (node)', () => { expect(result.available).toBe(true) }) + it('hydrates organization access, invite, role, and API key timestamps', async () => { + const client = createClient() + const timestamp = '2026-09-17T12:00:00.000Z' + globalThis.fetch = vi.fn(async (url) => + Response.json( + String(url).endsWith('/team') + ? [{ id: 'access_123', created_at: timestamp, updated_at: timestamp }] + : String(url).endsWith('/team/invites') + ? [{ id: 'invite_123', created_at: timestamp, expires_at: timestamp }] + : String(url).endsWith('/team/roles') + ? [{ id: 'role_123', created_at: timestamp, updated_at: timestamp }] + : [{ id: 'key_123', created_at: timestamp }], + ), + ) as typeof fetch + + const access = await client.organizations.listTeamAccess('org_123') + expect(access[0].created_at).toEqual(new Date(timestamp)) + expect(access[0].updated_at).toEqual(new Date(timestamp)) + const invites = await client.organizations.listSentTeamInvites('org_123') + expect(invites[0].created_at).toEqual(new Date(timestamp)) + expect(invites[0].expires_at).toEqual(new Date(timestamp)) + const roles = await client.organizations.listTeamRoles('org_123') + expect(roles[0].created_at).toEqual(new Date(timestamp)) + expect(roles[0].updated_at).toEqual(new Date(timestamp)) + expect((await client.organizations.listLiveApiKeys('org_123'))[0].created_at) + .toEqual(new Date(timestamp)) + }) + + it('hydrates dates across resource responses without changing SAT stamp text', async () => { + const client = createClient() + const timestamp = '2026-09-17T12:00:00.000Z' + const responses: Record = { + '/v2/customers/cus_123': { + created_at: timestamp, + sat_validated_at: timestamp, + edit_link_expires_at: timestamp, + }, + '/v2/products/prod_123': { created_at: timestamp }, + '/v2/receipts/rec_123': { + created_at: timestamp, + date: timestamp, + expires_at: timestamp, + }, + '/v2/retentions/ret_123': { + created_at: timestamp, + fecha_exp: timestamp, + stamp: { date: '2026-09-17T06:00:00' }, + }, + '/v2/organizations/me': { + created_at: timestamp, + certificate: { updated_at: timestamp, expires_at: timestamp }, + pending_add_ons_update: { add_ons: [], scheduled_for: timestamp }, + }, + '/v2/webhooks/wh_123': { created_at: timestamp }, + '/v2/invoices/zip-requests/zip_123': { + created_at: timestamp, + updated_at: timestamp, + }, + } + globalThis.fetch = vi.fn(async (url) => + Response.json(responses[new URL(String(url)).pathname]), + ) as typeof fetch + + const customer = await client.customers.retrieve('cus_123') + expect(customer.created_at).toEqual(new Date(timestamp)) + expect(customer.sat_validated_at).toEqual(new Date(timestamp)) + expect(customer.edit_link_expires_at).toEqual(new Date(timestamp)) + expect((await client.products.retrieve('prod_123')).created_at).toEqual( + new Date(timestamp), + ) + const receipt = await client.receipts.retrieve('rec_123') + expect(receipt.created_at).toEqual(new Date(timestamp)) + expect(receipt.date).toEqual(new Date(timestamp)) + expect(receipt.expires_at).toEqual(new Date(timestamp)) + const retention = await client.retentions.retrieve('ret_123') + expect(retention.created_at).toEqual(new Date(timestamp)) + expect(retention.fecha_exp).toEqual(new Date(timestamp)) + expect(retention.stamp?.date).toBe('2026-09-17T06:00:00') + const organization = await client.organizations.me() + expect(organization.created_at).toEqual(new Date(timestamp)) + expect(organization.certificate.updated_at).toEqual(new Date(timestamp)) + expect(organization.certificate.expires_at).toEqual(new Date(timestamp)) + expect(organization.pending_add_ons_update?.scheduled_for).toEqual( + new Date(timestamp), + ) + expect((await client.webhooks.retrieve('wh_123')).created_at).toEqual( + new Date(timestamp), + ) + const zipRequest = await client.invoices.retrieveZipRequest('zip_123') + expect(zipRequest.created_at).toEqual(new Date(timestamp)) + expect(zipRequest.updated_at).toEqual(new Date(timestamp)) + }) + it('posts multiple receipts to invoice payload to receipts endpoint', async () => { const client = createClient() const payload = { @@ -444,6 +561,37 @@ describe('runtime compatibility (node)', () => { ).rejects.toThrow('Invalid signature') }) + it('hydrates dates in locally validated webhook events', async () => { + const client = createClient() + const secret = 'whsec_test_dates' + const payload = JSON.stringify({ + created_at: '2026-09-17T12:00:00.000Z', + data: { + type: 'invoice', + object: { + created_at: '2026-09-17T11:00:00.000Z', + cancellation: { requested_at: '2026-09-17T12:59:16.000Z' }, + stamp: { date: '2026-09-17T06:59:16' }, + }, + }, + }) + + const event = await client.webhooks.validateSignature({ + secret, + signature: crypto.createHmac('sha256', secret).update(payload).digest('hex'), + payload, + }) + + expect(event.created_at).toEqual(new Date('2026-09-17T12:00:00.000Z')) + expect(event.data.object.created_at).toEqual( + new Date('2026-09-17T11:00:00.000Z'), + ) + expect(event.data.object.cancellation?.requested_at).toEqual( + new Date('2026-09-17T12:59:16.000Z'), + ) + expect(event.data.object.stamp?.date).toBe('2026-09-17T06:59:16') + }) + it('falls back to API validation when local crypto is unavailable', async () => { const client = createClient() const originalBuffer = (globalThis as any).Buffer diff --git a/test/node/signed-download-urls.node.test.ts b/test/node/signed-download-urls.node.test.ts index 510bbff..92282cf 100644 --- a/test/node/signed-download-urls.node.test.ts +++ b/test/node/signed-download-urls.node.test.ts @@ -59,6 +59,7 @@ describe('signed download URLs', () => { expect.objectContaining({ content_type: contentType, filename: `invoice.${format}`, + expires_at: new Date('2030-01-01T00:00:00.000Z'), }), ) }, diff --git a/test/web/runtime-compat.web.test.ts b/test/web/runtime-compat.web.test.ts index 9d928c2..4b7d825 100644 --- a/test/web/runtime-compat.web.test.ts +++ b/test/web/runtime-compat.web.test.ts @@ -51,7 +51,7 @@ describe('runtime compatibility (web simulation)', () => { }, }, async json() { - return { id: 'inv_123' } + return { id: 'inv_123', created_at: '2026-09-17T12:00:00.000Z' } }, async text() { return '' @@ -59,7 +59,9 @@ describe('runtime compatibility (web simulation)', () => { } as unknown as Response }) as typeof fetch - await client.invoices.retrieve('inv_123') + expect((await client.invoices.retrieve('inv_123')).created_at).toEqual( + new Date('2026-09-17T12:00:00.000Z'), + ) }) it('sends custom headers in web-like runtime', async () => {