Skip to content

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.

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

pascal
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

pascal
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?"

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