Guía de Integración Rápida con Delphi 7
Integrar el motor VeriFactu en aplicaciones legacy construidas sobre Delphi 7 ya no es un dolor de cabeza metodológico. Hemos abstraído toda la pesadilla técnica de criptografía, firmas de la AEAT y parsing JSON asíncrono detrás de la clase TVFEngine.
A continuación, te mostramos lo sencillo que es implementar el flujo en tu ERP.
WARNING
Aviso sobre el bloque metadata (JSON de Facturas) En algunos JSON de prueba locales verás un bloque raíz llamado "metadata": { "enviar_aeat": false, "simulacion": true, ... }. La API funciona perfectamente CON y SIN ese bloque. Se introdujo exclusivamente por compatibilidad con futuras versiones, pero se aconseja encarecidamente que prescindas totalmente de él para los procesos y desarrollos actuales, enviando únicamente "cabecera" y "detalle".
Vídeo Demostrativo
🏗️ 1. Configuración del Motor (TVFEngine)
Para empezar a dialogar con nuestro servidor, solo necesitas configurar tus credenciales (Token y NIF) y crear la instancia del motor.
var
Cfg: TVfDemoConfig;
Engine: TVFEngine;
begin
// 1. Preparamos el entorno de conexión
// IMPORTANTE: El SDK conecta contra la IP de TU MicroServer (Middleware Local),
// NO contra los servidores en la nube de SystemsFGH.
// 'http://localhost:8000' si está en la misma máquina, o 'http://192.168.1.50:8000' en red local.
Cfg.ApiBaseUrl := 'http://localhost:8000';
Cfg.Token := 'TXT-VFACTU-TU-TOKEN';
Cfg.NifEmisor := 'B12345678';
Cfg.TimeoutMs := 60000; // Tolerancia de 60 segundos si la AEAT va lenta
// 2. Arrancamos el motor
Engine := TVFEngine.Create(Cfg);🚀 2. Modalidad A: Emisión Síncrona ("Todo en Uno")
Recomendado para volúmenes bajos de facturación donde un cajero puede esperar la impresión del Ticket.
Este método coge tu JSON, lo empaqueta, lo manda a la AEAT, esucha la respuesta y marca la factura como "Verificada" automáticamente.
var
Res: TVFIngestaAckResult;
MiJsonFactura: string;
begin
MiJsonFactura := '{ ... aquí va el texto JSON de tu factura ... }';
try
// IngestaYConfirmacion hace absolutamente todo el trabajo sucio por nosotros
// Parámetros: (JSON, Timeout, Sleep_en_milisegundos, NumReintentos, AutoConfirmar)
Res := Engine.IngestaYConfirmacion(MiJsonFactura, Cfg.TimeoutMs, 200, 50, True);
if Res.Timeout then
ShowMessage('La AEAT está tardando. Consulte "Facturas Pendientes" más tarde.')
else if Res.AckHecho then
begin
// ¡ÉXITO! La factura está enviada y oficializada.
ShowMessage('Huella de la AEAT: ' + Res.Pendiente.Huella);
// Ya puedes imprimir el Código QR legal usando:
// Res.Pendiente.UrlQrVerifactu
end
else
// Si a la AEAT no le gusta tu factura (falta DNI, etc...)
ShowMessage('Error AEAT: ' + Res.ErrorMsg);
finally
Engine.Free;
end;
end;📬 3. Modalidad B: Emisión Asíncrona (Alto Rendimiento)
Recomendado para procesos Batch (facturación de cierre de mes de 5.000 tickets). El programa encola en el lado web y listo.
Paso 1: Soltar la factura al servidor y seguir trabajando
var
IngestaResp: TVFIngestaResponse;
begin
IngestaResp := Engine.IngestaFromJson(MiJsonFactura);
if IngestaResp.Ok then
ShowMessage('¡Factura absorbida! ID de seguimiento: ' + IngestaResp.Id)
else
ShowMessage('Error de formato antes de salir: ' + IngestaResp.ErrorMsg);
end;Paso 2: Polling en segundo plano (Típico Timer de Sistema) Más tarde (o en un hilo independiente), le pides a la API: "Oye, ¿la AEAT te ha contestado ya de los paquetes que te dejé antes?"
var
Pend: TVFPendientesResponse;
Item: TVFPendienteItem;
I: Integer;
begin
// Pregunta por los últimos 50 sobres que estaban esperando respuesta
Pend := Engine.GetPendientes(50);
if not Pend.Ok then Exit;
// Recorremos las facturas que YA tienen respuesta de la AEAT
for I := 0 to Length(Pend.Items) - 1 do
begin
Item := Pend.Items[I];
// Status 0..3 significa que el servidor de Hacienda la ha validado o rechazado
if (Item.Status >= 0) and (Item.Status <= 3) then
begin
// 1. Lo registramos en nuestra Base de Datos (Marcamos Fra como enviada)
// MiBBDD.FacturaEnviada(Item.IdEnvio, Item.UrlQrVerifactu);
// 2. Le mandamos el ACUSE DE RECIBO (ACK) al API para limpiar la cola
Engine.AckIndice(Item.IndiceLog);
end;
end;
end;NOTE
Todas las rutinas de la clase TVFEngine están cubiertas contra caídas de red y devoluciones atípicas, devolviendo siempre un registro legible .ErrorMsg para mostrar al usuario final.
📦 Código Fuente del Core SDK
Para que todo el flujo anterior funcione de manera transparente, el SDK de Delphi 7 se apoya en 4 unidades base. Estos archivos no tienen dependencias externas de terceros y son 100% compatibles con Delphi 7 y posteriores.
A continuación se incluye el código fuente completo de cada uno para que puedas revisarlo o integrarlo directamente copiando su contenido.
uConfig.pas
Configuración básica de credenciales y timeouts.
Ver/Ocultar Código Fuente (uConfig.pas)
unit uConfig;
interface
uses
SysUtils, Classes, IniFiles;
type
TVfDemoConfig = record
ApiBaseUrl: string;
TimeoutMs: Integer;
Token: string;
NifEmisor: string;
NUltimos: Integer;
end;
function LoadVfDemoConfig(const FileName: string): TVfDemoConfig;
implementation
function LoadVfDemoConfig(const FileName: string): TVfDemoConfig;
var
Ini: TIniFile;
begin
// Valores por defecto seguros
Result.ApiBaseUrl := '';
Result.TimeoutMs := 10000;
Result.Token := '';
Result.NifEmisor := '';
Result.NUltimos := 50;
if not FileExists(FileName) then
Exit;
Ini := TIniFile.Create(FileName);
try
Result.ApiBaseUrl := Ini.ReadString('api', 'base_url', Result.ApiBaseUrl);
Result.TimeoutMs := Ini.ReadInteger('api', 'timeout_ms', Result.TimeoutMs);
Result.Token := Ini.ReadString('api', 'token', Result.Token);
Result.NifEmisor := Ini.ReadString('demo', 'nif_emisor', Result.NifEmisor);
Result.NUltimos := Ini.ReadInteger('demo', 'n_ultimos', Result.NUltimos);
finally
Ini.Free;
end;
end;
end.uVFJson.pas
Motor ligero de parsing JSON diseñado específicamente para Delphi 7 sin dependencias externas.
Ver/Ocultar Código Fuente (uVFJson.pas)
unit uVFJson;
interface
uses
Classes, SysUtils;
type
TJSONValue = class;
TJSONPair = class;
TJSONObject = class;
TJSONArray = class;
TJSONValue = class
protected
FValue: string;
public
constructor Create(const AValue: string = '');
function GetValue(const Key: string): TJSONValue; virtual; abstract;
property Value: string read FValue write FValue;
function ToJSON: string; virtual; abstract;
end;
TJSONString = class(TJSONValue)
public
function GetValue(const Key: string): TJSONValue; override;
function ToJSON: string; override;
end;
TJSONNumber = class(TJSONValue)
public
function GetValue(const Key: string): TJSONValue; override;
function ToJSON: string; override;
end;
TJSONBool = class(TJSONValue)
public
function GetValue(const Key: string): TJSONValue; override;
function ToJSON: string; override;
end;
TJSONNull = class(TJSONValue)
public
function GetValue(const Key: string): TJSONValue; override;
function ToJSON: string; override;
end;
TJSONPair = class
private
FJsonString: TJSONString;
FJsonValue: TJSONValue;
public
constructor Create(const AStr: string; AVal: TJSONValue); overload;
constructor Create(const AStr: string; const AValStr: string); overload;
destructor Destroy; override;
property JsonString: TJSONString read FJsonString;
property JsonValue: TJSONValue read FJsonValue;
end;
TJSONObject = class(TJSONValue)
private
FPairs: TList;
function GetPair(Index: Integer): TJSONPair;
function GetCount: Integer;
public
constructor Create;
destructor Destroy; override;
function GetValue(const Key: string): TJSONValue; override;
procedure AddPair(const Str: string; Val: TJSONValue); overload;
procedure AddPair(const Str: string; const ValStr: string); overload;
function ToJSON: string; override;
property Count: Integer read GetCount;
property Pairs[Index: Integer]: TJSONPair read GetPair;
class function ParseJSONValue(const Json: string): TJSONValue;
end;
TJSONArray = class(TJSONValue)
private
FItems: TList;
function GetItem(Index: Integer): TJSONValue;
function GetCount: Integer;
public
constructor Create;
destructor Destroy; override;
function GetValue(const Key: string): TJSONValue; override;
procedure AddElement(Val: TJSONValue);
function ToJSON: string; override;
property Count: Integer read GetCount;
property Items[Index: Integer]: TJSONValue read GetItem;
end;
implementation
{ Parsers simples }
function RemoveQuotes(const S: string): string;
begin
Result := S;
if (Length(Result) >= 2) and (Result[1] = '"') and (Result[Length(Result)] = '"') then
Result := Copy(Result, 2, Length(Result) - 2);
end;
function EscapeStr(const S: string): string;
begin
// MÃnimo escape para JSON válido
Result := StringReplace(S, '\', '\\', [rfReplaceAll]);
Result := StringReplace(Result, '"', '\"', [rfReplaceAll]);
Result := StringReplace(Result, #13, '\r', [rfReplaceAll]);
Result := StringReplace(Result, #10, '\n', [rfReplaceAll]);
Result := StringReplace(Result, #9, '\t', [rfReplaceAll]);
end;
{ Simple Tokenizer }
type
TJsonTokenType = (ttError, ttObjStart, ttObjEnd, ttArrStart, ttArrEnd, ttColon, ttComma, ttString, ttNumber, ttBool, ttNull, ttEOF);
TJsonParser = class
private
FText: string;
FPos: Integer;
FLen: Integer;
public
constructor Create(const Txt: string);
function NextToken(out Value: string): TJsonTokenType;
function Parse: TJSONValue;
function ParseObject: TJSONObject;
function ParseArray: TJSONArray;
end;
constructor TJsonParser.Create(const Txt: string);
begin
FText := Txt;
FPos := 1;
FLen := Length(FText);
end;
function TJsonParser.NextToken(out Value: string): TJsonTokenType;
var
C: Char;
begin
Value := '';
while (FPos <= FLen) and (FText[FPos] <= ' ') do Inc(FPos);
if FPos > FLen then
begin
Result := ttEOF;
Exit;
end;
C := FText[FPos];
Inc(FPos);
case C of
'{': Result := ttObjStart;
'}': Result := ttObjEnd;
'[': Result := ttArrStart;
']': Result := ttArrEnd;
':': Result := ttColon;
',': Result := ttComma;
'"': begin
Result := ttString;
while FPos <= FLen do
begin
if (FText[FPos] = '"') and (FText[FPos-1] <> '\') then
begin
Inc(FPos);
Break;
end;
Value := Value + FText[FPos];
Inc(FPos);
end;
end;
else begin
Value := C;
while (FPos <= FLen) and not (FText[FPos] in ['}', ']', ',', ':']) and (FText[FPos] > ' ') do
begin
Value := Value + FText[FPos];
Inc(FPos);
end;
if SameText(Value, 'true') or SameText(Value, 'false') then Result := ttBool
else if SameText(Value, 'null') then Result := ttNull
else Result := ttNumber;
end;
end;
end;
function TJsonParser.Parse: TJSONValue;
var
Tk: TJsonTokenType;
Val: string;
begin
while (FPos <= FLen) and (FText[FPos] <= ' ') do Inc(FPos);
if FPos > FLen then
begin
Result := nil;
Exit;
end;
if FText[FPos] = '{' then Result := ParseObject
else if FText[FPos] = '[' then Result := ParseArray
else
begin
Tk := NextToken(Val);
case Tk of
ttString: Result := TJSONString.Create(Val);
ttNumber: Result := TJSONNumber.Create(Val);
ttBool: Result := TJSONBool.Create(Val);
ttNull: Result := TJSONNull.Create;
else Result := nil;
end;
end;
end;
function TJsonParser.ParseObject: TJSONObject;
var
Tk: TJsonTokenType;
Key, ValStr: string;
Val: TJSONValue;
begin
Result := TJSONObject.Create;
NextToken(Key); // consume {
while True do
begin
Tk := NextToken(Key); // Key o }
if Tk = ttObjEnd then Break;
if Tk = ttComma then
begin
Tk := NextToken(Key); // Próxima Key
if Tk = ttObjEnd then Break; // Trailing comma
end;
if Tk <> ttString then Break; // Error o vacio
NextToken(ValStr); // consume :
Val := Parse;
Result.AddPair(Key, Val);
end;
end;
function TJsonParser.ParseArray: TJSONArray;
var
Tk: TJsonTokenType;
ValStr: string;
Val: TJSONValue;
begin
Result := TJSONArray.Create;
NextToken(ValStr); // consume [
while True do
begin
// Check Fin o Coma
// Peek un poco sucio...
while (FPos <= FLen) and (FText[FPos] <= ' ') do Inc(FPos);
if (FPos <= FLen) and (FText[FPos] = ']') then
begin
Inc(FPos);
Break;
end;
if (FPos <= FLen) and (FText[FPos] = ',') then
Inc(FPos);
Val := Parse;
if Val <> nil then
Result.AddElement(Val)
else
Break;
end;
end;
{ TJSONValue }
constructor TJSONValue.Create(const AValue: string);
begin
FValue := AValue;
end;
{ TJSONString }
function TJSONString.GetValue(const Key: string): TJSONValue;
begin
Result := nil;
end;
function TJSONString.ToJSON: string;
begin
Result := '"' + EscapeStr(FValue) + '"';
end;
{ TJSONNumber }
function TJSONNumber.GetValue(const Key: string): TJSONValue;
begin
Result := nil;
end;
function TJSONNumber.ToJSON: string;
begin
Result := FValue;
end;
{ TJSONBool }
function TJSONBool.GetValue(const Key: string): TJSONValue;
begin
Result := nil;
end;
function TJSONBool.ToJSON: string;
begin
Result := LowerCase(FValue);
end;
{ TJSONNull }
function TJSONNull.GetValue(const Key: string): TJSONValue;
begin
Result := nil;
end;
function TJSONNull.ToJSON: string;
begin
Result := 'null';
end;
{ TJSONPair }
constructor TJSONPair.Create(const AStr: string; AVal: TJSONValue);
begin
FJsonString := TJSONString.Create(AStr);
FJsonValue := AVal;
end;
constructor TJSONPair.Create(const AStr: string; const AValStr: string);
begin
FJsonString := TJSONString.Create(AStr);
FJsonValue := TJSONString.Create(AValStr);
end;
destructor TJSONPair.Destroy;
begin
FJsonString.Free;
FJsonValue.Free;
inherited;
end;
{ TJSONObject }
constructor TJSONObject.Create;
begin
inherited Create('');
FPairs := TList.Create;
end;
destructor TJSONObject.Destroy;
var
I: Integer;
begin
for I := 0 to FPairs.Count - 1 do
TJSONPair(FPairs[I]).Free;
FPairs.Free;
inherited;
end;
function TJSONObject.GetCount: Integer;
begin
Result := FPairs.Count;
end;
function TJSONObject.GetPair(Index: Integer): TJSONPair;
begin
Result := TJSONPair(FPairs[Index]);
end;
function TJSONObject.GetValue(const Key: string): TJSONValue;
var
I: Integer;
begin
Result := nil;
for I := 0 to FPairs.Count - 1 do
begin
if SameText(TJSONPair(FPairs[I]).JsonString.Value, Key) then
begin
Result := TJSONPair(FPairs[I]).JsonValue;
Exit;
end;
end;
end;
procedure TJSONObject.AddPair(const Str: string; Val: TJSONValue);
begin
FPairs.Add(TJSONPair.Create(Str, Val));
end;
procedure TJSONObject.AddPair(const Str: string; const ValStr: string);
begin
FPairs.Add(TJSONPair.Create(Str, ValStr));
end;
function TJSONObject.ToJSON: string;
var
I: Integer;
begin
Result := '{';
for I := 0 to FPairs.Count - 1 do
begin
if I > 0 then Result := Result + ',';
Result := Result + TJSONPair(FPairs[I]).JsonString.ToJSON + ':' +
TJSONPair(FPairs[I]).JsonValue.ToJSON;
end;
Result := Result + '}';
end;
class function TJSONObject.ParseJSONValue(const Json: string): TJSONValue;
var
Parser: TJsonParser;
begin
Parser := TJsonParser.Create(Json);
try
Result := Parser.Parse;
finally
Parser.Free;
end;
end;
{ TJSONArray }
constructor TJSONArray.Create;
begin
inherited Create('');
FItems := TList.Create;
end;
destructor TJSONArray.Destroy;
var
I: Integer;
begin
for I := 0 to FItems.Count - 1 do
TJSONValue(FItems[I]).Free;
FItems.Free;
inherited;
end;
function TJSONArray.GetCount: Integer;
begin
Result := FItems.Count;
end;
function TJSONArray.GetItem(Index: Integer): TJSONValue;
begin
Result := TJSONValue(FItems[Index]);
end;
function TJSONArray.GetValue(const Key: string): TJSONValue;
begin
Result := nil;
end;
procedure TJSONArray.AddElement(Val: TJSONValue);
begin
FItems.Add(Val);
end;
function TJSONArray.ToJSON: string;
var
I: Integer;
begin
Result := '[';
for I := 0 to FItems.Count - 1 do
begin
if I > 0 then Result := Result + ',';
Result := Result + TJSONValue(FItems[I]).ToJSON;
end;
Result := Result + ']';
end;
end.uVFHttp.pas
Capa de comunicaciones REST utilizando WinInet pura con manejo automático de certificados y TLS.
Ver/Ocultar Código Fuente (uVFHttp.pas)
unit uVFHttp;
interface
uses
Windows, Classes, SysUtils, WinInet;
type
TVfApiClient = class
private
FBaseUrl: string;
FTimeoutMs: Integer;
FToken: string;
FNifEmisor: string;
FLastStatusCode: Integer;
FLastResponseText: string;
function SendRequest(const Method, Endpoint: string; const BodyData: string): string;
public
constructor Create(const BaseUrl: string; TimeoutMs: Integer; const Token: string);
function GetText(const PathAndQuery: string): string;
function PostJson(const Path: string; const JsonUtf8: string): string;
property BaseUrl: string read FBaseUrl;
property Token: string read FToken write FToken;
property NifEmisor: string read FNifEmisor write FNifEmisor;
property LastStatusCode: Integer read FLastStatusCode;
property LastResponseText: string read FLastResponseText;
end;
function UrlEncode(const S: string): string;
implementation
function UrlEncode(const S: string): string;
const
Hex: array[0..15] of Char = '0123456789ABCDEF';
var
I: Integer;
C: Byte;
begin
Result := '';
for I := 1 to Length(S) do
begin
C := Ord(S[I]);
if (C >= Ord('A')) and (C <= Ord('Z')) or
(C >= Ord('a')) and (C <= Ord('z')) or
(C >= Ord('0')) and (C <= Ord('9')) or
(S[I] in ['-','_','.','~']) then
Result := Result + S[I]
else if S[I] = ' ' then
Result := Result + '%20'
else
Result := Result + '%' + Hex[C shr 4] + Hex[C and $0F];
end;
end;
{ TVfApiClient }
constructor TVfApiClient.Create(const BaseUrl: string; TimeoutMs: Integer; const Token: string);
begin
inherited Create;
FBaseUrl := BaseUrl;
if (Length(FBaseUrl) > 0) and (FBaseUrl[Length(FBaseUrl)] = '/') then
Delete(FBaseUrl, Length(FBaseUrl), 1);
FTimeoutMs := TimeoutMs;
FToken := Token;
end;
function TVfApiClient.SendRequest(const Method, Endpoint: string; const BodyData: string): string;
const
// Errores WinInet SSL
ERROR_INTERNET_INVALID_CA = 12045;
ERROR_INTERNET_SEC_CERT_DATE_INVALID = 12037;
ERROR_INTERNET_SEC_CERT_CN_INVALID = 12038;
// Flags de Seguridad para InternetOption
SECURITY_FLAG_IGNORE_REVOCATION = $00000080;
SECURITY_FLAG_IGNORE_UNKNOWN_CA = $00000100;
SECURITY_FLAG_IGNORE_WRONG_USAGE = $00000200;
SECURITY_FLAG_IGNORE_CERT_CN_INVALID = $00001000;
SECURITY_FLAG_IGNORE_CERT_DATE_INVALID = $00002000;
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Delphi/7 VeriFactuClient/1.0';
var
HInt, HConn, HReq: HINTERNET;
HostName, UrlPath: string;
DwFlags, DwContext, BytesRead: DWORD;
Buffer: array[0..4095] of Char;
ResStr: string;
Headers: string;
StrBuffer: string;
Len: DWORD;
Port: Word;
FullUrl: string;
// Variables URL parsing
P: Integer;
Protocol: string;
// Variables para reintento SSL
Retries: Integer;
LastError: DWORD;
SecFlags: DWORD;
SecFlagsLen: DWORD;
begin
Result := '';
FLastResponseText := '';
FLastStatusCode := 0;
FullUrl := FBaseUrl + Endpoint;
// Analizar URL (Protocolo, Host, Puerto, Path)
Protocol := 'http';
Port := INTERNET_DEFAULT_HTTP_PORT;
DwFlags := 0;
if Pos('https://', LowerCase(FullUrl)) = 1 then
begin
Protocol := 'https';
Port := INTERNET_DEFAULT_HTTPS_PORT;
DwFlags := INTERNET_FLAG_SECURE or INTERNET_FLAG_IGNORE_CERT_CN_INVALID or INTERNET_FLAG_IGNORE_CERT_DATE_INVALID;
HostName := Copy(FullUrl, 9, MaxInt);
end
else if Pos('http://', LowerCase(FullUrl)) = 1 then
begin
HostName := Copy(FullUrl, 8, MaxInt);
end
else
HostName := FullUrl; // Assume http partial
// Path
P := Pos('/', HostName);
if P > 0 then
begin
UrlPath := Copy(HostName, P, MaxInt);
HostName := Copy(HostName, 1, P - 1);
end
else
UrlPath := '/';
// Port explicito en HostName? (e.g. localhost:9000)
P := Pos(':', HostName);
if P > 0 then
begin
try
Port := StrToInt(Copy(HostName, P + 1, MaxInt));
HostName := Copy(HostName, 1, P - 1);
except
// Si falla, usar default
end;
end;
// Iniciar WinInet
HInt := InternetOpen(PChar(USER_AGENT), INTERNET_OPEN_TYPE_PRECONFIG, nil, nil, 0);
if HInt = nil then RaiseLastOSError;
try
InternetSetOption(HInt, INTERNET_OPTION_CONNECT_TIMEOUT, @FTimeoutMs, SizeOf(FTimeoutMs));
InternetSetOption(HInt, INTERNET_OPTION_RECEIVE_TIMEOUT, @FTimeoutMs, SizeOf(FTimeoutMs));
HConn := InternetConnect(HInt, PChar(HostName), Port, nil, nil, INTERNET_SERVICE_HTTP, 0, 0);
if HConn = nil then RaiseLastOSError;
try
HReq := HttpOpenRequest(HConn, PChar(Method), PChar(UrlPath), nil, nil, nil,
INTERNET_FLAG_RELOAD or INTERNET_FLAG_NO_CACHE_WRITE or DwFlags, 0);
if HReq = nil then RaiseLastOSError;
try
Headers := 'Accept: application/json' + #13#10;
if FToken <> '' then
Headers := Headers + 'X-API-Key: ' + FToken + #13#10;
if FNifEmisor <> '' then
Headers := Headers + 'X-Verifactu-Emisor: ' + FNifEmisor + #13#10;
if (Method = 'POST') and (BodyData <> '') then
Headers := Headers + 'Content-Type: application/json; charset=utf-8' + #13#10;
// Bucle de reintentos para errores SSL
Retries := 0;
while True do
begin
// BodyData handling: WinInet works better with nil for empty body
if BodyData = '' then
begin
if HttpSendRequest(HReq, PChar(Headers), Length(Headers), nil, 0) then Break;
end
else
begin
if HttpSendRequest(HReq, PChar(Headers), Length(Headers), PChar(BodyData), Length(BodyData)) then Break;
end;
LastError := GetLastError;
// Reintentar si es error de certificado
if (Retries < 3) and
((LastError = ERROR_INTERNET_INVALID_CA) or
(LastError = ERROR_INTERNET_SEC_CERT_DATE_INVALID) or
(LastError = ERROR_INTERNET_SEC_CERT_CN_INVALID)) then
begin
Inc(Retries);
SecFlagsLen := SizeOf(SecFlags);
InternetQueryOption(HReq, INTERNET_OPTION_SECURITY_FLAGS, @SecFlags, SecFlagsLen);
SecFlags := SecFlags or SECURITY_FLAG_IGNORE_UNKNOWN_CA or
SECURITY_FLAG_IGNORE_CERT_CN_INVALID or
SECURITY_FLAG_IGNORE_CERT_DATE_INVALID or
SECURITY_FLAG_IGNORE_WRONG_USAGE or
SECURITY_FLAG_IGNORE_REVOCATION;
InternetSetOption(HReq, INTERNET_OPTION_SECURITY_FLAGS, @SecFlags, SizeOf(SecFlags));
Continue; // Reintentar
end;
// Fallo real de envio - Incluimos DEBUG INFO en el texto
Result := Format('ERROR_WININET_SEND: %s (%d). [URL: %s] [Host: %s] [Path: %s]',
[SysErrorMessage(LastError), LastError, FullUrl, HostName, UrlPath]);
Exit;
end;
// Leer Status Code
Len := SizeOf(FLastStatusCode);
DwContext := 0;
if not HttpQueryInfo(HReq, HTTP_QUERY_STATUS_CODE or HTTP_QUERY_FLAG_NUMBER, @FLastStatusCode, Len, DwContext) then
FLastStatusCode := 0;
// Leer Respuesta
ResStr := '';
repeat
if not InternetReadFile(HReq, @Buffer, SizeOf(Buffer), BytesRead) then Break;
if BytesRead > 0 then
begin
SetLength(StrBuffer, BytesRead);
Move(Buffer[0], StrBuffer[1], BytesRead);
ResStr := ResStr + StrBuffer;
end;
until BytesRead = 0;
FLastResponseText := UTF8Decode(ResStr);
Result := FLastResponseText;
finally
InternetCloseHandle(HReq);
end;
finally
InternetCloseHandle(HConn);
end;
finally
InternetCloseHandle(HInt);
end;
end;
function TVfApiClient.GetText(const PathAndQuery: string): string;
begin
Result := SendRequest('GET', PathAndQuery, '');
end;
function TVfApiClient.PostJson(const Path: string; const JsonUtf8: string): string;
begin
// Para enviar, convertimos Ansi -> UTF8
Result := SendRequest('POST', Path, UTF8Encode(JsonUtf8));
end;
end.uVFEngine.pas
El cerebro principal. Contiene la clase TVFEngine que orquesta la criptografía, las peticiones y las reglas de negocio.
Ver/Ocultar Código Fuente (uVFEngine.pas)
unit uVFEngine;
interface
uses
Windows, SysUtils, Classes,
uConfig, uVFHttp, uVFJson; // Usamos nuestras versiones D7
type
TVFCorrelacion = record
IdEnvio: Int64; // tracking.cab_num_secuencia <-> pendientes.id_envio
LineaDetalle: Integer; // tracking.det_linea <-> pendientes.linea_log_detalle
end;
// Funciones para records
function VFCorrelacion_Empty: TVFCorrelacion;
function VFCorrelacion_IsValid(const C: TVFCorrelacion): Boolean;
type
TVFIngestaResponse = record
Ok: Boolean;
HttpStatus: Integer;
Mensaje: string;
Id: string;
IdFormateado: string;
OperacionRealizada: string;
TrackingEmisor: string;
TrackingSerie: string;
TrackingNumFactura: string;
TrackingTimestamp: TDateTime;
Correlacion: TVFCorrelacion;
RawResponseJson: string;
ErrorMsg: string;
ErrorBodyJson: string;
end;
TVFPendienteItem = record
IndiceLog: Int64;
Creacion: TDateTime;
Modo: Integer;
IdEnvio: Int64;
LineaLogDetalle: Integer;
NumSerieFactura: string;
FechaExpedicion: TDateTime;
Huella: string;
CSV: string;
Total: Currency;
CodigoError: Integer;
DescripcionError: string;
Status: Integer;
QrVerifactu: string;
UrlQrVerifactu: string;
end;
TVFPendienteItemArray = array of TVFPendienteItem;
TVFPendientesResponse = record
Ok: Boolean;
HttpStatus: Integer;
Items: TVFPendienteItemArray;
RawResponseJson: string;
ErrorMsg: string;
ErrorBodyJson: string;
end;
TVFAckResponse = record
Ok: Boolean;
HttpStatus: Integer;
Status: string;
Message: string;
RawResponseJson: string;
ErrorMsg: string;
ErrorBodyJson: string;
end;
TVFIngestaAckResult = record
Ingesta: TVFIngestaResponse;
EncontradoEnPendientes: Boolean;
Pendiente: TVFPendienteItem;
IndiceLogEncontrado: Int64;
AckHecho: Boolean;
Ack: TVFAckResponse;
Timeout: Boolean;
ErrorMsg: string;
end;
TVFEngine = class
private
FCfg: TVfDemoConfig;
FCli: TVfApiClient;
function ParseIngestaResponse(const RespJson: string): TVFIngestaResponse;
function ParsePendientesResponse(const RespJson: string): TVFPendientesResponse;
function ParseAckResponse(const RespJson: string): TVFAckResponse;
function TryFindPendienteByCorrelacion(
const Pend: TVFPendientesResponse;
const Corr: TVFCorrelacion;
out Item: TVFPendienteItem
): Boolean;
public
constructor Create(const Cfg: TVfDemoConfig);
destructor Destroy; override;
function IngestaFromJson(const JsonIngesta: string): TVFIngestaResponse;
function IngestaFromFile(const FileName: string): TVFIngestaResponse;
function GetPendientes(NUltimos: Integer; IdEnvio: Int64 = 0; LineaDetalle: Integer = 0): TVFPendientesResponse;
function AckIndice(const IndiceLog: Int64): TVFAckResponse;
function IngestaYConfirmacion(
const JsonIngesta: string;
TimeoutMs: Integer = 5000;
PollIntervalMs: Integer = 200;
NUltimos: Integer = 50;
AckOnFound: Boolean = True
): TVFIngestaAckResult;
end;
function BuildAckJson(const Nif: string; Indice: Int64): string;
implementation
{ Funciones Auxiliares Fecha / ISO8601 }
{ Funciones Auxiliares Fecha / ISO8601 }
function TryISO8601ToDate(const S: string; out D: TDateTime): Boolean;
var
Y, M, Dd, H, N, Ss, Ms: Word;
P: Integer;
begin
Result := False;
D := 0;
if Length(S) < 19 then Exit;
try
// Formato: YYYY-MM-DDTHH:MM:SS...
Y := StrToInt(Copy(S, 1, 4));
M := StrToInt(Copy(S, 6, 2));
Dd := StrToInt(Copy(S, 9, 2));
// Detectar separador T o espacio
if (S[11] = 'T') or (S[11] = ' ') then
begin
H := StrToInt(Copy(S, 12, 2));
N := StrToInt(Copy(S, 15, 2));
Ss := StrToInt(Copy(S, 18, 2));
// Milisegundos opcionales (.123)
Ms := 0;
if (Length(S) > 19) and (S[20] = '.') then
begin
// Intentar leer hasta 3 digitos
try
Ms := StrToIntDef(Copy(S, 21, 3), 0);
except
end;
end;
D := EncodeDate(Y, M, Dd) + EncodeTime(H, N, Ss, Ms);
Result := True;
end;
except
// Si falla parseo manual, intentar StrToDateTime por si acaso
try
D := StrToDateTime(S);
Result := True;
except
end;
end;
end;
{ TVFCorrelacion Helpers }
function VFCorrelacion_Empty: TVFCorrelacion;
begin
Result.IdEnvio := 0;
Result.LineaDetalle := 0;
end;
function VFCorrelacion_IsValid(const C: TVFCorrelacion): Boolean;
begin
Result := (C.IdEnvio > 0) and (C.LineaDetalle > 0);
end;
{ Helpers JSON }
function BuildAckJson(const Nif: string; Indice: Int64): string;
begin
Result := Format('{"nif_emisor":"%s", "indice_log":%d}', [Nif, Indice]);
end;
function JGetStr(Obj: TJSONObject; const Name: string): string;
var
V: TJSONValue;
begin
Result := '';
if Obj = nil then Exit;
V := Obj.GetValue(Name);
if V <> nil then
Result := V.Value;
end;
function JGetInt64(Obj: TJSONObject; const Name: string): Int64;
var
S: string;
begin
Result := 0;
S := JGetStr(Obj, Name);
if S = '' then Exit;
Result := StrToInt64Def(S, 0);
end;
function JGetInt(Obj: TJSONObject; const Name: string): Integer;
var
S: string;
begin
Result := 0;
S := JGetStr(Obj, Name);
if S = '' then Exit;
Result := StrToIntDef(S, 0);
end;
function JGetCurrency(Obj: TJSONObject; const Name: string): Currency;
var
S: string;
begin
Result := 0;
S := JGetStr(Obj, Name);
if S = '' then Exit;
S := StringReplace(S, ',', '.', [rfReplaceAll]);
if DecimalSeparator <> '.' then
S := StringReplace(S, '.', DecimalSeparator, [rfReplaceAll]);
Result := StrToCurrDef(S, 0);
end;
function JGetDateTimeISO(const S: string): TDateTime;
begin
if not TryISO8601ToDate(S, Result) then
Result := 0;
end;
function ParseSqlTimestamp(const S: string): TDateTime;
begin
// Wrapper para reutilizar la logica robusta
// Acepta tanto "2026-02-16 22:42:53" como con T y milisegundos
if not TryISO8601ToDate(S, Result) then
Result := 0;
end;
{ TVFEngine }
constructor TVFEngine.Create(const Cfg: TVfDemoConfig);
begin
inherited Create;
FCfg := Cfg;
FCli := TVfApiClient.Create(FCfg.ApiBaseUrl, FCfg.TimeoutMs, FCfg.Token);
FCli.NifEmisor := FCfg.NifEmisor;
end;
destructor TVFEngine.Destroy;
begin
FreeAndNil(FCli);
inherited;
end;
function TVFEngine.IngestaFromJson(const JsonIngesta: string): TVFIngestaResponse;
function IsSuccess(Code: Integer): Boolean;
begin
Result := (Code = 200) or (Code = 201) or (Code = 422) or (Code = 409) or (Code = 403) or (Code = 400) or (Code = 500);
end;
var
Resp: string;
Attempt: Integer;
const
MAX_RETRIES = 3;
begin
Result.Ok := False;
Result.HttpStatus := -1;
Result.RawResponseJson := '';
Result.ErrorMsg := '';
Result.ErrorBodyJson := '';
Result.Correlacion := VFCorrelacion_Empty;
for Attempt := 1 to MAX_RETRIES do
begin
try
Resp := FCli.PostJson('/v1/ingesta', JsonIngesta);
Result := ParseIngestaResponse(Resp);
if Result.Ok or IsSuccess(FCli.LastStatusCode) then
begin
Result.HttpStatus := FCli.LastStatusCode;
if (Result.HttpStatus = 422) or (Result.HttpStatus = 409) then
begin
Result.Ok := True;
Result.Mensaje := Result.Mensaje + ' (Recuperado tras duplicado)';
end
else if not Result.Ok then
begin
if Result.Mensaje <> '' then
Result.ErrorMsg := Result.Mensaje
else
Result.ErrorMsg := 'Rechazado (HTTP ' + IntToStr(Result.HttpStatus) + ')';
end;
Break;
end;
except
on E: Exception do
begin
Result.Ok := False;
Result.ErrorMsg := E.Message;
Result.HttpStatus := FCli.LastStatusCode;
Result.ErrorBodyJson := FCli.LastResponseText;
if Attempt < MAX_RETRIES then
Sleep(1000);
end;
end;
end;
end;
function TVFEngine.IngestaFromFile(const FileName: string): TVFIngestaResponse;
var
SL: TStringList;
begin
if not FileExists(FileName) then
begin
Result.Ok := False;
Result.HttpStatus := -1;
Result.ErrorMsg := 'No existe el archivo: ' + FileName;
Exit;
end;
SL := TStringList.Create;
try
SL.LoadFromFile(FileName);
Result := IngestaFromJson(SL.Text);
finally
SL.Free;
end;
end;
function TVFEngine.GetPendientes(NUltimos: Integer; IdEnvio: Int64 = 0; LineaDetalle: Integer = 0): TVFPendientesResponse;
var
Resp: string;
Url: string;
N: Integer;
TieneCorrelacion: Boolean;
begin
Result.Ok := False;
Result.HttpStatus := -1;
Result.RawResponseJson := '';
Result.ErrorMsg := '';
Result.ErrorBodyJson := '';
SetLength(Result.Items, 0);
TieneCorrelacion := (IdEnvio > 0) and (LineaDetalle > 0);
if TieneCorrelacion then
begin
Url := Format('/verifactu/pendientes?nif_emisor=%s&id_envio=%d&linea_log_detalle=%d',
[UrlEncode(FCfg.NifEmisor), IdEnvio, LineaDetalle]);
end
else
begin
N := NUltimos;
if N < 1 then N := 50;
Url := Format('/verifactu/pendientes?nif_emisor=%s&n_ultimos=%d',
[UrlEncode(FCfg.NifEmisor), N]);
end;
try
Resp := FCli.GetText(Url);
Result := ParsePendientesResponse(Resp);
Result.HttpStatus := 200;
except
on E: Exception do
begin
Result.Ok := False;
Result.ErrorMsg := E.Message;
Result.HttpStatus := FCli.LastStatusCode;
Result.ErrorBodyJson := FCli.LastResponseText;
end;
end;
end;
function TVFEngine.AckIndice(const IndiceLog: Int64): TVFAckResponse;
var
ReqJson: string;
Resp: string;
begin
Result.Ok := False;
Result.HttpStatus := -1;
Result.RawResponseJson := '';
Result.ErrorMsg := '';
Result.ErrorBodyJson := '';
try
ReqJson := BuildAckJson(FCfg.NifEmisor, IndiceLog);
Resp := FCli.PostJson('/verifactu/ack', ReqJson);
Result := ParseAckResponse(Resp);
Result.HttpStatus := 200;
except
on E: Exception do
begin
Result.Ok := False;
Result.ErrorMsg := E.Message;
Result.HttpStatus := FCli.LastStatusCode;
Result.ErrorBodyJson := FCli.LastResponseText;
end;
end;
end;
function TVFEngine.IngestaYConfirmacion(
const JsonIngesta: string;
TimeoutMs, PollIntervalMs, NUltimos: Integer;
AckOnFound: Boolean
): TVFIngestaAckResult;
var
T0: Cardinal;
Pend: TVFPendientesResponse;
Item: TVFPendienteItem;
begin
FillChar(Result, SizeOf(Result), 0);
Result.IndiceLogEncontrado := 0;
Result.Timeout := False;
Result.ErrorMsg := '';
Result.Ingesta := IngestaFromJson(JsonIngesta);
if not Result.Ingesta.Ok then
begin
Result.ErrorMsg := 'Ingesta fallida: ' + Result.Ingesta.ErrorMsg;
Exit;
end;
if not VFCorrelacion_IsValid(Result.Ingesta.Correlacion) then
begin
Result.ErrorMsg := 'No se pudo obtener correlación (IdEnvio/LineaDetalle) desde la respuesta de ingesta.';
Exit;
end;
T0 := GetTickCount;
while True do
begin
Pend := GetPendientes(NUltimos, Result.Ingesta.Correlacion.IdEnvio, Result.Ingesta.Correlacion.LineaDetalle);
if Pend.Ok then
begin
if TryFindPendienteByCorrelacion(Pend, Result.Ingesta.Correlacion, Item) then
begin
Result.EncontradoEnPendientes := True;
Result.Pendiente := Item;
Result.IndiceLogEncontrado := Item.IndiceLog;
if AckOnFound then
begin
Result.Ack := AckIndice(Item.IndiceLog);
Result.AckHecho := Result.Ack.Ok;
if not Result.Ack.Ok then
Result.ErrorMsg := 'ACK fallido: ' + Result.Ack.ErrorMsg;
end;
Exit;
end;
end;
if (TimeoutMs > 0) and (GetTickCount - T0 >= Cardinal(TimeoutMs)) then
begin
Result.Timeout := True;
Result.ErrorMsg := 'Timeout esperando aparición en pendientes.';
Exit;
end;
Sleep(PollIntervalMs);
end;
end;
function TVFEngine.TryFindPendienteByCorrelacion(
const Pend: TVFPendientesResponse;
const Corr: TVFCorrelacion;
out Item: TVFPendienteItem
): Boolean;
var
I: Integer;
begin
Result := False;
// FillChar(Item, SizeOf(Item), 0);
if not Pend.Ok then Exit;
if not VFCorrelacion_IsValid(Corr) then Exit;
for I := 0 to High(Pend.Items) do
begin
if (Pend.Items[I].IdEnvio = Corr.IdEnvio) and
(Pend.Items[I].LineaLogDetalle = Corr.LineaDetalle) then
begin
Item := Pend.Items[I];
Result := True;
Exit;
end;
end;
end;
{ Parsers }
function TVFEngine.ParseIngestaResponse(const RespJson: string): TVFIngestaResponse;
var
V: TJSONValue;
Obj, Trk: TJSONObject;
begin
Result.Ok := False;
Result.HttpStatus := 200;
Result.RawResponseJson := RespJson;
Result.ErrorMsg := '';
Result.ErrorBodyJson := '';
Result.Correlacion := VFCorrelacion_Empty;
Result.TrackingTimestamp := 0;
V := TJSONObject.ParseJSONValue(RespJson);
try
if not (V is TJSONObject) then
begin
// INFO IMPORTANTE PARA DEPURAR 502/HTML
Result.ErrorMsg := 'Respuesta de ingesta no es JSON object. Contenido: ' + Copy(RespJson, 1, 100);
Exit;
end;
Obj := TJSONObject(V);
Result.Mensaje := JGetStr(Obj, 'mensaje');
Result.Id := JGetStr(Obj, 'id');
Result.IdFormateado := JGetStr(Obj, 'id_formateado');
Result.OperacionRealizada := JGetStr(Obj, 'operacion_realizada');
Result.Ok := SameText(JGetStr(Obj, 'status'), 'ok');
if Obj.GetValue('tracking') is TJSONObject then
begin
Trk := TJSONObject(Obj.GetValue('tracking'));
Result.TrackingEmisor := JGetStr(Trk, 'emisor');
Result.TrackingSerie := JGetStr(Trk, 'serie');
Result.TrackingNumFactura := JGetStr(Trk, 'numfactura');
Result.TrackingTimestamp := JGetDateTimeISO(JGetStr(Trk, 'timestamp'));
Result.Correlacion.IdEnvio := JGetInt64(Trk, 'cab_num_secuencia');
Result.Correlacion.LineaDetalle := JGetInt(Trk, 'det_linea');
end;
finally
V.Free;
end;
end;
function TVFEngine.ParsePendientesResponse(const RespJson: string): TVFPendientesResponse;
var
V: TJSONValue;
Arr: TJSONArray;
I: Integer;
Obj: TJSONObject;
It: TVFPendienteItem;
SFecha, SCreacion: string;
begin
Result.Ok := False;
Result.HttpStatus := 200;
Result.RawResponseJson := RespJson;
Result.ErrorMsg := '';
Result.ErrorBodyJson := '';
SetLength(Result.Items, 0);
V := TJSONObject.ParseJSONValue(RespJson);
try
if not (V is TJSONArray) then
begin
if V is TJSONObject then
begin
Result.ErrorMsg := JGetStr(TJSONObject(V), 'mensaje');
if Result.ErrorMsg = '' then Result.ErrorMsg := JGetStr(TJSONObject(V), 'detail');
end;
if Result.ErrorMsg = '' then
Result.ErrorMsg := 'Respuesta no es JSON array. Contenido: ' + Copy(RespJson, 1, 100);
Exit;
end;
Arr := TJSONArray(V);
SetLength(Result.Items, Arr.Count);
for I := 0 to Arr.Count - 1 do
begin
if not (Arr.Items[I] is TJSONObject) then
Continue;
Obj := TJSONObject(Arr.Items[I]);
It.IndiceLog := JGetInt64(Obj, 'indice_log');
SCreacion := JGetStr(Obj, 'creacion');
It.Creacion := ParseSqlTimestamp(SCreacion);
It.Modo := JGetInt(Obj, 'modo');
It.IdEnvio := JGetInt64(Obj, 'id_envio');
It.LineaLogDetalle := JGetInt(Obj, 'linea_log_detalle');
It.NumSerieFactura := JGetStr(Obj, 'num_serie_factura');
SFecha := JGetStr(Obj, 'fecha_expedicion_factura');
It.FechaExpedicion := JGetDateTimeISO(SFecha);
It.Huella := JGetStr(Obj, 'huella');
It.CSV := JGetStr(Obj, 'csv');
It.Total := JGetCurrency(Obj, 'total');
It.CodigoError := JGetInt(Obj, 'codigo_error_verifactu');
It.DescripcionError := JGetStr(Obj, 'descripcion_error_verifactu');
It.Status := JGetInt(Obj, 'status');
It.QrVerifactu := JGetStr(Obj, 'qr_verifactu');
It.UrlQrVerifactu := JGetStr(Obj, 'url_qr_verifactu');
Result.Items[I] := It;
end;
Result.Ok := True;
finally
V.Free;
end;
end;
function TVFEngine.ParseAckResponse(const RespJson: string): TVFAckResponse;
var
V: TJSONValue;
Obj: TJSONObject;
begin
Result.Ok := False;
Result.HttpStatus := 200;
Result.RawResponseJson := RespJson;
Result.ErrorMsg := '';
Result.ErrorBodyJson := '';
V := TJSONObject.ParseJSONValue(RespJson);
try
if not (V is TJSONObject) then
begin
Result.ErrorMsg := 'Respuesta de ACK no es JSON object. Contenido: ' + Copy(RespJson, 1, 100);
Exit;
end;
Obj := TJSONObject(V);
Result.Status := JGetStr(Obj, 'status');
Result.Message := JGetStr(Obj, 'message');
if Result.Message = '' then Result.Message := JGetStr(Obj, 'mensaje');
Result.Ok := SameText(Result.Status, 'ok');
if not Result.Ok then
Result.ErrorMsg := Result.Message;
finally
V.Free;
end;
end;
end.