@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 +21 -0
- package/README.md +111 -0
- package/dist/index.d.ts +254 -0
- package/dist/index.js +209 -0
- package/package.json +39 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|