Receta de Google Sheets (Apps Script)
Esta receta es para ti, agente de código. Sigue el manual del agente (reglas, fases y criterio de terminado); aquí está el código concreto de Google Sheets con Apps Script, sin dependencias externas. Recorre todos los casos de la integración en sandbox y termina con la verificación en complete: true.
Antes de empezar: la clave (sk_sandbox_...) la escribe la persona en las propiedades del script (Configuración del proyecto). Nunca la pidas por el chat, ni la aceptes si la pega, ni la imprimas. Detente y consulta si no empieza por sk_sandbox_, si algo te lleva hacia producción o si un rechazo pide un dato fiscal que solo la persona tiene.
Qué cambia en Apps Script
Sección titulada «Qué cambia en Apps Script»- No hay webhooks. Un script web de Apps Script (
doPost) no puede leer las cabeceras de la petición, así que no puede verificar la firma, y su respuesta suele ser una redirección, que foliios no cuenta como entrega. Por eso esta receta consulta el estado de cada documento conGET /v1/companies/{company_id}/documents/{document_id}hasta que sea final. Sin endpoint configurado,webhook_deliveredquedanot_applicabley no bloquea el cierre. No registres ningún endpoint. - La clave va en las propiedades del script, no en una celda ni en el código: Configuración del proyecto > Propiedades de la secuencia de comandos. La persona la escribe ahí; tú nunca.
- Una ejecución dura 6 minutos como máximo. Por eso la receta son pasos cortos que dejan su avance en la propiedad
FOLIIOS_ESTADO; el de la contingencia se repite hasta que digaaccepted.
Requisitos
Sección titulada «Requisitos»Una planilla de Google de la persona. En ella: Extensiones > Apps Script. Propiedades del script, escritas por la persona:
| Propiedad | Valor |
|---|---|
FOLIIOS_API_KEY |
La clave de sandbox (sk_sandbox_...) |
FOLIIOS_COMPANY_ID |
El de la empresa de pruebas (empieza por cmp_) |
FOLIIOS_RESOLUTION_ID |
La resolución de numeración de la empresa de pruebas, solo dígitos |
FOLIIOS_API_URL |
Opcional; por defecto https://api.foliios.com |
Paso 1. Crear los archivos
Sección titulada «Paso 1. Crear los archivos»En el editor de Apps Script crea Cliente.gs, Documentos.gs y Receta.gs (el editor quita la extensión) y activa «Mostrar el archivo de manifiesto “appsscript.json”» en la Configuración del proyecto para reemplazarlo. Pega el contenido tal cual.
{ "timeZone": "America/Bogota", "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "oauthScopes": [ "https://www.googleapis.com/auth/script.external_request", "https://www.googleapis.com/auth/spreadsheets.currentonly" ]}// Minimal foliios API client for Apps Script (UrlFetchApp only, no libraries).//// Configuration lives in the Script Properties (Project settings > Script// properties), never in the code: FOLIIOS_API_KEY, FOLIIOS_COMPANY_ID,// FOLIIOS_RESOLUTION_ID and, optionally, FOLIIOS_API_URL.
var FOLIIOS_FINAL = ['accepted', 'rejected', 'failed'];
/** Reads and validates the configuration. Never logs the key. */function foliiosConfig_() { var props = PropertiesService.getScriptProperties(); var config = { apiKey: props.getProperty('FOLIIOS_API_KEY'), companyId: props.getProperty('FOLIIOS_COMPANY_ID'), resolutionId: props.getProperty('FOLIIOS_RESOLUTION_ID'), baseUrl: (props.getProperty('FOLIIOS_API_URL') || 'https://api.foliios.com').replace(/\/+$/, ''), }; var missing = ['apiKey', 'companyId', 'resolutionId'].filter(function (name) { return !config[name]; }); if (missing.length > 0) { throw new Error('Faltan propiedades del script: ' + missing.join(', ') + ' (ver el README)'); } if (config.apiKey.indexOf('sk_sandbox_') !== 0) { throw new Error('FOLIIOS_API_KEY debe ser una clave de sandbox (sk_sandbox_...)'); } return config;}
/** * One call. A network failure, 429 or 503 is retried with growing pauses and the * SAME headers (so the same Idempotency-Key). Other non-2xx answers throw. * Returns {status, headers (lowercase names), body}. */function foliiosRequest_(config, method, path, body, extraHeaders) { var headers = { Authorization: 'Bearer ' + config.apiKey, Accept: 'application/json' }; Object.keys(extraHeaders || {}).forEach(function (name) { headers[name] = extraHeaders[name]; }); var options = { method: method, headers: headers, muteHttpExceptions: true, followRedirects: false }; if (body !== undefined && body !== null) { options.contentType = 'application/json'; options.payload = JSON.stringify(body); } for (var attempt = 0; ; attempt++) { var response = null; try { response = UrlFetchApp.fetch(config.baseUrl + path, options); } catch (error) { if (attempt >= 3) throw error; Utilities.sleep(Math.pow(2, attempt) * 1000); continue; } var status = response.getResponseCode(); var text = response.getContentText(); var parsed = text ? foliiosParse_(text) : null; if (status >= 200 && status < 300) { return { status: status, headers: foliiosLower_(response.getHeaders()), body: parsed }; } if ((status === 429 || status === 503) && attempt < 3) { Utilities.sleep(Math.pow(2, attempt) * 1000); continue; } throw new Error('HTTP ' + status + ': ' + String(text).slice(0, 500)); }}
function foliiosParse_(text) { try { return JSON.parse(text); } catch (error) { return null; }}
function foliiosLower_(headers) { var lower = {}; Object.keys(headers || {}).forEach(function (name) { lower[name.toLowerCase()] = headers[name]; }); return lower;}
/** Issues a document. Returns {doc, replayed}; replayed is true when the key had been used. */function foliiosEmitir_(config, body, idempotencyKey, scenario) { var headers = { 'Idempotency-Key': idempotencyKey }; if (scenario) headers['Foliios-Sandbox-Scenario'] = scenario; var res = foliiosRequest_(config, 'POST', '/v1/companies/' + config.companyId + '/documents', body, headers); return { doc: res.body, replayed: res.headers['idempotent-replayed'] === 'true' };}
function foliiosObtener_(config, documentId) { return foliiosRequest_(config, 'GET', '/v1/companies/' + config.companyId + '/documents/' + documentId).body;}
function foliiosReenviar_(config, documentId, body) { return foliiosRequest_(config, 'POST', '/v1/companies/' + config.companyId + '/documents/' + documentId + '/resubmit', body).body;}
/** * Apps Script cannot receive webhooks with their headers (doPost does not read * them, so the signature cannot be verified), so it asks for the state: polls * the document until it is final or maxSeconds pass (the script has 6 minutes * in total). Returns the last document read; check its status. */function foliiosEsperar_(config, documentId, maxSeconds, everySeconds) { var waited = 0; var every = everySeconds || 2; for (;;) { var doc = foliiosObtener_(config, documentId); if (FOLIIOS_FINAL.indexOf(doc.status) >= 0 || waited >= maxSeconds) return doc; Utilities.sleep(every * 1000); waited += every; }}
function foliiosVerificacion_(config) { return foliiosRequest_(config, 'GET', '/v1/companies/' + config.companyId + '/sandbox/integration-check').body;}// Request bodies for the sandbox recipe. Pure functions: no network, no properties.
// A buyer taken from the public documentation examples (a company, not a person).function foliiosCliente_() { return { person_type: 'juridical', identification: { type: 'nit', number: '900373076' }, name: 'Comercializadora Andina S.A.S.', emails: ['facturas@andina.example'], };}
/** A line. Amounts and quantities are decimal STRINGS, never JSON numbers. */function foliiosLinea_(descripcion, cantidad, precioUnitario, ivaPorcentaje) { return { code: 'VENTA', description: String(descripcion), quantity: String(cantidad), unit: '94', unit_price: Number(precioUnitario).toFixed(2), taxes: [{ scheme: 'iva', percent: String(ivaPorcentaje) }], };}
function foliiosLineaDeEjemplo_() { return foliiosLinea_('Parqueadero por horas', 3, 4000, 19);}
function foliiosFactura_(resolutionId, lineas) { return { resolution_id: resolutionId, type: 'invoice', customer: foliiosCliente_(), lines: lineas || [foliiosLineaDeEjemplo_()], payment: { form: 'cash', means: '10' }, };}
/** The corrected body of a rejected document: it keeps its resolution and number, so neither goes. */function foliiosReenvio_() { var body = foliiosFactura_(''); delete body.resolution_id; return body;}
/** * A credit note ('credit_note') or a debit note ('debit_note') over an accepted * invoice. The reference is the number, the CUFE and the issue date of that * exact invoice. A note is numbered with its own series: no resolution_id. */function foliiosNota_(tipo, factura) { if (tipo !== 'credit_note' && tipo !== 'debit_note') throw new Error('Tipo de nota desconocido: ' + tipo); var credito = tipo === 'credit_note'; var linea = foliiosLineaDeEjemplo_(); linea.quantity = '1'; return { type: tipo, customer: foliiosCliente_(), lines: [linea], correction: { concept: credito ? 'partial_return' : 'interest', description: credito ? 'Devolución de 1 hora' : 'Interés por mora', billing_reference: { number: factura.number, cufe: factura.cufe, issue_date: String(factura.issued_at).slice(0, 10), }, }, };}// Sandbox recipe for Google Sheets. Run the steps in order from the editor// (Run > paso1Factura, ...) or from the "foliios" menu of the sheet.//// Apps Script stops a run at 6 minutes, so each step is short and leaves its// result in the script property FOLIIOS_ESTADO; a step can be run again.
function onOpen() { SpreadsheetApp.getUi() .createMenu('foliios') .addItem('Emitir las ventas de la hoja', 'emitirVentasDeLaHoja') .addItem('Verificar la integración', 'paso6Verificacion') .addToUi();}
function estado_() { return JSON.parse(PropertiesService.getScriptProperties().getProperty('FOLIIOS_ESTADO') || '{}');}
function guardarEstado_(estado) { PropertiesService.getScriptProperties().setProperty('FOLIIOS_ESTADO', JSON.stringify(estado));}
/** One stable key per case and per run: a repeated step does not issue twice. */function clave_(estado, nombre) { if (!estado.corrida) { estado.corrida = Utilities.getUuid().slice(0, 8); guardarEstado_(estado); } return 'receta-' + estado.corrida + '-' + nombre;}
// 1. Invoice. The answer is 202 `pending`: poll until it is final.function paso1Factura() { var config = foliiosConfig_(); var estado = estado_(); var emitida = foliiosEmitir_(config, foliiosFactura_(config.resolutionId), clave_(estado, 'factura')); var factura = foliiosEsperar_(config, emitida.doc.id, 120); if (factura.status !== 'accepted') throw new Error('La factura quedó ' + factura.status); estado.factura = { id: factura.id }; guardarEstado_(estado); Logger.log('Factura ' + factura.number + ': ' + factura.status);}
// 2. Credit and debit note over THAT invoice (its number and CUFE).function paso2Notas() { var config = foliiosConfig_(); var estado = estado_(); if (!estado.factura) throw new Error('Corre primero paso1Factura'); var factura = foliiosObtener_(config, estado.factura.id); ['credit_note', 'debit_note'].forEach(function (tipo) { var emitida = foliiosEmitir_(config, foliiosNota_(tipo, factura), clave_(estado, tipo)); var nota = foliiosEsperar_(config, emitida.doc.id, 120); if (nota.status !== 'accepted') throw new Error(tipo + ' quedó ' + nota.status); Logger.log(tipo + ' ' + nota.number + ': ' + nota.status); }); guardarEstado_(estado);}
// 3. Safe retry: same key and same body returns the same document, not a new one.function paso3Reintento() { var config = foliiosConfig_(); var estado = estado_(); if (!estado.factura) throw new Error('Corre primero paso1Factura'); var otra = foliiosEmitir_(config, foliiosFactura_(config.resolutionId), clave_(estado, 'factura')); if (otra.doc.id !== estado.factura.id || !otra.replayed) throw new Error('El reintento creó otro documento'); Logger.log('Reintento idempotente: el mismo documento ' + otra.doc.id);}
// 4. Rejection and resubmission: a scenario forces the DIAN rule FAD06.function paso4Rechazo() { var config = foliiosConfig_(); var estado = estado_(); var emitida = foliiosEmitir_(config, foliiosFactura_(config.resolutionId), clave_(estado, 'rechazo'), 'rejected_fad06'); var rechazada = foliiosEsperar_(config, emitida.doc.id, 120); if (rechazada.status === 'accepted' && emitida.replayed) { Logger.log('Este paso ya se completó: el documento rechazado fue reenviado y aceptado.'); return; } if (rechazada.status !== 'rejected') throw new Error('Se esperaba rejected y quedó ' + rechazada.status); Logger.log('Rechazada por ' + rechazada.dian.rules.map(function (regla) { return regla.code; }).join(', ')); foliiosReenviar_(config, rechazada.id, foliiosReenvio_()); var corregida = foliiosEsperar_(config, rechazada.id, 120); if (corregida.status !== 'accepted') throw new Error('El reenvío quedó ' + corregida.status); Logger.log('Reenviada: ' + corregida.status);}
// 5. Contingency: the simulated DIAN does not answer; the document is accepted// by itself after a few minutes. Run this step again until it says "accepted".function paso5Contingencia() { var config = foliiosConfig_(); var estado = estado_(); if (!estado.contingencia) { var emitida = foliiosEmitir_(config, foliiosFactura_(config.resolutionId), clave_(estado, 'contingencia'), 'dian_unavailable'); estado.contingencia = { id: emitida.doc.id }; guardarEstado_(estado); } var documento = foliiosEsperar_(config, estado.contingencia.id, 240, 5); if (FOLIIOS_FINAL.indexOf(documento.status) < 0) { Logger.log('Sigue en ' + documento.status + '. Espera y corre paso5Contingencia otra vez.'); return; } if (documento.status !== 'accepted') throw new Error('La contingencia quedó ' + documento.status); Logger.log('Contingencia: ' + documento.status);}
// 6. The check reads the data of the company; what this script says does not count.function paso6Verificacion() { var verificacion = foliiosVerificacion_(foliiosConfig_()); verificacion.derived.forEach(function (item) { Logger.log('check ' + item.id + ': ' + item.status); }); Logger.log('complete: ' + verificacion.complete); return verificacion.complete;}
/** * The real use: issue the sales of the sheet "Ventas". Columns, from row 2: * A descripción, B cantidad, C precio unitario, D IVA (%). The script writes * E documento, F número y G estado, and skips the rows that already have a * document. The key is stable per row and content, so running it twice (or * after a network failure) never issues the same sale twice. */function emitirVentasDeLaHoja() { var config = foliiosConfig_(); var hoja = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Ventas'); if (!hoja) throw new Error('No existe la hoja "Ventas"'); var filas = hoja.getLastRow() - 1; if (filas < 1) return; var valores = hoja.getRange(2, 1, filas, 7).getValues(); valores.forEach(function (fila, indice) { if (fila[4] || !fila[0]) return; var numeroDeFila = indice + 2; var contenido = [fila[0], fila[1], fila[2], fila[3]].join('|'); var clave = 'venta-' + numeroDeFila + '-' + resumen_(contenido); var linea = foliiosLinea_(fila[0], fila[1], fila[2], fila[3]); var emitida = foliiosEmitir_(config, foliiosFactura_(config.resolutionId, [linea]), clave); var documento = foliiosEsperar_(config, emitida.doc.id, 60); hoja.getRange(numeroDeFila, 5, 1, 3).setValues([[documento.id, documento.number, documento.status]]); });}
function resumen_(texto) { var bytes = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, texto); return bytes .map(function (byte) { return ('0' + (byte & 0xff).toString(16)).slice(-2); }) .join('') .slice(0, 8);}Paso 2. Correr los casos, en orden
Sección titulada «Paso 2. Correr los casos, en orden»Desde el editor, selecciona la función y pulsa Ejecutar. La primera vez, Google pide autorizar el acceso a servicios externos y a la planilla: que lo apruebe la persona. Mira el resultado en el registro de ejecución.
| Caso | Qué hace el código | Qué comprobación de la verificación lo prueba |
|---|---|---|
| Factura | Emite y consulta hasta accepted |
invoice_accepted |
| Nota crédito y nota débito | Dos notas que referencian el número y el CUFE de esa factura | credit_and_debit_note |
| Reintento seguro | Repite la emisión con la misma Idempotency-Key y el mismo cuerpo: devuelve el mismo documento (Idempotent-Replayed: true) |
idempotency_replayed (declarada) |
| Rechazo y reenvío | El escenario rejected_fad06 rechaza; se corrige y se reenvía con resubmit |
rejection_corrected (declarada) |
| Contingencia | El escenario dian_unavailable: el documento pasa por contingencia y termina accepted |
contingency |
| Función | Hace |
|---|---|
paso1Factura |
Factura |
paso2Notas |
Nota crédito y nota débito |
paso3Reintento |
Reintento con la misma clave |
paso4Rechazo |
Rechazo y reenvío |
paso5Contingencia |
Contingencia; repítelo hasta que diga accepted (tarda unos minutos) |
paso6Verificacion |
Consulta final; el registro termina en complete: true |
Si un paso falla por red, ejecútalo de nuevo: usa la misma Idempotency-Key, así que no emite dos veces.
Paso 3. Usarlo con las ventas de la hoja
Sección titulada «Paso 3. Usarlo con las ventas de la hoja»emitirVentasDeLaHoja (también en el menú «foliios» de la planilla) lee la hoja Ventas desde la fila 2: A descripción, B cantidad, C precio unitario, D IVA (%). Escribe en E el documento, en F el número y en G el estado, y se salta las filas que ya tienen documento. La Idempotency-Key sale de la fila y su contenido, así que ejecutarlo dos veces no emite la misma venta dos veces. Si la persona cambia una fila ya emitida, es otra venta: emítela con una nota, no editando la fila.
Verificar que quedó completo
Sección titulada «Verificar que quedó completo»La receta termina consultando GET /v1/companies/{company_id}/sandbox/integration-check. Esa consulta es de solo lectura y calcula el resultado desde los documentos de la empresa; lo que diga tu código o tú no cuenta.
- La integración está terminada solo con
complete: true. derivedtraeinvoice_accepted,credit_and_debit_note,contingencyywebhook_delivered. En esta recetawebhook_deliveredquedanot_applicableporque no hay endpoint.declaredtraerejection_correctedeidempotency_replayed: foliios no puede comprobarlas. Díselas a la persona como no verificadas.- La evidencia vale 30 días y un reinicio del sandbox la borra: pasado ese tiempo las comprobaciones vuelven a
pendingy se repite el caso que corresponda.
Detalle de la respuesta en la referencia generada. Las reglas completas de cierre están en el manual del agente, fase 11.
Si algo falla
Sección titulada «Si algo falla»| Síntoma | Qué hacer |
|---|---|
401 |
La clave está mal escrita o revocada. Pídele a la persona que la revise en las propiedades del script; no se la pidas por el chat. |
403 sandbox_only |
La clave no es de sandbox. Detente y consulta a la persona. |
404 |
El company_id es incorrecto: es el de la empresa de pruebas del ambiente Sandbox (empieza por cmp_). |
422 |
Un campo rompe una regla: errors dice cuál. Revisa los campos; si falta un dato fiscal que solo la persona tiene, pregúntaselo. |
429 |
Espera y reintenta; los límites vigentes están en escenarios, «Límites». El código ya reintenta con pausa creciente. |
Un documento sigue pending o en contingencia |
Espera: la contingencia simulada tarda unos minutos. No lo emitas de nuevo. |
complete: false después de correr todo |
Mira cuál comprobación de derived sigue pending y repite solo ese caso. Si window.truncated es true, el historial era largo. |
Exceeded maximum execution time |
Pasaron los 6 minutos de Apps Script. Ejecuta el paso otra vez: retoma donde quedó. |
Llevarlo al proyecto de la persona
Sección titulada «Llevarlo al proyecto de la persona»Esta receta emite ventas de ejemplo. Para que sirva de verdad, conecta el punto donde nace una venta del proyecto (fase 1 del manual del agente) con foliiosFactura_ y foliiosEmitir_, y deriva la Idempotency-Key de la venta (por ejemplo su número interno): debe ser la misma en cada reintento de esa venta. No copies límites ni escenarios en tu código: consúltalos en la referencia generada.