Visual FoxPro
Muchos sistemas contables y ERP de Ecuador están escritos en Visual FoxPro y corren en las propias computadoras de la oficina. Pueden emitir comprobantes electrónicos con Flashpi sin un SDK: Flashpi es una API HTTPS que recibe y devuelve JSON, y Windows ya trae el cliente HTTP que Visual FoxPro necesita. Esta guía emite la factura del Inicio rápido desde Visual FoxPro 9, con el emisor demo compartido en el ambiente de pruebas del SRI.
Antes de empezar
Sección titulada «Antes de empezar»- Visual FoxPro 9 (SP2). Los ejemplos usan
TRY ... CATCH,CREATEBINARY()y las conversiones a UTF-8 deSTRCONV(). - Windows 10 u 11, o Windows Server 2016 o posterior, en la computadora que llama a la API. Visual FoxPro no tiene TLS propio: usa el cliente HTTP de Windows (consulta TLS 1.2 y versiones de Windows).
- Una API key de prueba (
fp_test_), creada en API keys de la consola con Pruebas elegido, con alcance Toda la cuenta (account) y acceso de lectura y escritura (consulta Autenticación y API keys), y el ID del emisor demo, del Resumen de la consola (consulta Sandbox y emisor demo). - Una librería de JSON para leer las respuestas: Visual FoxPro no trae un lector de JSON. Los ejemplos usan nfJson (
nfJsonRead()), gratuita en VFPX; cualquier otra funciona igual.
Protege la API key
Sección titulada «Protege la API key»- Lo mejor: un servidor llama a Flashpi. Las PC le piden a un pequeño servicio en tu servidor (o en el servidor del ERP) que emita, y solo ese servidor tiene la llave. Las PC nunca la ven, y la dirección pública del servidor puede ser la única de la lista de IP permitidas de la llave.
- Si cada PC llama a la API, guarda la llave solo en esa PC, legible por el usuario de Windows que usa el sistema: una variable de entorno de ese usuario (los ejemplos leen
GETENV("FLASHPI_API_KEY")) o, mejor, cifrada con la API de protección de datos de Windows (DPAPI), que solo ese usuario o esa computadora pueden descifrar. - Si vendes tu sistema a otras empresas, cada cliente crea su propia cuenta y sus llaves de Flashpi, e ingresa su llave al instalar el sistema. Nunca incluyas tu propia llave en el producto.
- Dale a cada llave solo lo que necesita: una llave con alcance de un emisor para el único RUC por el que emite el sistema, y una lista de IP permitidas con la dirección pública de la oficina cuando es fija.
- Empieza con una llave de prueba (
fp_test_): sus comprobantes no tienen efecto tributario. Usa una llave de producción (fp_live_) solo en las computadoras que emiten de verdad, cuando todo funcione. - Nunca escribas la llave en un log ni la muestres en pantalla. Si se filtra, revócala en la consola: deja de funcionar al instante.
Comprueba la conexión
Sección titulada «Comprueba la conexión»GET /v1/status no necesita llave, así que te dice si la computadora llega a la API:
loHttp = CREATEOBJECT("MSXML2.ServerXMLHTTP.6.0")loHttp.open("GET", "https://api.flashpi.com.ec/v1/status", .F.)loHttp.send()? loHttp.status, loHttp.responseText && 200 {"status":"operational"}Si send() falla con A security error occurred (0x80072F8F), Windows no pudo establecer TLS con la API: consulta TLS 1.2 y versiones de Windows.
Usa MSXML2.ServerXMLHTTP.6.0 o WinHttp.WinHttpRequest.5.1. Evita MSXML2.XMLHTTP y Microsoft.XMLHTTP: están hechos para Internet Explorer, siguen su configuración y pueden guardar respuestas en caché.
Las funciones de apoyo
Sección titulada «Las funciones de apoyo»Pon estas funciones en un programa, por ejemplo flashpi.prg, y cárgalo con SET PROCEDURE TO flashpi ADDITIVE.
#DEFINE FLASHPI_API "https://api.flashpi.com.ec"
* One HTTPS call to Flashpi. Returns the MSXML object with the answer* (status, responseText, responseBody), or .NULL. when no answer arrived:* a network error, a timeout or a TLS failure. tcError gets the reason.FUNCTION FlashpiCall(tcMethod, tcPath, tcBody, tcIdempotencyKey, tcError) LOCAL loHttp, loError tcError = "" loHttp = CREATEOBJECT("MSXML2.ServerXMLHTTP.6.0") * Resolve, connect, send and receive timeouts, in milliseconds. Receiving * waits longer than the sync mode's wait (15 seconds by default). loHttp.setTimeouts(5000, 10000, 30000, 30000) TRY loHttp.open(tcMethod, FLASHPI_API + tcPath, .F.) loHttp.setRequestHeader("Authorization", "Bearer " + GETENV("FLASHPI_API_KEY")) * Error messages in Spanish, to show them to the user loHttp.setRequestHeader("Accept-Language", "es") IF !EMPTY(tcIdempotencyKey) loHttp.setRequestHeader("Idempotency-Key", tcIdempotencyKey) ENDIF IF EMPTY(tcBody) loHttp.send() ELSE loHttp.setRequestHeader("Content-Type", "application/json; charset=utf-8") * The JSON's UTF-8 bytes, so accents and the ñ arrive as they are loHttp.send(CREATEBINARY(STRCONV(tcBody, 9))) ENDIF CATCH TO loError tcError = loError.Message loHttp = .NULL. ENDTRY RETURN loHttpENDFUNC
* Issues a document, retrying safely: the same body and the same* Idempotency-Key on every attempt, so it is never issued twice. Returns the* document as an object, or .NULL. with tcError set.FUNCTION IssueDocument(tcPath, tcBody, tcIdempotencyKey, tcError) LOCAL lnAttempt, lnWait, loHttp, loProblem DECLARE Sleep IN kernel32 INTEGER nMilliseconds FOR lnAttempt = 1 TO 5 lnWait = 2 ^ (lnAttempt - 1) && 1, 2, 4 and 8 seconds between attempts loHttp = FlashpiCall("POST", tcPath, tcBody, tcIdempotencyKey, @tcError) DO CASE CASE ISNULL(loHttp) * No answer: Flashpi may have received the request, so send it again CASE INLIST(loHttp.status, 200, 202) RETURN nfJsonRead(loHttp.responseText) CASE loHttp.status >= 500 tcError = ProblemText(loHttp) OTHERWISE tcError = ProblemText(loHttp) IF LEFT(LTRIM(loHttp.responseText), 1) <> "{" * Not problem details, such as a proxy's error page RETURN .NULL. ENDIF loProblem = nfJsonRead(loHttp.responseText) DO CASE CASE loProblem.code == "idempotency_key_in_progress" * The first attempt is still running: wait and send it again CASE loProblem.code == "idempotency_key_reused" AND TYPE("loProblem.documentId") = "C" * The key already created a document: read it instead of issuing again loHttp = FlashpiCall("GET", "/v1/documents/" + loProblem.documentId, "", "", @tcError) IF !ISNULL(loHttp) AND loHttp.status = 200 RETURN nfJsonRead(loHttp.responseText) ENDIF IF !ISNULL(loHttp) tcError = ProblemText(loHttp) ENDIF RETURN .NULL. CASE loHttp.status = 429 AND VAL(loHttp.getResponseHeader("Retry-After")) <= 60 lnWait = VAL(loHttp.getResponseHeader("Retry-After")) OTHERWISE * Fix the request first: sent unchanged, it gets the same answer RETURN .NULL. ENDCASE ENDCASE IF lnAttempt < 5 Sleep(lnWait * 1000) ENDIF ENDFOR RETURN .NULL.ENDFUNC
* The text of an error answer (RFC 9457 problem details) to show or log:* detail and code, each field error, and the requestId for supportFUNCTION ProblemText(toHttp) LOCAL loProblem, lcText, lnI IF LEFT(LTRIM(toHttp.responseText), 1) <> "{" * Not problem details, such as a proxy's error page: the status, and the * request ID when the API sent one lcText = "HTTP " + TRANSFORM(toHttp.status) TRY lcText = lcText + CHR(13) + "requestId: " + toHttp.getResponseHeader("x-request-id") CATCH * No x-request-id header: the answer did not come from the API ENDTRY RETURN lcText ENDIF loProblem = nfJsonRead(toHttp.responseText) lcText = loProblem.detail + " (" + loProblem.code + ")" IF TYPE("loProblem.errors[1].field") = "C" FOR lnI = 1 TO ALEN(loProblem.errors) lcText = lcText + CHR(13) + loProblem.errors[lnI].field + ": " + ; loProblem.errors[lnI].message ENDFOR ENDIF RETURN lcText + CHR(13) + "requestId: " + loProblem.requestIdENDFUNC
* A text value as a JSON string: trimmed (table fields are padded with* spaces) and escapedFUNCTION JsonText(tcValue) LOCAL lcText, lnI lcText = ALLTRIM(tcValue) lcText = STRTRAN(lcText, "\", "\\") lcText = STRTRAN(lcText, '"', '\"') lcText = STRTRAN(lcText, CHR(13), "\r") lcText = STRTRAN(lcText, CHR(10), "\n") lcText = STRTRAN(lcText, CHR(9), "\t") * Any other control character, such as CHR(0) or CHR(26), as \u0000 FOR lnI = 0 TO 31 lcText = STRTRAN(lcText, CHR(lnI), "\u" + RIGHT(TRANSFORM(lnI, "@0"), 4)) ENDFOR RETURN '"' + lcText + '"'ENDFUNC
* An amount or a quantity as the API takes it: a string with a dot,* whatever SET POINT says (a comma in many Ecuadorian setups)FUNCTION JsonDecimal(tnValue, tnDecimals) RETURN '"' + CHRTRAN(ALLTRIM(STR(tnValue, 20, tnDecimals)), SET("POINT"), ".") + '"'ENDFUNC
* A new GUID without braces: the Idempotency-Key of one documentFUNCTION NewGuid() LOCAL lcGuid, lcText DECLARE INTEGER CoCreateGuid IN ole32 STRING @pGuid DECLARE INTEGER StringFromGUID2 IN ole32 STRING rGuid, STRING @lpsz, INTEGER cchMax lcGuid = REPLICATE(CHR(0), 16) lcText = REPLICATE(CHR(0), 80) CoCreateGuid(@lcGuid) StringFromGUID2(lcGuid, @lcText, 40) * {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} in UTF-16: 38 characters, 76 bytes RETURN SUBSTR(STRCONV(LEFT(lcText, 76), 6), 2, 36)ENDFUNCEmite la factura
Sección titulada «Emite la factura»-
Dale a la factura su Idempotency-Key y guárdala con la factura antes del primer intento. Un reintento, incluso después de reiniciar el programa o la PC, envía la misma clave, y Flashpi le responde con el comprobante que creó el primer intento en lugar de emitir otro (consulta Idempotencia). Aquí la clave vive en un campo
idem_key C(36)de la tabla de facturas. Si la tabla tiene buffering,TABLEUPDATE()la escribe en disco enseguida, para que unTABLEREVERT()o un cierre inesperado no la pierdan. -
Arma el cuerpo JSON. Los montos y las cantidades son strings decimales con punto. Envías el precio y la tarifa de IVA; Flashpi calcula el subtotal, el IVA (
67.50) y el total (517.50), y los pagos deben sumar ese total.TEXT ... ENDTEXTconTEXTMERGEescribe el JSON con los valores de tus tablas; para varias líneas, arma cada ítem igual dentro de unSCANy únelos con comas. -
Envíala con
IssueDocument(), que reintenta sola cuando es seguro hacerlo.
SET PROCEDURE TO flashpi ADDITIVELOCAL lcKey, lcBody, lcError, loDoc
* 1. One key per invoice, saved before the first attemptSELECT invoicesIF EMPTY(invoices.idem_key) REPLACE idem_key WITH NewGuid() IN invoices * With a buffered table, write the key to disk now IF CURSORGETPROP("Buffering", "invoices") > 1 AND !TABLEUPDATE(0, .F., "invoices") MESSAGEBOX("The invoice could not be saved", 48, "Flashpi") RETURN ENDIFENDIFlcKey = ALLTRIM(invoices.idem_key)
* 2. The body, with the values your system keeps in its tableslcIssuerId = "<demo issuer id>" && from the console's OverviewlcCustomerId = "1790000000001"lcCustomerName = "Supermercados La Pradera S.A."lcDescription = "Monthly accounting services"lnQuantity = 1lnUnitPrice = 450lnTotal = 517.50
TEXT TO lcBody TEXTMERGE NOSHOW{ "issuerId": <<JsonText(lcIssuerId)>>, "establishment": "001", "emissionPoint": "001", "customer": { "idType": "ruc", "id": <<JsonText(lcCustomerId)>>, "name": <<JsonText(lcCustomerName)>> }, "items": [ { "code": "SRV-001", "description": <<JsonText(lcDescription)>>, "quantity": <<JsonDecimal(lnQuantity, 2)>>, "unitPrice": <<JsonDecimal(lnUnitPrice, 2)>>, "taxes": [{ "type": "vat", "rate": "15" }] } ], "payments": [{ "method": "other_financial_system", "amount": <<JsonDecimal(lnTotal, 2)>> }], "options": { "waitForAuthorization": true }}ENDTEXT
* 3. Send itloDoc = IssueDocument("/v1/invoices", lcBody, lcKey, @lcError)IF ISNULL(loDoc) MESSAGEBOX(lcError, 48, "Flashpi") RETURNENDIF
* Keep the document's id: it reads the document, its RIDE and its XMLREPLACE flashpi_id WITH loDoc.id, doc_status WITH loDoc.status, ; doc_number WITH loDoc.number, access_key WITH loDoc.accessKey, ; sent_at WITH DATETIME(), next_check WITH DATETIME() IN invoicesIF CURSORGETPROP("Buffering", "invoices") > 1 AND !TABLEUPDATE(0, .F., "invoices") * Sent again, its key brings back the same document MESSAGEBOX("The invoice was issued but could not be saved", 48, "Flashpi")ENDIFLa misma función emite los demás comprobantes: solo cambian la ruta y el cuerpo (/v1/credit-notes, /v1/withholdings y los demás, consulta Emitir comprobantes).
Lee la respuesta
Sección titulada «Lee la respuesta»| Respuesta | Qué significa | Qué hacer |
|---|---|---|
200 OK |
Solo en modo síncrono: el SRI resolvió el comprobante dentro de la espera. status es authorized, rejected o failed. |
authorized: descarga e imprime el RIDE. rejected: los motivos del SRI están en sriMessages. failed: failure dice por qué. |
202 Accepted |
El comprobante quedó guardado y sigue su curso: processing, o pending_sri cuando la espera terminó antes de que el SRI respondiera. |
Guarda su id y síguelo (consulta Modo síncrono, consultas o webhooks). No se pierde nada. |
4xx |
Un error que no se resuelve solo, como un objeto problem details. | Muestra detail, decide según code, corrige y vuelve a enviar. Solo vale la pena reintentar sin cambios 409 idempotency_key_in_progress y un 429 con un Retry-After corto. |
5xx, o sin respuesta |
La solicitud pudo llegar o no. | Envíala otra vez con la misma clave y el mismo cuerpo, esperando más cada vez. |
El comprobante que devuelve la API es el del Inicio rápido: id, status, number (001-001-000004821), accessKey (la clave de acceso de 49 dígitos), totals, authorization, sriMessages y lo demás. Una respuesta de error se ve así, en español porque FlashpiCall() envía Accept-Language: es, y ProblemText() la convierte en un solo texto:
{ "type": "https://flashpi.com.ec/docs/errors/validation_failed", "title": "Error de validación", "status": 422, "detail": "Uno o más campos no son válidos. Revisa errors para ver el detalle.", "instance": "/v1/invoices", "code": "validation_failed", "errors": [ { "field": "customer.id", "code": "invalid_check_digit", "message": "El dígito verificador de este número de identificación es incorrecto." } ], "requestId": "01a1412b-9c03-7e55-b1a8-27f4d6e0c913"}Decide según code y errors[].code, que nunca cambian con el idioma; title, detail y message son textos para personas. Cada código tiene su página en Errores.
Reintentos y tiempos de espera
Sección titulada «Reintentos y tiempos de espera»IssueDocument() sigue las reglas de Idempotencia:
- Solo reintenta lo que es seguro reintentar: sin respuesta (un error de red o un tiempo de espera agotado), un
5xx,409idempotency_key_in_progressy un429cuyoRetry-Afteres de 60 segundos o menos. Un429que pide más, como la cuota diaria del emisor demo, se detiene y muestra el error. - Siempre con la misma clave y el mismo cuerpo. Si el primer intento sí llegó, Flashpi responde al reintento con el mismo comprobante y el header
Idempotent-Replayed: true, nunca con una segunda factura. Con la misma clave y otro cuerpo, la respuesta es422idempotency_key_reused. - Después del último intento la factura conserva su clave, así que enviarla más tarde sigue siendo seguro. Dentro de las 24 horas en que Flashpi recuerda la clave, si el primer intento emitió el comprobante, la misma solicitud recibe otra vez esa respuesta. Después, la respuesta es
422idempotency_key_reusedcon eldocumentIddel comprobante que creó la clave, eIssueDocument()lee ese comprobante. Si cambiaste la factura dentro de las 24 horas, el422no traedocumentId: no le des una clave nueva, porque el primer intento pudo haberla emitido; búscala primero en la consola. - El tiempo de espera de recepción (30 segundos aquí) debe ser mayor que la espera del modo síncrono: 15 segundos por defecto, hasta
waitTimeoutSeconds(30 como máximo), más unos segundos. Si no, el programa se rinde justo antes de que llegue la respuesta.
Modo síncrono, consultas o webhooks
Sección titulada «Modo síncrono, consultas o webhooks»- El modo síncrono (
options.waitForAuthorization, como en el ejemplo) sirve para un punto de venta que imprime el RIDE enseguida: la solicitud espera hasta 15 segundos al SRI (waitTimeoutSeconds, de 1 a 30). Cuando el SRI tarda más, la respuesta es un202conpending_sri, y el comprobante sigue por su cuenta (consulta Modo síncrono y asíncrono). - Los webhooks envían cada autorización y cada rechazo a una dirección HTTPS tuya, así que necesitan un servidor al que se pueda llegar desde internet: una PC de escritorio no puede recibirlos. Si tu sistema tiene un servidor o un servicio web, recíbelos ahí (consulta Webhooks).
- Las consultas funcionan desde cualquier PC: lee
GET /v1/documents/{id}hasta que el estado sea final (authorized,rejectedofailed). Espera unos segundos entre lecturas al principio y luego más, por ejemplo un minuto, porque cuando el SRI está caído un comprobante puede tardar horas o días: Flashpi intenta entregarlo al SRI hasta 72 horas desde su creación y espera su decisión hasta 72 horas desde el envío (consulta Límites). Después de que el sistema estuvo cerrado,GET /v1/eventslista los eventos que te perdiste. Un comprobantefailedtodavía no genera evento: búscalo también conGET /v1/documents?status=failed.
Por ejemplo, en el evento Timer de un temporizador con Interval = 5000, en una sola PC o en el servidor: si cada PC lee todas las facturas pendientes de la tabla compartida, las solicitudes se multiplican. La tabla guarda también cuándo se emitió cada factura (sent_at T) y cuándo volver a leerla (next_check T): cada 5 segundos durante el primer minuto y luego cada minuto. El temporizador abre su propia copia de la tabla, así que no mueve el registro ni el área de trabajo de los formularios.
LOCAL lnArea, loHttp, loDoc, lcErrorlnArea = SELECT()USE invoices AGAIN IN 0 ALIAS inv_poll SHAREDSELECT inv_pollSCAN FOR INLIST(ALLTRIM(doc_status), "processing", "pending_sri") AND next_check <= DATETIME() loHttp = FlashpiCall("GET", "/v1/documents/" + ALLTRIM(inv_poll.flashpi_id), "", "", @lcError) IF !ISNULL(loHttp) AND loHttp.status = 200 loDoc = nfJsonRead(loHttp.responseText) REPLACE doc_status WITH loDoc.status IN inv_poll ENDIF * Every 5 seconds in the first minute after issuing, then every minute REPLACE next_check WITH DATETIME() + IIF(DATETIME() - inv_poll.sent_at < 60, 5, 60) IN inv_pollENDSCANUSE IN inv_pollSELECT (lnArea)Descarga el RIDE y el XML
Sección titulada «Descarga el RIDE y el XML»Cuando el comprobante está authorized, descarga su RIDE (el PDF) con GET /v1/documents/{id}/pdf y su XML autorizado con GET /v1/documents/{id}/xml. Flashpi todavía no se los envía por correo al comprador, así que los entrega el emisor (consulta RIDE y correo).
* Saves the RIDE ("pdf") or the XML ("xml") of a document in a fileFUNCTION SaveDocumentFile(tcDocumentId, tcKind, tcFile, tcError) LOCAL loHttp loHttp = FlashpiCall("GET", "/v1/documents/" + tcDocumentId + "/" + tcKind, "", "", @tcError) IF ISNULL(loHttp) RETURN .F. ENDIF IF loHttp.status <> 200 tcError = ProblemText(loHttp) RETURN .F. ENDIF * responseBody holds the file's bytes as they arrived STRTOFILE(loHttp.responseBody, tcFile) RETURN .T.ENDFUNClcFile = "C:\Invoices\factura-" + ALLTRIM(invoices.doc_number)IF !SaveDocumentFile(ALLTRIM(invoices.flashpi_id), "pdf", lcFile + ".pdf", @lcError) MESSAGEBOX(lcError, 48, "Flashpi")ENDIFSaveDocumentFile(ALLTRIM(invoices.flashpi_id), "xml", lcFile + ".xml", @lcError)El RIDE se genera justo después de la autorización, normalmente unos segundos más tarde. Hasta entonces la respuesta es 409 ride_not_available: vuelve a intentarlo un momento después.
TLS 1.2 y versiones de Windows
Sección titulada «TLS 1.2 y versiones de Windows»La API solo acepta TLS 1.2 y TLS 1.3, con conjuntos de cifrado actuales (ECDHE con AES-GCM o ChaCha20). MSXML2.ServerXMLHTTP y WinHttp.WinHttpRequest usan WinHTTP, el TLS del propio Windows, así que lo que cuenta es la versión de Windows, no la de Visual FoxPro:
- Windows 10 y 11, y Windows Server 2016 o posterior se conectan tal como están.
- Windows 7 y Windows Server 2008 R2 no pueden conectarse, ni siquiera después de activar TLS 1.2 en WinHTTP (actualización KB3140245): les faltan esos conjuntos de cifrado, así que el handshake falla con
0x80072F8F. Las versiones anteriores tampoco. - Otras versiones sin soporte de Microsoft pueden fallar igual. Comprueba la conexión en cada computadora antes de confiar en ella.
En una computadora que no puede conectarse, mueve la llamada: que las PC le pidan a un servicio en un Windows con soporte (la opción del servidor de Protege la API key), o actualiza el Windows de esa computadora.
Texto y UTF-8
Sección titulada «Texto y UTF-8»- La API lee y escribe UTF-8. Visual FoxPro guarda el texto en la página de códigos de Windows (1252 en un Windows en español):
STRCONV(..., 9)convierte el cuerpo a UTF-8 antes de enviarlo, yresponseTextdecodifica la respuesta, que dicecharset=utf-8, de vuelta a la página de códigos. Un carácter que la página de códigos no tiene llega como?. - Declara
application/jsonoapplication/json; charset=utf-8. Un charset que no empiece porutf-, comowindows-1252olatin1, se rechaza con415unsupported_media_type. - Recorta los campos de tus tablas (
JsonText()lo hace): un DBF los rellena con espacios. Los campos de texto admiten hasta 300 caracteres, en una línea y sin emojis (consulta Límites).
Con WinHttp.WinHttpRequest.5.1
Sección titulada «Con WinHttp.WinHttpRequest.5.1»WinHttp.WinHttpRequest.5.1 funciona igual: cambia el CREATEOBJECT() de FlashpiCall(). Sus métodos tienen los mismos nombres y argumentos (open, setRequestHeader, setTimeouts, send, status, responseText, responseBody y getResponseHeader), y usa el mismo WinHTTP, así que se conectan las mismas versiones de Windows.
Siguientes pasos
Sección titulada «Siguientes pasos»- Sigue la vida de un comprobante: Ciclo de vida del comprobante.
- Emite con tu propio RUC: crea tu emisor desde su certificado de firma y usa su
issuerId. - Pasa a producción: una llave de producción (
fp_live_) emite con efecto tributario, con las mismas solicitudes. - ¿Prefieres Delphi? Consulta Delphi.