Skip to content
6 changes: 3 additions & 3 deletions src/tools/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -149,7 +149,7 @@ export default class Webhooks {
if (!isValid) {
throw new Error('Invalid signature');
}
return JSON.parse(payloadString) as ApiEvent<T>;
return deserializeResponseDates(JSON.parse(payloadString)) as ApiEvent<T>;
}
}

Expand Down Expand Up @@ -177,7 +177,7 @@ export default class Webhooks {
if (!isValid) {
throw new Error('Invalid signature');
}
return JSON.parse(payloadString) as ApiEvent<T>;
return deserializeResponseDates(JSON.parse(payloadString)) as ApiEvent<T>;
}

// Fallback for runtimes without local crypto support (e.g. some RN setups)
Expand Down
2 changes: 1 addition & 1 deletion src/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export type BinaryInput =
*/
export interface SignedDownloadUrl {
url: string;
expires_at: string;
expires_at: Date;
content_type: string;
filename: string;
}
4 changes: 2 additions & 2 deletions src/types/customer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
6 changes: 4 additions & 2 deletions src/types/invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
20 changes: 12 additions & 8 deletions src/types/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface OrganizationDefaultSeriesUpdateInput {
export interface ApiKeys {
id: string;
first_12: string;
created_at: string;
created_at: Date;
}

export interface OrganizationUserAccess {
Expand All @@ -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 {
Expand All @@ -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<string, any> | null;
updated_by?: Record<string, any> | null;
}
Expand Down Expand Up @@ -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;
}
3 changes: 2 additions & 1 deletion src/types/retention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
49 changes: 48 additions & 1 deletion src/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') || '';
Expand Down Expand Up @@ -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();
};
Expand Down
62 changes: 61 additions & 1 deletion test-d/runtime-types.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
import { expectAssignable, expectType, expectError } from 'tsd'
import Facturapi, {
BinaryDownload,
ApiKeys,
ApiEvent,
CursorSearchParams,
Customer,
PageSearchParams,
FacturapiError,
Invoice,
InvoiceItem,
InvoiceType,
IssuingType,
NodeLikeReadableStream,
Organization,
OrganizationInvite,
OrganizationTeamRole,
OrganizationUserAccess,
PagoComplementData,
Product,
Receipt,
Retention,
SearchResult,
SignedDownloadUrl,
TaxFactor,
Webhook,
ZipRequest,
} from '../dist'

Expand Down Expand Up @@ -46,7 +58,7 @@ expectType<Promise<BinaryDownload>>(

declare const signedDownloadUrl: SignedDownloadUrl
expectType<string>(signedDownloadUrl.url)
expectType<string>(signedDownloadUrl.expires_at)
expectType<Date>(signedDownloadUrl.expires_at)
expectType<string>(signedDownloadUrl.content_type)
expectType<string>(signedDownloadUrl.filename)
expectType<Promise<SignedDownloadUrl>>(
Expand Down Expand Up @@ -109,6 +121,54 @@ expectAssignable<TaxFactor>(TaxFactor.EXENTO)
declare const invoiceItem: InvoiceItem
expectType<string[]>(invoiceItem.property_tax_account)

declare const invoice: Invoice
expectType<Date>(invoice.created_at)
expectType<Date | null>(invoice.date)
expectType<Date | null | undefined>(invoice.canceled_at)
expectType<Date | null | undefined>(invoice.cancellation?.requested_at)
expectType<string | undefined>(invoice.stamp?.date)

declare const receipt: Receipt
expectType<Date>(receipt.created_at)
expectType<Date>(receipt.date)
expectType<Date>(receipt.expires_at)

declare const customer: Customer
expectType<Date>(customer.created_at)
expectType<Date | null | undefined>(customer.sat_validated_at)
expectType<Date | null | undefined>(customer.edit_link_expires_at)
declare const product: Product
expectType<Date>(product.created_at)
declare const organization: Organization
expectType<Date>(organization.created_at)
expectType<Date | null | undefined>(organization.certificate.expires_at)
expectType<Date | undefined>(organization.pending_add_ons_update?.scheduled_for)
declare const webhook: Webhook
expectType<Date>(webhook.created_at)
declare const event: ApiEvent
expectType<Date>(event.created_at)
declare const payment: PagoComplementData
expectType<Date>(payment.date)
declare const zipRequest: ZipRequest
expectType<Date | undefined>(zipRequest.created_at)
expectType<Date | undefined>(zipRequest.updated_at)

declare const retention: Retention
expectType<Date | null>(retention.fecha_exp)
expectType<string | undefined>(retention.stamp?.date)

declare const apiKey: ApiKeys
expectType<Date>(apiKey.created_at)
declare const access: OrganizationUserAccess
expectType<Date>(access.created_at)
expectType<Date>(access.updated_at)
declare const invite: OrganizationInvite
expectType<Date>(invite.created_at)
expectType<Date | null>(invite.expires_at)
declare const role: OrganizationTeamRole
expectType<Date>(role.created_at)
expectType<Date>(role.updated_at)

declare const apiError: FacturapiError
expectType<number>(apiError.status)
expectType<string | undefined>(apiError.code)
Expand Down
Loading
Loading