@timbra-ec/sri 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/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-lint.log +5 -0
- package/.turbo/turbo-typecheck.log +5 -0
- package/dist/clave-acceso.d.ts +31 -0
- package/dist/clave-acceso.d.ts.map +1 -0
- package/dist/clave-acceso.js +70 -0
- package/dist/clave-acceso.js.map +1 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/signing/certificate.d.ts +27 -0
- package/dist/signing/certificate.d.ts.map +1 -0
- package/dist/signing/certificate.js +78 -0
- package/dist/signing/certificate.js.map +1 -0
- package/dist/signing/xades-bes.d.ts +10 -0
- package/dist/signing/xades-bes.d.ts.map +1 -0
- package/dist/signing/xades-bes.js +166 -0
- package/dist/signing/xades-bes.js.map +1 -0
- package/dist/soap/autorizacion.d.ts +21 -0
- package/dist/soap/autorizacion.d.ts.map +1 -0
- package/dist/soap/autorizacion.js +99 -0
- package/dist/soap/autorizacion.js.map +1 -0
- package/dist/soap/client.d.ts +6 -0
- package/dist/soap/client.d.ts.map +1 -0
- package/dist/soap/client.js +37 -0
- package/dist/soap/client.js.map +1 -0
- package/dist/soap/recepcion.d.ts +9 -0
- package/dist/soap/recepcion.d.ts.map +1 -0
- package/dist/soap/recepcion.js +60 -0
- package/dist/soap/recepcion.js.map +1 -0
- package/dist/xml/builder.d.ts +31 -0
- package/dist/xml/builder.d.ts.map +1 -0
- package/dist/xml/builder.js +287 -0
- package/dist/xml/builder.js.map +1 -0
- package/dist/xml/shared.d.ts +21 -0
- package/dist/xml/shared.d.ts.map +1 -0
- package/dist/xml/shared.js +93 -0
- package/dist/xml/shared.js.map +1 -0
- package/dist/xml/types.d.ts +179 -0
- package/dist/xml/types.d.ts.map +1 -0
- package/dist/xml/types.js +2 -0
- package/dist/xml/types.js.map +1 -0
- package/package.json +37 -0
- package/src/clave-acceso.ts +106 -0
- package/src/errors.ts +32 -0
- package/src/index.ts +49 -0
- package/src/signing/certificate.ts +103 -0
- package/src/signing/xades-bes.ts +188 -0
- package/src/soap/autorizacion.ts +139 -0
- package/src/soap/client.ts +45 -0
- package/src/soap/recepcion.ts +83 -0
- package/src/xml/builder.ts +344 -0
- package/src/xml/shared.ts +110 -0
- package/src/xml/types.ts +195 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import forge from 'node-forge'
|
|
2
|
+
import { DOMParser, XMLSerializer } from '@xmldom/xmldom'
|
|
3
|
+
import * as xmlCrypto from 'xml-crypto'
|
|
4
|
+
import type { ParsedCertificate } from './certificate'
|
|
5
|
+
import { SriSigningError } from '../errors'
|
|
6
|
+
|
|
7
|
+
const DS_NS = 'http://www.w3.org/2000/09/xmldsig#'
|
|
8
|
+
const ETSI_NS = 'http://uri.etsi.org/01903/v1.3.2#'
|
|
9
|
+
const C14N_ALGO = 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315'
|
|
10
|
+
const SHA1_ALGO = 'http://www.w3.org/2000/09/xmldsig#sha1'
|
|
11
|
+
const RSA_SHA1_ALGO = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'
|
|
12
|
+
const ENVELOPED_ALGO = 'http://www.w3.org/2000/09/xmldsig#enveloped-signature'
|
|
13
|
+
|
|
14
|
+
/** Compute SHA-1 digest and return base64-encoded result */
|
|
15
|
+
function sha1Base64(data: string): string {
|
|
16
|
+
const md = forge.md.sha1.create()
|
|
17
|
+
md.update(data, 'utf8')
|
|
18
|
+
return forge.util.encode64(md.digest().getBytes())
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Canonicalize an XML node using C14N 1.0 */
|
|
22
|
+
function canonicalize(node: Node): string {
|
|
23
|
+
const c14n = new xmlCrypto.C14nCanonicalization()
|
|
24
|
+
return c14n.process(node, { defaultNsForPrefix: {}, ancestorNamespaces: [] })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Get base64-encoded DER certificate */
|
|
28
|
+
function certToBase64(cert: forge.pki.Certificate): string {
|
|
29
|
+
const asn1 = forge.pki.certificateToAsn1(cert)
|
|
30
|
+
const der = forge.asn1.toDer(asn1).getBytes()
|
|
31
|
+
return forge.util.encode64(der)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Get RSA modulus as base64 */
|
|
35
|
+
function getModulus(key: forge.pki.rsa.PrivateKey): string {
|
|
36
|
+
const n = (key as unknown as { n: forge.jsbn.BigInteger }).n
|
|
37
|
+
return forge.util.encode64(forge.util.hexToBytes(n.toString(16)))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Get RSA public exponent as base64 */
|
|
41
|
+
function getExponent(key: forge.pki.rsa.PrivateKey): string {
|
|
42
|
+
const e = (key as unknown as { e: forge.jsbn.BigInteger }).e
|
|
43
|
+
return forge.util.encode64(forge.util.hexToBytes(e.toString(16)))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** RSA-SHA1 sign data and return base64 signature */
|
|
47
|
+
function rsaSha1Sign(data: string, privateKey: forge.pki.rsa.PrivateKey): string {
|
|
48
|
+
const md = forge.md.sha1.create()
|
|
49
|
+
md.update(data, 'utf8')
|
|
50
|
+
const signature = privateKey.sign(md)
|
|
51
|
+
return forge.util.encode64(signature)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Format issuer DN in RFC 2253 format for XAdES */
|
|
55
|
+
function issuerDN(cert: forge.pki.Certificate): string {
|
|
56
|
+
return cert.issuer.attributes
|
|
57
|
+
.reverse()
|
|
58
|
+
.map((a) => `${a.shortName ?? a.name}=${a.value}`)
|
|
59
|
+
.join(',')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sign an XML document using XAdES-BES as required by Ecuador's SRI.
|
|
64
|
+
*
|
|
65
|
+
* The XML must have `id="comprobante"` on its root element.
|
|
66
|
+
* Uses SHA-1 digest and RSA-SHA1 signature (SRI requirement).
|
|
67
|
+
* The `<ds:Signature>` is inserted as the last child of the root element.
|
|
68
|
+
*/
|
|
69
|
+
export function signXml(xml: string, certificate: ParsedCertificate): string {
|
|
70
|
+
const { privateKey, certificate: cert } = certificate
|
|
71
|
+
|
|
72
|
+
const parser = new DOMParser()
|
|
73
|
+
const doc = parser.parseFromString(xml, 'text/xml')
|
|
74
|
+
const root = doc.documentElement
|
|
75
|
+
if (!root) {
|
|
76
|
+
throw new SriSigningError('Invalid XML: no root element')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const signingTime = new Date().toISOString().replace('Z', '-05:00')
|
|
80
|
+
const certBase64 = certToBase64(cert)
|
|
81
|
+
const certDigest = sha1Base64(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes())
|
|
82
|
+
const issuerName = issuerDN(cert)
|
|
83
|
+
const serialNumber = cert.serialNumber
|
|
84
|
+
|
|
85
|
+
// --- Build QualifyingProperties ---
|
|
86
|
+
const signedPropsXml = [
|
|
87
|
+
`<etsi:SignedProperties xmlns:etsi="${ETSI_NS}" xmlns:ds="${DS_NS}" Id="SignedProperties">`,
|
|
88
|
+
`<etsi:SignedSignatureProperties>`,
|
|
89
|
+
`<etsi:SigningTime>${signingTime}</etsi:SigningTime>`,
|
|
90
|
+
`<etsi:SigningCertificate>`,
|
|
91
|
+
`<etsi:Cert>`,
|
|
92
|
+
`<etsi:CertDigest>`,
|
|
93
|
+
`<ds:DigestMethod Algorithm="${SHA1_ALGO}"/>`,
|
|
94
|
+
`<ds:DigestValue>${certDigest}</ds:DigestValue>`,
|
|
95
|
+
`</etsi:CertDigest>`,
|
|
96
|
+
`<etsi:IssuerSerial>`,
|
|
97
|
+
`<ds:X509IssuerName>${issuerName}</ds:X509IssuerName>`,
|
|
98
|
+
`<ds:X509SerialNumber>${parseInt(serialNumber, 16)}</ds:X509SerialNumber>`,
|
|
99
|
+
`</etsi:IssuerSerial>`,
|
|
100
|
+
`</etsi:Cert>`,
|
|
101
|
+
`</etsi:SigningCertificate>`,
|
|
102
|
+
`</etsi:SignedSignatureProperties>`,
|
|
103
|
+
`<etsi:SignedDataObjectProperties>`,
|
|
104
|
+
`<etsi:DataObjectFormat ObjectReference="#Reference-ID">`,
|
|
105
|
+
`<etsi:Description>contenido comprobante</etsi:Description>`,
|
|
106
|
+
`<etsi:MimeType>text/xml</etsi:MimeType>`,
|
|
107
|
+
`</etsi:DataObjectFormat>`,
|
|
108
|
+
`</etsi:SignedDataObjectProperties>`,
|
|
109
|
+
`</etsi:SignedProperties>`,
|
|
110
|
+
].join('')
|
|
111
|
+
|
|
112
|
+
// --- Build KeyInfo ---
|
|
113
|
+
const keyInfoXml = [
|
|
114
|
+
`<ds:KeyInfo xmlns:ds="${DS_NS}" Id="Certificate">`,
|
|
115
|
+
`<ds:X509Data>`,
|
|
116
|
+
`<ds:X509Certificate>${certBase64}</ds:X509Certificate>`,
|
|
117
|
+
`</ds:X509Data>`,
|
|
118
|
+
`<ds:KeyValue>`,
|
|
119
|
+
`<ds:RSAKeyValue>`,
|
|
120
|
+
`<ds:Modulus>${getModulus(privateKey)}</ds:Modulus>`,
|
|
121
|
+
`<ds:Exponent>${getExponent(privateKey)}</ds:Exponent>`,
|
|
122
|
+
`</ds:RSAKeyValue>`,
|
|
123
|
+
`</ds:KeyValue>`,
|
|
124
|
+
`</ds:KeyInfo>`,
|
|
125
|
+
].join('')
|
|
126
|
+
|
|
127
|
+
// --- Compute digests ---
|
|
128
|
+
|
|
129
|
+
// 1. Document digest (with enveloped-signature transform — digest the doc as-is since signature isn't inserted yet)
|
|
130
|
+
const docDigest = sha1Base64(canonicalize(root))
|
|
131
|
+
|
|
132
|
+
// 2. SignedProperties digest
|
|
133
|
+
const signedPropsDoc = parser.parseFromString(signedPropsXml, 'text/xml')
|
|
134
|
+
const signedPropsDigest = sha1Base64(canonicalize(signedPropsDoc))
|
|
135
|
+
|
|
136
|
+
// 3. KeyInfo digest
|
|
137
|
+
const keyInfoDoc = parser.parseFromString(keyInfoXml, 'text/xml')
|
|
138
|
+
const keyInfoDigest = sha1Base64(canonicalize(keyInfoDoc))
|
|
139
|
+
|
|
140
|
+
// --- Build SignedInfo ---
|
|
141
|
+
const signedInfoXml = [
|
|
142
|
+
`<ds:SignedInfo xmlns:ds="${DS_NS}" Id="SignedInfo">`,
|
|
143
|
+
`<ds:CanonicalizationMethod Algorithm="${C14N_ALGO}"/>`,
|
|
144
|
+
`<ds:SignatureMethod Algorithm="${RSA_SHA1_ALGO}"/>`,
|
|
145
|
+
`<ds:Reference Id="Reference-ID" URI="#comprobante">`,
|
|
146
|
+
`<ds:Transforms>`,
|
|
147
|
+
`<ds:Transform Algorithm="${ENVELOPED_ALGO}"/>`,
|
|
148
|
+
`</ds:Transforms>`,
|
|
149
|
+
`<ds:DigestMethod Algorithm="${SHA1_ALGO}"/>`,
|
|
150
|
+
`<ds:DigestValue>${docDigest}</ds:DigestValue>`,
|
|
151
|
+
`</ds:Reference>`,
|
|
152
|
+
`<ds:Reference URI="#SignedProperties" Type="http://uri.etsi.org/01903#SignedProperties">`,
|
|
153
|
+
`<ds:DigestMethod Algorithm="${SHA1_ALGO}"/>`,
|
|
154
|
+
`<ds:DigestValue>${signedPropsDigest}</ds:DigestValue>`,
|
|
155
|
+
`</ds:Reference>`,
|
|
156
|
+
`<ds:Reference URI="#Certificate">`,
|
|
157
|
+
`<ds:DigestMethod Algorithm="${SHA1_ALGO}"/>`,
|
|
158
|
+
`<ds:DigestValue>${keyInfoDigest}</ds:DigestValue>`,
|
|
159
|
+
`</ds:Reference>`,
|
|
160
|
+
`</ds:SignedInfo>`,
|
|
161
|
+
].join('')
|
|
162
|
+
|
|
163
|
+
// --- Sign ---
|
|
164
|
+
const signedInfoDoc = parser.parseFromString(signedInfoXml, 'text/xml')
|
|
165
|
+
const canonicalSignedInfo = canonicalize(signedInfoDoc)
|
|
166
|
+
const signatureValue = rsaSha1Sign(canonicalSignedInfo, privateKey)
|
|
167
|
+
|
|
168
|
+
// --- Assemble full Signature element ---
|
|
169
|
+
const signatureXml = [
|
|
170
|
+
`<ds:Signature xmlns:ds="${DS_NS}" xmlns:etsi="${ETSI_NS}" Id="Signature">`,
|
|
171
|
+
signedInfoXml,
|
|
172
|
+
`<ds:SignatureValue>${signatureValue}</ds:SignatureValue>`,
|
|
173
|
+
keyInfoXml,
|
|
174
|
+
`<ds:Object Id="XadesObjectId">`,
|
|
175
|
+
`<etsi:QualifyingProperties Target="#Signature">`,
|
|
176
|
+
signedPropsXml,
|
|
177
|
+
`</etsi:QualifyingProperties>`,
|
|
178
|
+
`</ds:Object>`,
|
|
179
|
+
`</ds:Signature>`,
|
|
180
|
+
].join('')
|
|
181
|
+
|
|
182
|
+
// --- Insert signature into document ---
|
|
183
|
+
const sigDoc = parser.parseFromString(signatureXml, 'text/xml')
|
|
184
|
+
const imported = doc.importNode(sigDoc.documentElement!, true)
|
|
185
|
+
root.appendChild(imported)
|
|
186
|
+
|
|
187
|
+
return new XMLSerializer().serializeToString(doc)
|
|
188
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { DOMParser } from '@xmldom/xmldom'
|
|
2
|
+
import type { SriEnvironment, SriAutorizacionResponse, SriMensaje } from '@timbra-ec/types'
|
|
3
|
+
import { SRI_URLS } from '@timbra-ec/types'
|
|
4
|
+
import { SriSoapError, SriTimeoutError } from '../errors'
|
|
5
|
+
import { soapCall } from './client'
|
|
6
|
+
|
|
7
|
+
export interface PollOptions {
|
|
8
|
+
/** Maximum number of authorization queries (default: 10) */
|
|
9
|
+
maxAttempts?: number
|
|
10
|
+
/** Milliseconds between retries (default: 2000) */
|
|
11
|
+
intervalMs?: number
|
|
12
|
+
/** AbortSignal for cancellation */
|
|
13
|
+
signal?: AbortSignal
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Extract text content of a tag from a parent element */
|
|
17
|
+
function getTagText(parent: Element | Document, tag: string): string {
|
|
18
|
+
const el = parent.getElementsByTagName(tag)[0]
|
|
19
|
+
return el?.textContent ?? ''
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parse SRI mensajes from an autorizacion element */
|
|
23
|
+
function parseMensajes(parent: Element | Document): SriMensaje[] {
|
|
24
|
+
const mensajes: SriMensaje[] = []
|
|
25
|
+
const mensajeEls = parent.getElementsByTagName('mensaje')
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < mensajeEls.length; i++) {
|
|
28
|
+
const el = mensajeEls[i]!
|
|
29
|
+
mensajes.push({
|
|
30
|
+
identificador: getTagText(el, 'identificador'),
|
|
31
|
+
mensaje: getTagText(el, 'mensaje'),
|
|
32
|
+
informacionAdicional: getTagText(el, 'informacionAdicional') || undefined,
|
|
33
|
+
tipo: getTagText(el, 'tipo') as SriMensaje['tipo'],
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return mensajes
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
if (signal?.aborted) {
|
|
43
|
+
reject(new SriTimeoutError('Authorization query aborted'))
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
const timer = setTimeout(resolve, ms)
|
|
47
|
+
signal?.addEventListener(
|
|
48
|
+
'abort',
|
|
49
|
+
() => {
|
|
50
|
+
clearTimeout(timer)
|
|
51
|
+
reject(new SriTimeoutError('Authorization query aborted'))
|
|
52
|
+
},
|
|
53
|
+
{ once: true },
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Query SRI for the authorization status of a document.
|
|
60
|
+
*
|
|
61
|
+
* Polls the autorizacionComprobante service using the clave de acceso.
|
|
62
|
+
* Retries when status is EN PROCESO, up to maxAttempts times.
|
|
63
|
+
*
|
|
64
|
+
* Returns the authorization response when AUTORIZADO.
|
|
65
|
+
* Throws SriSoapError when NO AUTORIZADO.
|
|
66
|
+
* Throws SriTimeoutError when max attempts exhausted.
|
|
67
|
+
*/
|
|
68
|
+
export async function queryAutorizacion(
|
|
69
|
+
claveAcceso: string,
|
|
70
|
+
environment: SriEnvironment,
|
|
71
|
+
options?: PollOptions,
|
|
72
|
+
): Promise<SriAutorizacionResponse> {
|
|
73
|
+
const maxAttempts = options?.maxAttempts ?? 10
|
|
74
|
+
const intervalMs = options?.intervalMs ?? 2000
|
|
75
|
+
const signal = options?.signal
|
|
76
|
+
|
|
77
|
+
const url = environment === '1' ? SRI_URLS.TEST.AUTORIZACION : SRI_URLS.PRODUCTION.AUTORIZACION
|
|
78
|
+
|
|
79
|
+
const body = [
|
|
80
|
+
'<aut:autorizacionComprobante xmlns:aut="http://ec.gob.sri.ws.autorizacion">',
|
|
81
|
+
`<claveAccesoComprobante>${claveAcceso}</claveAccesoComprobante>`,
|
|
82
|
+
'</aut:autorizacionComprobante>',
|
|
83
|
+
].join('')
|
|
84
|
+
|
|
85
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
86
|
+
if (signal?.aborted) {
|
|
87
|
+
throw new SriTimeoutError('Authorization query aborted')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const responseXml = await soapCall(url, body, signal)
|
|
91
|
+
|
|
92
|
+
const parser = new DOMParser()
|
|
93
|
+
const doc = parser.parseFromString(responseXml, 'text/xml')
|
|
94
|
+
|
|
95
|
+
// Find the autorizacion element
|
|
96
|
+
const autorizacionEls = doc.getElementsByTagName('autorizacion')
|
|
97
|
+
if (autorizacionEls.length === 0) {
|
|
98
|
+
// No authorization data yet, retry
|
|
99
|
+
if (attempt < maxAttempts) {
|
|
100
|
+
await delay(intervalMs, signal)
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
throw new SriTimeoutError(`No authorization response after ${maxAttempts} attempts`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const autorizacion = autorizacionEls[0]!
|
|
107
|
+
const estado = getTagText(autorizacion, 'estado')
|
|
108
|
+
const mensajes = parseMensajes(autorizacion)
|
|
109
|
+
|
|
110
|
+
if (estado === 'AUTORIZADO') {
|
|
111
|
+
return {
|
|
112
|
+
estado: 'AUTORIZADO',
|
|
113
|
+
numeroAutorizacion: getTagText(autorizacion, 'numeroAutorizacion'),
|
|
114
|
+
fechaAutorizacion: getTagText(autorizacion, 'fechaAutorizacion'),
|
|
115
|
+
ambiente: getTagText(autorizacion, 'ambiente'),
|
|
116
|
+
comprobante: getTagText(autorizacion, 'comprobante'),
|
|
117
|
+
mensajes,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (estado === 'NO AUTORIZADO') {
|
|
122
|
+
const errorMsg = mensajes.map((m) => `[${m.identificador}] ${m.mensaje}`).join('; ')
|
|
123
|
+
throw new SriSoapError(
|
|
124
|
+
`SRI denied authorization: ${errorMsg}`,
|
|
125
|
+
mensajes[0]?.identificador,
|
|
126
|
+
mensajes,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// EN PROCESO — retry after interval
|
|
131
|
+
if (attempt < maxAttempts) {
|
|
132
|
+
await delay(intervalMs, signal)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
throw new SriTimeoutError(
|
|
137
|
+
`Authorization still EN PROCESO after ${maxAttempts} attempts for clave ${claveAcceso}`,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { SriSoapError } from '../errors'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Make a SOAP call to SRI web services.
|
|
5
|
+
* Returns the raw XML response body.
|
|
6
|
+
*/
|
|
7
|
+
export async function soapCall(url: string, body: string, signal?: AbortSignal): Promise<string> {
|
|
8
|
+
// Strip ?wsdl from URL — we call the service endpoint directly
|
|
9
|
+
const serviceUrl = url.replace(/\?wsdl$/i, '')
|
|
10
|
+
|
|
11
|
+
const envelope = [
|
|
12
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
13
|
+
'<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">',
|
|
14
|
+
'<soapenv:Body>',
|
|
15
|
+
body,
|
|
16
|
+
'</soapenv:Body>',
|
|
17
|
+
'</soapenv:Envelope>',
|
|
18
|
+
].join('')
|
|
19
|
+
|
|
20
|
+
let response: Response
|
|
21
|
+
try {
|
|
22
|
+
response = await fetch(serviceUrl, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: {
|
|
25
|
+
'Content-Type': 'text/xml; charset=utf-8',
|
|
26
|
+
},
|
|
27
|
+
body: envelope,
|
|
28
|
+
signal,
|
|
29
|
+
})
|
|
30
|
+
} catch (err) {
|
|
31
|
+
throw new SriSoapError(
|
|
32
|
+
`Network error connecting to SRI: ${err instanceof Error ? err.message : String(err)}`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const responseText = await response.text()
|
|
37
|
+
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw new SriSoapError(
|
|
40
|
+
`SRI returned HTTP ${response.status}: ${responseText.substring(0, 500)}`,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return responseText
|
|
45
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { DOMParser } from '@xmldom/xmldom'
|
|
2
|
+
import type { SriEnvironment, SriRecepcionResponse, SriMensaje } from '@timbra-ec/types'
|
|
3
|
+
import { SRI_URLS } from '@timbra-ec/types'
|
|
4
|
+
import { SriSoapError } from '../errors'
|
|
5
|
+
import { soapCall } from './client'
|
|
6
|
+
|
|
7
|
+
/** Extract text content of a tag from a parent element */
|
|
8
|
+
function getTagText(parent: Element, tag: string): string {
|
|
9
|
+
const el = parent.getElementsByTagName(tag)[0]
|
|
10
|
+
return el?.textContent ?? ''
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Parse SRI mensajes from a comprobante element */
|
|
14
|
+
function parseMensajes(comprobante: Element): SriMensaje[] {
|
|
15
|
+
const mensajes: SriMensaje[] = []
|
|
16
|
+
const mensajeEls = comprobante.getElementsByTagName('mensaje')
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < mensajeEls.length; i++) {
|
|
19
|
+
const el = mensajeEls[i]!
|
|
20
|
+
mensajes.push({
|
|
21
|
+
identificador: getTagText(el, 'identificador'),
|
|
22
|
+
mensaje: getTagText(el, 'mensaje'),
|
|
23
|
+
informacionAdicional: getTagText(el, 'informacionAdicional') || undefined,
|
|
24
|
+
tipo: getTagText(el, 'tipo') as SriMensaje['tipo'],
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return mensajes
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Submit a signed XML document to SRI's reception service (validarComprobante).
|
|
33
|
+
*
|
|
34
|
+
* The signed XML is base64-encoded and sent via SOAP.
|
|
35
|
+
* Returns RECIBIDA if accepted, throws SriSoapError if DEVUELTA.
|
|
36
|
+
*/
|
|
37
|
+
export async function submitComprobante(
|
|
38
|
+
signedXml: string,
|
|
39
|
+
environment: SriEnvironment,
|
|
40
|
+
signal?: AbortSignal,
|
|
41
|
+
): Promise<SriRecepcionResponse> {
|
|
42
|
+
const url = environment === '1' ? SRI_URLS.TEST.RECEPCION : SRI_URLS.PRODUCTION.RECEPCION
|
|
43
|
+
|
|
44
|
+
const xmlBase64 = Buffer.from(signedXml, 'utf-8').toString('base64')
|
|
45
|
+
|
|
46
|
+
const body = [
|
|
47
|
+
'<ec:validarComprobante xmlns:ec="http://ec.gob.sri.ws.recepcion">',
|
|
48
|
+
`<xml>${xmlBase64}</xml>`,
|
|
49
|
+
'</ec:validarComprobante>',
|
|
50
|
+
].join('')
|
|
51
|
+
|
|
52
|
+
const responseXml = await soapCall(url, body, signal)
|
|
53
|
+
|
|
54
|
+
const parser = new DOMParser()
|
|
55
|
+
const doc = parser.parseFromString(responseXml, 'text/xml')
|
|
56
|
+
|
|
57
|
+
const estado = getTagText(doc.documentElement!, 'estado')
|
|
58
|
+
|
|
59
|
+
const comprobantes: SriRecepcionResponse['comprobantes'] = []
|
|
60
|
+
const comprobanteEls = doc.getElementsByTagName('comprobante')
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < comprobanteEls.length; i++) {
|
|
63
|
+
const el = comprobanteEls[i]!
|
|
64
|
+
comprobantes.push({
|
|
65
|
+
claveAcceso: getTagText(el, 'claveAcceso'),
|
|
66
|
+
mensajes: parseMensajes(el),
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const response: SriRecepcionResponse = { estado: estado as 'RECIBIDA' | 'DEVUELTA', comprobantes }
|
|
71
|
+
|
|
72
|
+
if (estado === 'DEVUELTA') {
|
|
73
|
+
const allMensajes = comprobantes.flatMap((c) => c.mensajes)
|
|
74
|
+
const errorMsg = allMensajes.map((m) => `[${m.identificador}] ${m.mensaje}`).join('; ')
|
|
75
|
+
throw new SriSoapError(
|
|
76
|
+
`SRI rejected document: ${errorMsg}`,
|
|
77
|
+
allMensajes[0]?.identificador,
|
|
78
|
+
allMensajes,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return response
|
|
83
|
+
}
|