@keysoftinc/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KEYSOFT INC (Softzone Systems)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # @keysoftinc/sdk
2
+
3
+ Facturación electrónica SUNAT desde TypeScript. **Sin dependencias.**
4
+
5
+ ```bash
6
+ npm install @keysoftinc/sdk
7
+ ```
8
+
9
+ ## De cero a una factura aceptada
10
+
11
+ ```ts
12
+ import { KeySoft } from '@keysoftinc/sdk'
13
+
14
+ const keysoft = new KeySoft(process.env.KEYSOFT_API_KEY!)
15
+
16
+ // El ambiente sale de la clave: ks_test_ es pruebas, ks_live_ es producción.
17
+ console.log(keysoft.environment) // 'DEMO'
18
+
19
+ const { document } = await keysoft.issue(
20
+ companyId,
21
+ {
22
+ documentType: 'FACTURA',
23
+ series: 'F001',
24
+ customer: {
25
+ identityType: 'RUC',
26
+ identityValue: '20601234567',
27
+ legalName: 'DISTRIBUIDORA DEL NORTE S.A.C.',
28
+ email: 'compras@cliente.pe', // le llega su factura al aceptarse
29
+ },
30
+ lines: [{ description: 'Servicio de consultoría', quantity: 2, unitValue: 150 }],
31
+ },
32
+ { idempotencyKey: `pedido-${pedido.id}` },
33
+ )
34
+
35
+ const { cdr } = await keysoft.send(companyId, document.id)
36
+ console.log(cdr?.description) // "La Factura numero F001-00000001, ha sido aceptada"
37
+ ```
38
+
39
+ ## Idempotencia
40
+
41
+ `issue()` **manda `Idempotency-Key` por defecto**. Si tu proceso reintenta por un
42
+ timeout, sin ella emitirías **dos facturas** — y una factura de más es un problema
43
+ fiscal, no un duplicado inocente.
44
+
45
+ Pásale la tuya: el id de tu pedido es la mejor opción, porque sobrevive a un
46
+ reinicio de tu proceso.
47
+
48
+ ## Los errores dicen qué hacer
49
+
50
+ ```ts
51
+ import { KeySoftError } from '@keysoftinc/sdk'
52
+
53
+ try {
54
+ await keysoft.issue(companyId, factura)
55
+ } catch (e) {
56
+ if (e instanceof KeySoftError) {
57
+ console.error(e.code) // 'certificate_missing' — estable, programa contra él
58
+ console.error(e.message) // en castellano
59
+ console.error(e.action) // qué hacer para arreglarlo
60
+ if (e.retryable) await reintentar()
61
+ }
62
+ }
63
+ ```
64
+
65
+ ## Descargar y verificar
66
+
67
+ `download()` **recalcula el SHA-256 y lanza si no cuadra**. No te fíes de nosotros:
68
+ la comprobación es el producto.
69
+
70
+ ```ts
71
+ const { content } = await keysoft.download(companyId, document.id, 'xml')
72
+ ```
73
+
74
+ ## Boletas: el reloj de los 7 días
75
+
76
+ Una boleta no se envía sola — va en el resumen diario, y **caduca a los 7 días
77
+ calendario** sin que nadie te avise. Por eso `summaries()` te dice cuántos quedan.
78
+
79
+ ```ts
80
+ const { pending, alert } = await keysoft.summaries(companyId)
81
+ if (alert.level !== 'ok') console.warn(alert.message)
82
+ // "Quedan 2 día(s) para informar las boletas más antiguas sin resumen."
83
+ ```
84
+
85
+ ## Webhooks
86
+
87
+ ```ts
88
+ import { verifyWebhook } from '@keysoftinc/sdk'
89
+
90
+ app.post(
91
+ '/hooks/keysoft',
92
+ express.raw({ type: 'application/json' }),
93
+ async (req, res) => {
94
+ const ok = await verifyWebhook({
95
+ body: req.body.toString(), // el cuerpo CRUDO
96
+ signature: req.get('keysoft-signature')!,
97
+ secret: process.env.KEYSOFT_WEBHOOK_SECRET!,
98
+ })
99
+ if (!ok) return res.status(400).end()
100
+
101
+ const { event, data } = JSON.parse(req.body.toString())
102
+ res.status(200).end()
103
+ },
104
+ )
105
+ ```
106
+
107
+ **El cuerpo tiene que ser el crudo.** Si lo parseas y lo vuelves a serializar la
108
+ firma deja de cuadrar: es el fallo más común al integrarlo.
109
+
110
+ La firma **caduca a los 5 minutos**, que es lo que impide que quien capture una
111
+ petición válida te la reenvíe mañana.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * SDK de TypeScript para la API de KeySoft (proceso P15).
3
+ *
4
+ * **Sin dependencias.** Usa `fetch`, que está en Node 18+ y en cualquier navegador,
5
+ * porque un SDK de facturación que arrastra medio `node_modules` es un SDK que
6
+ * nadie quiere meter en su servidor de producción.
7
+ *
8
+ * Lo que hace por ti y no se ve:
9
+ *
10
+ * · Manda `Idempotency-Key` **por defecto** al emitir. Es la protección que más
11
+ * falta hace y la que más se olvida.
12
+ * · Convierte los errores de la API en `KeySoftError`, con el código estable y
13
+ * **qué hacer** — no en un `Error: Request failed with status code 422`.
14
+ * · **Comprueba el hash** de cada fichero que descargas. No te fíes de nosotros.
15
+ */
16
+ export type Environment = 'DEMO' | 'PRODUCTION';
17
+ export type DocumentType = 'FACTURA' | 'BOLETA' | 'NOTA_CREDITO' | 'NOTA_DEBITO';
18
+ export type IdentityType = 'RUC' | 'DNI' | 'CE' | 'PASAPORTE' | 'OTROS';
19
+ export type IgvType = 'GRAVADO_ONEROSA' | 'GRAVADO_GRATUITA' | 'EXONERADO_ONEROSA' | 'EXONERADO_GRATUITA' | 'INAFECTO_ONEROSA' | 'INAFECTO_GRATUITA' | 'EXPORTACION';
20
+ /** Un importe. Manda cadena si te preocupa la precisión de los flotantes. */
21
+ export type Amount = number | string;
22
+ /**
23
+ * Una línea. El precio va de una de dos formas, y solo de una: `unitValue`, sin IGV,
24
+ * o `unitPrice`, con IGV —el del letrero—. Con `unitPrice` el total de la línea
25
+ * sale exacto (cantidad × precio) y el IGV, por diferencia.
26
+ */
27
+ export type Line = {
28
+ description: string;
29
+ quantity: Amount;
30
+ unitCode?: string;
31
+ igvType?: IgvType;
32
+ discount?: Amount;
33
+ sku?: string;
34
+ sunatCode?: string;
35
+ /**
36
+ * Bolsas de plástico: suma el impuesto por bolsa (ICBPER, S/ 0.50 desde 2023)
37
+ * aparte del precio. En soles, bolsas enteras y con precio.
38
+ */
39
+ icbper?: boolean;
40
+ } & ({
41
+ unitValue: Amount;
42
+ unitPrice?: never;
43
+ } | {
44
+ unitPrice: Amount;
45
+ unitValue?: never;
46
+ });
47
+ export type NewDocument = {
48
+ documentType: DocumentType;
49
+ series: string;
50
+ customer: {
51
+ identityType: IdentityType;
52
+ identityValue: string;
53
+ legalName: string;
54
+ address?: string;
55
+ /** Si lo mandas, al comprador le llega su factura cuando SUNAT la acepte. */
56
+ email?: string;
57
+ };
58
+ lines: Line[];
59
+ issueDate?: string;
60
+ dueDate?: string;
61
+ currency?: 'PEN' | 'USD' | 'EUR';
62
+ payment?: {
63
+ method: 'CONTADO' | 'CREDITO';
64
+ installments?: {
65
+ amount: Amount;
66
+ dueDate: string;
67
+ }[];
68
+ };
69
+ note?: {
70
+ type: string;
71
+ reason: string;
72
+ affectedDocumentId: string;
73
+ };
74
+ };
75
+ export type DocumentView = {
76
+ id: string;
77
+ fullNumber: string;
78
+ documentType: DocumentType;
79
+ status: string;
80
+ environment: Environment;
81
+ issueDate: string;
82
+ totals: Record<string, string>;
83
+ sunat: {
84
+ code: string | null;
85
+ description: string | null;
86
+ notes: string[];
87
+ acceptedAt: string | null;
88
+ };
89
+ };
90
+ /**
91
+ * Un error de la API, con lo que hace falta para reaccionar.
92
+ *
93
+ * `code` es estable: puedes programar contra él. `action` dice qué hacer, y en la
94
+ * mayoría de los casos se le puede enseñar al usuario tal cual.
95
+ */
96
+ export declare class KeySoftError extends Error {
97
+ readonly status: number;
98
+ readonly code: string;
99
+ readonly action?: string;
100
+ readonly details?: unknown;
101
+ constructor(args: {
102
+ status: number;
103
+ code: string;
104
+ message: string;
105
+ action?: string;
106
+ details?: unknown;
107
+ });
108
+ /** ¿Reintentar tiene sentido? Un 422 no se arregla insistiendo. */
109
+ get retryable(): boolean;
110
+ }
111
+ export type KeySoftOptions = {
112
+ apiKey: string;
113
+ /** Por defecto, la API pública. */
114
+ baseUrl?: string;
115
+ /** Milisegundos. Por defecto 30 s: SUNAT puede tardar. */
116
+ timeoutMs?: number;
117
+ };
118
+ export declare class KeySoft {
119
+ #private;
120
+ constructor(options: KeySoftOptions | string);
121
+ /** El ambiente sale de la clave, no de una opción: `ks_live_` es producción. */
122
+ get environment(): Environment;
123
+ /**
124
+ * Emite un comprobante.
125
+ *
126
+ * **Manda `Idempotency-Key` por defecto.** Si tu proceso reintenta por un
127
+ * timeout, sin ella emitirías dos facturas — y una factura de más es un problema
128
+ * fiscal, no un duplicado inocente. Pasa la tuya (el id de tu pedido es ideal) o
129
+ * deja que genere una.
130
+ */
131
+ issue(companyId: string, document: NewDocument, options?: {
132
+ idempotencyKey?: string;
133
+ }): Promise<{
134
+ document: DocumentView;
135
+ file: {
136
+ name: string;
137
+ sha256: string;
138
+ sizeBytes: number;
139
+ };
140
+ idempotent: boolean;
141
+ quota?: {
142
+ level: string;
143
+ message: string;
144
+ };
145
+ }>;
146
+ /** Envía a SUNAT y devuelve el CDR. Una boleta va por resumen diario. */
147
+ send(companyId: string, documentId: string): Promise<{
148
+ document: DocumentView;
149
+ cdr: {
150
+ code: string;
151
+ description: string;
152
+ notes: string[];
153
+ hasFiscalValidity: boolean;
154
+ } | null;
155
+ message: string;
156
+ action?: string;
157
+ alreadyResolved: boolean;
158
+ }>;
159
+ /**
160
+ * Descarga un fichero **y comprueba su hash**.
161
+ *
162
+ * El XML y el CDR vienen con su SHA-256 declarado; este método lo recalcula y
163
+ * lanza si no cuadra. **No te fíes de nosotros:** la comprobación es el producto.
164
+ */
165
+ download(companyId: string, documentId: string, kind: 'xml' | 'cdr' | 'pdf'): Promise<{
166
+ content: Uint8Array;
167
+ sha256: string | null;
168
+ }>;
169
+ /** Entrega el comprobante al comprador por correo. */
170
+ deliver(companyId: string, documentId: string, options?: {
171
+ email?: string;
172
+ resend?: boolean;
173
+ }): Promise<{
174
+ delivered: boolean;
175
+ alreadyDelivered: boolean;
176
+ message: string;
177
+ }>;
178
+ /** Los resúmenes y **cuántos días quedan** para informar cada día de boletas. */
179
+ summaries(companyId: string): Promise<{
180
+ summaries: unknown[];
181
+ pending: {
182
+ referenceDate: string;
183
+ documents: number;
184
+ daysLeft: number;
185
+ expired: boolean;
186
+ }[];
187
+ alert: {
188
+ level: "ok" | "urgent" | "expired";
189
+ message: string;
190
+ };
191
+ }>;
192
+ createSummary(companyId: string, referenceDate?: string): Promise<{
193
+ summary: {
194
+ id: string;
195
+ identifier: string;
196
+ };
197
+ documents: number;
198
+ }>;
199
+ /** Devuelve un **ticket**, no un veredicto. Las boletas siguen sin informar. */
200
+ sendSummary(companyId: string, summaryId: string): Promise<{
201
+ ticket: string | null;
202
+ informed: boolean;
203
+ message: string;
204
+ }>;
205
+ /** Consulta el ticket y propaga el resultado a cada boleta. */
206
+ summaryStatus(companyId: string, summaryId: string): Promise<{
207
+ processing: boolean;
208
+ informed: boolean;
209
+ propagatedTo: number;
210
+ message: string;
211
+ }>;
212
+ /** Tu consumo del mes. **`emissionBlocked` es siempre `false`.** */
213
+ usage(period?: string): Promise<{
214
+ period: string;
215
+ plan: {
216
+ id: string;
217
+ name: string;
218
+ included: number | null;
219
+ };
220
+ usage: {
221
+ billable: number;
222
+ rejected: number;
223
+ remaining: number;
224
+ overage: number;
225
+ overageCost: number | null;
226
+ };
227
+ emissionBlocked: false;
228
+ alert?: {
229
+ level: string;
230
+ message: string;
231
+ };
232
+ rules: {
233
+ id: string;
234
+ rule: string;
235
+ detail: string;
236
+ }[];
237
+ }>;
238
+ }
239
+ /**
240
+ * Verifica la firma de un webhook. **Esto corre en tu servidor.**
241
+ *
242
+ * La firma **caduca a los 5 minutos**, y eso es lo que impide que quien capture una
243
+ * petición válida te la reenvíe mañana. El HMAC va sobre `epoch.cuerpo`, así que la
244
+ * marca de tiempo no se puede cambiar sin romper la firma.
245
+ *
246
+ * @param body El cuerpo **crudo**, tal como llegó. Si lo parseas y lo vuelves a
247
+ * serializar, la firma deja de cuadrar — es el fallo más común al integrarlo.
248
+ */
249
+ export declare function verifyWebhook(args: {
250
+ body: string;
251
+ signature: string;
252
+ secret: string;
253
+ toleranceSeconds?: number;
254
+ }): Promise<boolean>;
package/dist/index.js ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * SDK de TypeScript para la API de KeySoft (proceso P15).
3
+ *
4
+ * **Sin dependencias.** Usa `fetch`, que está en Node 18+ y en cualquier navegador,
5
+ * porque un SDK de facturación que arrastra medio `node_modules` es un SDK que
6
+ * nadie quiere meter en su servidor de producción.
7
+ *
8
+ * Lo que hace por ti y no se ve:
9
+ *
10
+ * · Manda `Idempotency-Key` **por defecto** al emitir. Es la protección que más
11
+ * falta hace y la que más se olvida.
12
+ * · Convierte los errores de la API en `KeySoftError`, con el código estable y
13
+ * **qué hacer** — no en un `Error: Request failed with status code 422`.
14
+ * · **Comprueba el hash** de cada fichero que descargas. No te fíes de nosotros.
15
+ */
16
+ /**
17
+ * Un error de la API, con lo que hace falta para reaccionar.
18
+ *
19
+ * `code` es estable: puedes programar contra él. `action` dice qué hacer, y en la
20
+ * mayoría de los casos se le puede enseñar al usuario tal cual.
21
+ */
22
+ export class KeySoftError extends Error {
23
+ status;
24
+ code;
25
+ action;
26
+ details;
27
+ constructor(args) {
28
+ super(args.message);
29
+ this.name = 'KeySoftError';
30
+ this.status = args.status;
31
+ this.code = args.code;
32
+ this.action = args.action;
33
+ this.details = args.details;
34
+ }
35
+ /** ¿Reintentar tiene sentido? Un 422 no se arregla insistiendo. */
36
+ get retryable() {
37
+ return this.status === 429 || this.status >= 500;
38
+ }
39
+ }
40
+ export class KeySoft {
41
+ #apiKey;
42
+ #baseUrl;
43
+ #timeoutMs;
44
+ constructor(options) {
45
+ const o = typeof options === 'string' ? { apiKey: options } : options;
46
+ this.#apiKey = o.apiKey;
47
+ this.#baseUrl = (o.baseUrl ?? 'https://api.keysoft.pe').replace(/\/+$/, '');
48
+ this.#timeoutMs = o.timeoutMs ?? 30_000;
49
+ }
50
+ /** El ambiente sale de la clave, no de una opción: `ks_live_` es producción. */
51
+ get environment() {
52
+ return this.#apiKey.startsWith('ks_live_') ? 'PRODUCTION' : 'DEMO';
53
+ }
54
+ async #request(method, path, body, headers = {}) {
55
+ let respuesta;
56
+ try {
57
+ respuesta = await fetch(this.#baseUrl + '/api/v1' + path, {
58
+ method,
59
+ headers: {
60
+ authorization: 'Bearer ' + this.#apiKey,
61
+ ...(body ? { 'content-type': 'application/json' } : {}),
62
+ ...headers,
63
+ },
64
+ ...(body ? { body: JSON.stringify(body) } : {}),
65
+ signal: AbortSignal.timeout(this.#timeoutMs),
66
+ });
67
+ }
68
+ catch (cause) {
69
+ throw new KeySoftError({
70
+ status: 503,
71
+ code: 'network_error',
72
+ message: 'No se pudo contactar con KeySoft: ' +
73
+ (cause instanceof Error ? cause.message : String(cause)),
74
+ action: 'Reintenta en unos segundos.',
75
+ });
76
+ }
77
+ const cuerpo = await respuesta.json().catch(() => null);
78
+ if (!respuesta.ok) {
79
+ const e = cuerpo?.error;
80
+ throw new KeySoftError({
81
+ status: respuesta.status,
82
+ code: e?.code ?? 'unknown_error',
83
+ message: e?.message ?? 'KeySoft devolvió ' + respuesta.status + '.',
84
+ action: e?.action,
85
+ details: e?.details,
86
+ });
87
+ }
88
+ return cuerpo;
89
+ }
90
+ // ─────────────────────────────────────────────── Comprobantes
91
+ /**
92
+ * Emite un comprobante.
93
+ *
94
+ * **Manda `Idempotency-Key` por defecto.** Si tu proceso reintenta por un
95
+ * timeout, sin ella emitirías dos facturas — y una factura de más es un problema
96
+ * fiscal, no un duplicado inocente. Pasa la tuya (el id de tu pedido es ideal) o
97
+ * deja que genere una.
98
+ */
99
+ async issue(companyId, document, options = {}) {
100
+ return this.#request('POST', '/companies/' + companyId + '/documents', document, {
101
+ 'idempotency-key': options.idempotencyKey ?? crypto.randomUUID(),
102
+ });
103
+ }
104
+ /** Envía a SUNAT y devuelve el CDR. Una boleta va por resumen diario. */
105
+ async send(companyId, documentId) {
106
+ return this.#request('POST', '/companies/' + companyId + '/documents/' + documentId + '/send');
107
+ }
108
+ /**
109
+ * Descarga un fichero **y comprueba su hash**.
110
+ *
111
+ * El XML y el CDR vienen con su SHA-256 declarado; este método lo recalcula y
112
+ * lanza si no cuadra. **No te fíes de nosotros:** la comprobación es el producto.
113
+ */
114
+ async download(companyId, documentId, kind) {
115
+ const url = this.#baseUrl +
116
+ '/api/v1/companies/' +
117
+ companyId +
118
+ '/documents/' +
119
+ documentId +
120
+ '/files/' +
121
+ kind;
122
+ const respuesta = await fetch(url, {
123
+ headers: { authorization: 'Bearer ' + this.#apiKey },
124
+ signal: AbortSignal.timeout(this.#timeoutMs),
125
+ });
126
+ if (!respuesta.ok) {
127
+ throw new KeySoftError({
128
+ status: respuesta.status,
129
+ code: 'download_failed',
130
+ message: 'No se pudo descargar el ' + kind + ': ' + respuesta.status + '.',
131
+ });
132
+ }
133
+ const content = new Uint8Array(await respuesta.arrayBuffer());
134
+ const declarado = respuesta.headers.get('x-keysoft-sha256');
135
+ if (declarado) {
136
+ const digest = await crypto.subtle.digest('SHA-256', content);
137
+ const calculado = [...new Uint8Array(digest)]
138
+ .map((b) => b.toString(16).padStart(2, '0'))
139
+ .join('');
140
+ if (calculado !== declarado) {
141
+ throw new KeySoftError({
142
+ status: 500,
143
+ code: 'hash_mismatch',
144
+ message: 'El ' + kind + ' descargado NO coincide con su hash declarado. No lo uses.',
145
+ action: 'Vuelve a descargarlo. Si persiste, escríbenos: es un problema serio.',
146
+ });
147
+ }
148
+ }
149
+ return { content, sha256: declarado };
150
+ }
151
+ /** Entrega el comprobante al comprador por correo. */
152
+ async deliver(companyId, documentId, options = {}) {
153
+ return this.#request('POST', '/companies/' + companyId + '/documents/' + documentId + '/deliver', options);
154
+ }
155
+ // ─────────────────────────────────────────────── Boletas
156
+ /** Los resúmenes y **cuántos días quedan** para informar cada día de boletas. */
157
+ async summaries(companyId) {
158
+ return this.#request('GET', '/companies/' + companyId + '/summaries');
159
+ }
160
+ async createSummary(companyId, referenceDate) {
161
+ return this.#request('POST', '/companies/' + companyId + '/summaries', { referenceDate });
162
+ }
163
+ /** Devuelve un **ticket**, no un veredicto. Las boletas siguen sin informar. */
164
+ async sendSummary(companyId, summaryId) {
165
+ return this.#request('POST', '/companies/' + companyId + '/summaries/' + summaryId + '/send');
166
+ }
167
+ /** Consulta el ticket y propaga el resultado a cada boleta. */
168
+ async summaryStatus(companyId, summaryId) {
169
+ return this.#request('POST', '/companies/' + companyId + '/summaries/' + summaryId + '/status');
170
+ }
171
+ // ─────────────────────────────────────────────── Referencia
172
+ /** Tu consumo del mes. **`emissionBlocked` es siempre `false`.** */
173
+ async usage(period) {
174
+ return this.#request('GET', '/usage' + (period ? '?period=' + period : ''));
175
+ }
176
+ }
177
+ /**
178
+ * Verifica la firma de un webhook. **Esto corre en tu servidor.**
179
+ *
180
+ * La firma **caduca a los 5 minutos**, y eso es lo que impide que quien capture una
181
+ * petición válida te la reenvíe mañana. El HMAC va sobre `epoch.cuerpo`, así que la
182
+ * marca de tiempo no se puede cambiar sin romper la firma.
183
+ *
184
+ * @param body El cuerpo **crudo**, tal como llegó. Si lo parseas y lo vuelves a
185
+ * serializar, la firma deja de cuadrar — es el fallo más común al integrarlo.
186
+ */
187
+ export async function verifyWebhook(args) {
188
+ const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(args.signature.trim());
189
+ if (!m)
190
+ return false;
191
+ const ahora = Math.floor(Date.now() / 1000);
192
+ if (Math.abs(ahora - Number(m[1])) > (args.toleranceSeconds ?? 300))
193
+ return false;
194
+ const clave = await crypto.subtle.importKey('raw', new TextEncoder().encode(args.secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
195
+ const firma = await crypto.subtle.sign('HMAC', clave, new TextEncoder().encode(m[1] + '.' + args.body));
196
+ const esperado = [...new Uint8Array(firma)]
197
+ .map((b) => b.toString(16).padStart(2, '0'))
198
+ .join('');
199
+ // Comparación en tiempo constante: comparar con === filtra cuántos caracteres
200
+ // coinciden, y eso basta para adivinar una firma a base de intentos.
201
+ const recibido = m[2];
202
+ if (esperado.length !== recibido.length)
203
+ return false;
204
+ let diff = 0;
205
+ for (let i = 0; i < esperado.length; i++) {
206
+ diff |= esperado.charCodeAt(i) ^ recibido.charCodeAt(i);
207
+ }
208
+ return diff === 0;
209
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@keysoftinc/sdk",
3
+ "version": "0.1.0",
4
+ "description": "SDK de TypeScript para la API de KeySoft — facturación electrónica SUNAT.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "keywords": [
25
+ "sunat",
26
+ "peru",
27
+ "factura-electronica",
28
+ "facturacion",
29
+ "cpe"
30
+ ],
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "dependencies": {},
35
+ "sideEffects": false,
36
+ "publishConfig": {
37
+ "access": "public"
38
+ }
39
+ }