Issue a receipt
A receipt (boleta electrónica, type 39) is the document of a sale to the public. It differs from an invoice in four ways that this example shows: the prices already include VAT, there is usually no receiver (the buyer is the final consumer), the envelope is an EnvioBOLETA, and it is sent through the receipts REST API in production and through DTEUpload in certification. It also builds the folio consumption report that the receipts certification asks for, and ends with the exempt receipt (type 41).
This page runs on dte-sdk 0.8.0 from npm (see Installation). Documents and envelopes are tested against synthetic SII answers and have not been sent to the real SII yet. Of the SII web services, only the seed and token over SOAP and the acceptance and claim registry have met it, and only in production; the receipts REST API, uploads and status queries have not run in either environment yet (issue 8). The certification of receipts, which needs a company enrolled in the receipts application, has not been run either; the receipts envelope and the folio consumption report do validate against the official schemas in the tests. Run this against the certification environment first.
What you'll build#
A program, receipt.ts, that:
- issues two receipts of type 39, one per sale, with prices that include VAT and no receiver,
- puts both in one
EnvioBOLETA, signs it and keeps the signed XML, - uploads it on the channel that corresponds to the environment and waits for the status on that same channel,
- in certification, builds, signs and uploads the folio consumption report (RCOF) of those receipts.
The two sales and the totals the SDK computes. The unit price is what the customer pays, so the SDK derives the net from the gross (round(gross / 1.19)) and the VAT is what is left:
| Receipt | Lines | Gross | Net | VAT |
|---|---|---|---|---|
| Folio 1 | 2 × 2500 (café americano) + 3 × 1200 (medialuna) | 8600 | 7227 | 1373 |
| Folio 2 | 1 × 2500 (café americano) | 2500 | 2101 | 399 |
The total of a receipt is its gross amount, and net plus VAT always add up to it.
Prerequisites#
- Everything in Issue an invoice: Node 22.12 or later,
firma.pfxwithPFX_PASSWORD, and the resolution inSII_RESOLUTION_DATEandSII_RESOLUTION_NUMBER. - A CAF of type 39 as
caf-39.xml. Receipts have their own CAF, and the CAF of type 41 is another one. SII_ENVIRONMENTset tocertification(the default of the program) orproduction.- The company has to be authorized to issue receipts at the SII. In certification that is the receipts application, which has its own test set: see Receipts certification.
The full program#
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import {
createEnvelope,
createFolioConsumption,
createSiiClient,
encodeLatin1,
folioConsumptionEntryFromDocument,
isDteError,
issueDocument,
loadCertificateFromPkcs12,
parseCaf,
signEnvelope,
signFolioConsumption,
uploadEnvelope,
uploadFolioConsumption,
waitForUploadStatus,
type DocumentInput,
type Environment,
type SignedDocument,
} from 'dte-sdk'
import { createDirectoryCapture, createDirectoryFolioRegistry, createDirectoryUploadArchive } from 'dte-sdk/node'
const COMPANY_RUT = '76543210-3'
const ENVIRONMENT = (process.env.SII_ENVIRONMENT ?? 'certification') as Environment // or 'production'
const resolution = {
date: process.env.SII_RESOLUTION_DATE!, // YYYY-MM-DD
number: Number(process.env.SII_RESOLUTION_NUMBER ?? 0),
}
const issuer = {
rut: COMPANY_RUT,
businessName: 'Comercial Andina SpA',
businessActivity: 'Cafetería',
address: 'Av. Los Aromos 1540',
commune: 'Santiago',
}
// Two sales. The prices are what the customer pays: they already include VAT
const SALES: DocumentInput['items'][] = [
[
{ name: 'Café americano', quantity: 2, unitPrice: 2500 },
{ name: 'Medialuna', quantity: 3, unitPrice: 1200 },
],
[{ name: 'Café americano', quantity: 1, unitPrice: 2500 }],
]
const certificate = loadCertificateFromPkcs12(await readFile('firma.pfx'), process.env.PFX_PASSWORD!)
const caf = parseCaf(await readFile('caf-39.xml', 'latin1')) // the CAF of type 39
const folios = createDirectoryFolioRegistry({ dir: './data/folios' })
const scope = { companyRut: COMPANY_RUT, type: 39, range: caf.range, environment: ENVIRONMENT } as const
const sii = createSiiClient({
environment: ENVIRONMENT,
certificate,
companyRut: COMPANY_RUT,
capture: createDirectoryCapture({ dir: './data/capture' }),
archive: createDirectoryUploadArchive({ dir: './data/uploads' }),
})
try {
// 1. One receipt per sale: no receiver (final consumer), prices with VAT
const receipts: SignedDocument[] = []
for (const items of SALES) {
const folio = await folios.reserve(scope)
const receipt = issueDocument({
document: { type: 39, folio, issuer, items, pricesIncludeVat: true },
caf,
certificate,
})
receipts.push(receipt)
console.log(`receipt 39 #${folio}`, JSON.stringify(receipt.header.totals))
}
// 2. All of them in one EnvioBOLETA
const envelope = signEnvelope(
createEnvelope({
kind: 'receipt',
documents: receipts,
cover: {
issuerRut: COMPANY_RUT,
senderRut: certificate.rut!,
resolutionDate: resolution.date,
resolutionNumber: resolution.number,
},
}),
certificate,
)
await mkdir('./data/signed', { recursive: true })
await writeFile(`./data/signed/39-${receipts[0].header.folio}.xml`, encodeLatin1(envelope.xml))
// 3. Upload: REST in production, DTEUpload in certification (sii.receiptsChannel says which)
const upload = await uploadEnvelope(sii, { envelope })
for (const receipt of receipts) await folios.markSent(scope, receipt.header.folio, { trackId: upload.trackId })
console.log(`uploaded through ${upload.channel}, track id ${upload.trackId}`)
// 4. Wait on the same channel the file went through
const status = await waitForUploadStatus(sii, { trackId: upload.trackId, channel: upload.channel })
console.log(`envelope ${status.code} ${status.status}: ${status.message}`)
// 5. Folio consumption report: the receipts certification asks for it
if (ENVIRONMENT === 'certification' && status.status !== 'rejected') {
const report = signFolioConsumption(
createFolioConsumption({
cover: {
issuerRut: COMPANY_RUT,
senderRut: certificate.rut!,
resolutionDate: resolution.date,
resolutionNumber: resolution.number,
},
entries: receipts.map(folioConsumptionEntryFromDocument),
}),
certificate,
)
console.log('consumption', JSON.stringify(report.summaries))
const consumption = await uploadFolioConsumption(sii, { xml: report.xml })
const consumptionStatus = await waitForUploadStatus(sii, { trackId: consumption.trackId })
console.log(`consumption ${consumptionStatus.code} ${consumptionStatus.status}: ${consumptionStatus.message}`)
}
} catch (error) {
if (!isDteError(error)) throw error
console.error(error.code, error.details)
process.exitCode = 1
}Walk-through#
Issue the receipts
The document has no receiver. For a receipt the SDK fills in the final consumer: RUT 66666666-6, name Consumidor Final. If the buyer asks to be identified, pass a receiver; the address and the commune are not required on a receipt. serviceIndicator (IndServicio) defaults to 3, sales and services.
pricesIncludeVat: true is what makes the prices gross. Without it, the SDK would read unitPrice as net and add VAT on top, and the customer would be charged more than the price on the shelf. With it, each line amount is gross, the net is round(gross / 1.19) and the VAT is the difference. A receipt never carries TasaIVA and, unlike an invoice, its header has no MntBruto.
The folio comes from the registry, one per receipt, from the scope of the CAF of type 39. A CAF of type 39 does not expire: only the CAF of invoices and of types 46, 56 and 61 does.
Put them in an EnvioBOLETA
kind: 'receipt' makes an EnvioBOLETA. Its receiver is always the SII, and a document that is not a receipt in it, or a receipt in an EnvioDTE, throws ENVELOPE_KIND_MISMATCH. Receipts of type 39 and 41 can travel together. On the REST channel one envelope can carry at most 500 receipts: with more, uploadEnvelope throws SII_UPLOAD_REJECTED with details.status equal to 'TOO_MANY' before sending anything.
Upload on the right channel
The client decides: receiptsChannel is rest in production and soap (the DTEUpload CGI) in certification, and uploadEnvelope(sii, { envelope, channel }) forces one. An EnvioDTE always goes through DTEUpload. The REST channel has its own seed and token, which the client obtains and renews like the others.
upload.channel tells which one was used, and waitForUploadStatus must ask on that same channel: the program passes channel: upload.channel. The REST status carries more than the SOAP one: status.documents lists every document with its state (DOK, RPR, RCH, RLV) and the errors found in it, each with its section, line, level, code and description. On the REST channel RLV is a success; on DTEUpload it is a rejection, and the SDK classifies each accordingly.
The status of one receipt, queryDocumentStatus, defaults to the REST API for types 39 and 41, and that API answers about ten minutes after the upload.
The folio consumption report
The folio consumption report (RCOF, ConsumoFolios, now Resumen de Ventas Diarias) tells the SII which folios of receipts you used on a day. What you should know about it: the receipts certification uses it, and the SII probably no longer requires it in production. That is why the program sends it only in certification. If you issue receipts in production, check with the SII whether you have to send it before you do.
folioConsumptionEntryFromDocument derives one line per receipt (type, folio, date, net, VAT, exempt, total). createFolioConsumption computes the summary per type: amounts, issued, voided and used counts, and the ranges of consecutive folios (usedRanges). With no from and to in the cover, the period is the earliest and the latest issue date of the lines. signFolioConsumption signs it and uploadFolioConsumption sends it through DTEUpload as ConsumoFolios.xml.
The report is a daily summary: in a real run you pass every receipt of the day, not only those of one envelope. Folios voided in your system before they reached the SII go in voidedFolios, as { type, folio }; a day without sales is a report with no entries and the from and to of that day, and it comes out as a summary of type 39 in zeros. The cover has a sequence, 1 by default: to correct a report you send it whole again with a higher number. The runner of the receipts certification assumes that the certification environment takes one report per company per day.
Variants#
Receipt references#
A receipt has its own kind of reference. The receipts schema gives Referencia no date and a free-text code, so a receipt takes receiptReferences (code of at most 18 characters, reason of at most 90, and optionally documentType and folio) and refuses references with INVALID_OPTION. The other way round, receiptReferences on an invoice or a note is refused too. In the certification test set every receipt carries the reference SET / CASO-<n>:
const receipt = issueDocument({
document: {
type: 39,
folio,
issuer,
items,
pricesIncludeVat: true,
// in the certification test set every receipt points at its case
receiptReferences: [{ code: 'SET', reason: 'CASO-1' }],
},
caf,
certificate,
})Outside certification the reference is optional.
Exempt receipt (type 41)#
The exempt receipt is a receipt with no VAT: every line is exempt, there is no net and no VAT, and pricesIncludeVat does nothing. It has its own CAF and its own folios, so it has its own scope in the registry. It travels in the same EnvioBOLETA as the receipts of type 39, and its folio consumption line goes in the same report: the summary of type 41 has net and VAT in zeros and no vatRate.
// an exempt receipt (type 41): its own CAF, no VAT and no pricesIncludeVat
const caf41 = parseCaf(await readFile('caf-41.xml', 'latin1'))
const scope41 = { companyRut: COMPANY_RUT, type: 41, range: caf41.range, environment: ENVIRONMENT } as const
const exempt = issueDocument({
document: {
type: 41,
folio: await folios.reserve(scope41),
issuer,
items: [{ name: 'Entrada al museo', quantity: 2, unitPrice: 3000 }],
},
caf: caf41,
certificate,
})
exempt.header.totals // { exemptAmount: 6000, totalAmount: 6000 }For a receipt of 2 tickets at 3000, the totals are { exemptAmount: 6000, totalAmount: 6000 }. To send it with the others, add exempt to the documents of createEnvelope and to the entries of the report.
Expected output#
In certification, with a fresh registry and a CAF of type 39 that starts at folio 1:
receipt 39 #1 {"netAmount":7227,"vatAmount":1373,"totalAmount":8600}
receipt 39 #2 {"netAmount":2101,"vatAmount":399,"totalAmount":2500}
uploaded through soap, track id 4503230
envelope EPR accepted: Envío procesado
consumption [{"type":39,"netAmount":9328,"vatAmount":1772,"vatRate":19,"exemptAmount":0,"totalAmount":11100,"issuedCount":2,"voidedCount":0,"usedCount":2,"usedRanges":[{"from":1,"to":2}],"voidedRanges":[]}]
consumption EPR accepted: Envío procesadoThe totals of the receipts and the summary of the report are computed by the SDK and are always these. The track ids, the codes and the texts of the SII are examples: yours will differ. In production the second line reads uploaded through rest, there is no consumption report, and the status codes are those of the REST API.
What can go wrong#
| Code | Where | What to do |
|---|---|---|
CAF_TYPE_MISMATCH, CAF_ISSUER_MISMATCH, FOLIO_OUT_OF_RANGE | issueDocument, reserve | The CAF is not for type 39 or for this RUT, or it has no folios left. |
INVALID_OPTION | createDocument | You passed references on a receipt, or receiptReferences on another type. |
FIELD_TOO_LONG, MISSING_FIELD, INVALID_AMOUNT | createDocument | The input is wrong. details.field says which field. |
ENVELOPE_KIND_MISMATCH | createEnvelope | A document of type 33 or similar in an EnvioBOLETA. Receipts and other documents go in different envelopes. |
INVALID_DOCUMENT_TYPE | folioConsumptionEntryFromDocument, createFolioConsumption | Only types 39 and 41 go in the report. |
SII_UPLOAD_REJECTED | uploadEnvelope, uploadFolioConsumption | The SII refused the file (details.status on DTEUpload), or you sent more than 500 receipts on the REST channel ('TOO_MANY'). |
SII_TOKEN_INVALID | Any SII call | The token was refused (an HTTP 401 on REST, status 5 on DTEUpload) even after one renewal. |
WAIT_TIMEOUT | waitForUploadStatus | The SII is still working. Ask again later with the track id. |
The statuses, the upload statuses of DTEUpload and the transport codes are the same as for the invoice: see What can go wrong. A status rejected on the envelope makes the program skip the folio consumption report.
Next steps#
- Receipts certification: the receipts application has its own test set, and the SDK has pieces to issue it, send it with its folio consumption report and declare it.
- Get folios from the SII to ask for the CAFs of types 39 and 41.
- Printed samples for the PDF of a receipt with its stamp.
- Documents and Totals for every field and the rounding rules, and Envelopes and SII web services for the two channels.