Ir al contenido

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.

  • Visual FoxPro 9 (SP2). Los ejemplos usan TRY ... CATCH, CREATEBINARY() y las conversiones a UTF-8 de STRCONV().
  • 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.
  • 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.

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é.

Pon estas funciones en un programa, por ejemplo flashpi.prg, y cárgalo con SET PROCEDURE TO flashpi ADDITIVE.

flashpi.prg
#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 loHttp
ENDFUNC
* 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 support
FUNCTION 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.requestId
ENDFUNC
* A text value as a JSON string: trimmed (table fields are padded with
* spaces) and escaped
FUNCTION 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 document
FUNCTION 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)
ENDFUNC
  1. 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 un TABLEREVERT() o un cierre inesperado no la pierdan.

  2. 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 ... ENDTEXT con TEXTMERGE escribe el JSON con los valores de tus tablas; para varias líneas, arma cada ítem igual dentro de un SCAN y únelos con comas.

  3. Envíala con IssueDocument(), que reintenta sola cuando es seguro hacerlo.

SET PROCEDURE TO flashpi ADDITIVE
LOCAL lcKey, lcBody, lcError, loDoc
* 1. One key per invoice, saved before the first attempt
SELECT invoices
IF 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
ENDIF
ENDIF
lcKey = ALLTRIM(invoices.idem_key)
* 2. The body, with the values your system keeps in its tables
lcIssuerId = "<demo issuer id>" && from the console's Overview
lcCustomerId = "1790000000001"
lcCustomerName = "Supermercados La Pradera S.A."
lcDescription = "Monthly accounting services"
lnQuantity = 1
lnUnitPrice = 450
lnTotal = 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 it
loDoc = IssueDocument("/v1/invoices", lcBody, lcKey, @lcError)
IF ISNULL(loDoc)
MESSAGEBOX(lcError, 48, "Flashpi")
RETURN
ENDIF
* Keep the document's id: it reads the document, its RIDE and its XML
REPLACE 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 invoices
IF 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")
ENDIF

La 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).

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.

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, 409 idempotency_key_in_progress y un 429 cuyo Retry-After es de 60 segundos o menos. Un 429 que 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 es 422 idempotency_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 422 idempotency_key_reused con el documentId del comprobante que creó la clave, e IssueDocument() lee ese comprobante. Si cambiaste la factura dentro de las 24 horas, el 422 no trae documentId: 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.
  • 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 un 202 con pending_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, rejected o failed). 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/events lista los eventos que te perdiste. Un comprobante failed todavía no genera evento: búscalo también con GET /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, lcError
lnArea = SELECT()
USE invoices AGAIN IN 0 ALIAS inv_poll SHARED
SELECT inv_poll
SCAN 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_poll
ENDSCAN
USE IN inv_poll
SELECT (lnArea)

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 file
FUNCTION 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.
ENDFUNC
lcFile = "C:\Invoices\factura-" + ALLTRIM(invoices.doc_number)
IF !SaveDocumentFile(ALLTRIM(invoices.flashpi_id), "pdf", lcFile + ".pdf", @lcError)
MESSAGEBOX(lcError, 48, "Flashpi")
ENDIF
SaveDocumentFile(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.

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.

  • 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, y responseText decodifica la respuesta, que dice charset=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/json o application/json; charset=utf-8. Un charset que no empiece por utf-, como windows-1252 o latin1, se rechaza con 415 unsupported_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).

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.