Skip to content

Guía de Integración Rápida con Delphi 10+ (Modern)

La integración con Delphi 10 (Seattle, Berlin, Rio, Sydney, Alexandria, Athens) y Delphi 11 mantiene la misma filosofía que nuestra guía de Delphi 7 (una única clase orquestadora TVFEngine), pero implementando mejoras de rendimiento radicales en su funcionamiento interno para aprovechar la modernidad del compilador.

A diferencia del SDK para sistemas legacy, esta versión se caracteriza por:

  1. JSON Nativo: Toda la serialización y validación se realiza utilizando la librería oficial e integrada System.JSON.
  2. Motor HTTP ICS: Las comunicaciones abandonan WinInet para utilizar el prestigioso motor asíncrono ICS (Internet Component Suite - Overbyte), maximizando el rendimiento del pool de conexiones mTLS a la AEAT.

TIP

Instalación "Zero-Friction" de ICS. Para utilizar nuestra capa de comunicaciones, no necesitas instalar componentes visuales en la IDE. Simplemente descárgate los códigos fuente de ICS y añade su directorio a tu Library Path (Tools > Options > Language > Delphi Options > Library). El código compilará la red directamente.

A continuación, te mostramos lo sencillo que es implementar el flujo en tu ERP moderno.

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, configura las credenciales y crea 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 encapsula la factura, la manda al MicroServer, espera a que se procese criptográficamente en la AEAT y te devuelve la respuesta legal.

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 masiva) y alto tráfico.

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 Más tarde, le pides a la API: "Oye, ¿la AEAT te ha contestado ya?"

pascal
var
  Pend: TVFPendientesResponse;
  Item: TVFPendienteItem;
  I: Integer;
begin
  Pend := Engine.GetPendientes(50);
  if not Pend.Ok then Exit;

  for I := 0 to Length(Pend.Items) - 1 do
  begin
    Item := Pend.Items[I];
    if (Item.Status >= 0) and (Item.Status <= 3) then
    begin
      // 1. Lo registramos en nuestra BBDD como oficial
      // 2. Le mandamos el ACUSE DE RECIBO (ACK) al API para limpiar la cola
      Engine.AckIndice(Item.IndiceLog);
    end;
  end;
end;

📦 Código Fuente del Core SDK

Para que todo el flujo anterior funcione de manera nativa, el SDK de Delphi 10 se apoya en 4 unidades base modernas. Se han purgado dependencias externas.

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.

uHttpVF.pas

Capa de comunicaciones REST de altísimo rendimiento y robustez asíncrona utilizando el motor ICS (Internet Component Suite - Overbyte), con manejo estricto de TLS y certificados.

Ver/Ocultar Código Fuente (uHttpVF.pas)
pascal
unit uHttpVF;

interface

type
  TVfApiClient = class
  private
    FBaseUrl: string;
    FTimeoutMs: Integer;
    FToken: string;
    FNifEmisor: string;
    FLastStatusCode: Integer;
    FLastResponseText: 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

uses
  SysUtils, Classes,
  OverbyteIcsHttpProt;

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
    // Para NIF y params simples (ASCII) esto es suficiente y seguro.
    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;

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;

procedure ApplyCommonHeaders(H: THttpCli; const Token: string; const NifEmisor: string);
begin
  H.ExtraHeaders.Clear;
  H.ExtraHeaders.Add('Accept: application/json');
  H.ExtraHeaders.Add('Connection: close');

  if Token <> '' then
    H.ExtraHeaders.Add('X-API-Key: ' + Token);

  if NifEmisor <> '' then
    H.ExtraHeaders.Add('X-Verifactu-Emisor: ' + NifEmisor);
end;


function ReadStreamAsStringUTF8(S: TStream): string;
var
  Bytes: TBytes;
begin
  SetLength(Bytes, S.Size);
  S.Position := 0;
  if S.Size > 0 then
    S.ReadBuffer(Bytes[0], Length(Bytes));
  Result := TEncoding.UTF8.GetString(Bytes);
end;

procedure WriteStringAsUTF8(const Str: string; Dest: TStream);
var
  Bytes: TBytes;
begin
  Bytes := TEncoding.UTF8.GetBytes(Str);
  if Length(Bytes) > 0 then
    Dest.WriteBuffer(Bytes[0], Length(Bytes));
end;

function ToTimeoutSeconds(TimeoutMs: Integer): Integer;
begin
  Result := TimeoutMs div 1000;
  if Result < 1 then
    Result := 1;
end;

function TVfApiClient.GetText(const PathAndQuery: string): string;
var
  H: THttpCli;
  OutStream: TMemoryStream;
begin
  H := THttpCli.Create(nil);
  OutStream := TMemoryStream.Create;
  try
    H.URL := FBaseUrl + PathAndQuery;
    H.RcvdStream := OutStream;

    H.Timeout := ToTimeoutSeconds(FTimeoutMs);

    ApplyCommonHeaders(H, FToken, FNifEmisor);

    try
      H.Get;
    finally
      FLastStatusCode := H.StatusCode;
      Result := ReadStreamAsStringUTF8(OutStream);
      FLastResponseText := Result;
    end;
  finally
    OutStream.Free;
    H.Free;
  end;
end;

function TVfApiClient.PostJson(const Path: string; const JsonUtf8: string): string;
var
  H: THttpCli;
  InStream, OutStream: TMemoryStream;
begin
  H := THttpCli.Create(nil);
  InStream := TMemoryStream.Create;
  OutStream := TMemoryStream.Create;
  try
    WriteStringAsUTF8(JsonUtf8, InStream);
    InStream.Position := 0;

    H.URL := FBaseUrl + Path;
    H.SendStream := InStream;
    H.RcvdStream := OutStream;

    H.Timeout := ToTimeoutSeconds(FTimeoutMs);

    ApplyCommonHeaders(H, FToken, FNifEmisor);

    H.ContentTypePost := 'application/json; charset=utf-8';

    try
      H.Post;
    finally
      FLastStatusCode := H.StatusCode;
      Result := ReadStreamAsStringUTF8(OutStream);
      FLastResponseText := Result;
    end;
  finally
    OutStream.Free;
    InStream.Free;
    H.Free;
  end;
end;

end.

uVFPayload.pas

Estructuras de datos y formateo de utilidades para serialización/deserialización utilizando System.JSON nativo.

Ver/Ocultar Código Fuente (uVFPayload.pas)
pascal
unit uVFPayload;

interface

function LoadIngestaFromTemplate(
  const TemplateFile: string;
  const NifEmisor: string
): string;

function BuildAckJson(
  const NifEmisor: string;
  const IndiceLog: Int64
): string;

implementation

uses
  SysUtils, Classes, System.JSON;

function LoadTextFile(const FileName: string): string;
var
  FS: TFileStream;
  SS: TStringStream;
begin
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  if FS.Size = 0 then
    raise Exception.Create('PLANTILLA VACIA: ' + FileName);
  try
    SS := TStringStream.Create('', TEncoding.UTF8);
    try
      SS.CopyFrom(FS, FS.Size);
      Result := SS.DataString;
    finally
      SS.Free;
    end;
  finally
    FS.Free;
  end;
end;

function LoadIngestaFromTemplate(
  const TemplateFile: string;
  const NifEmisor: string
): string;
var
  Timestamp: string;
begin
  Result := LoadTextFile(TemplateFile);
  Result := StringReplace(Result, '{{EMISOR}}', NifEmisor, [rfReplaceAll]);

  // Generar ID unico en base a fecha/hora para evitar 422 (Duplicado)
  Timestamp := FormatDateTime('yyyymmdd-hhnnss', Now);
  // Reemplazar la factura "505" de la plantilla por algo unico
  Result := StringReplace(Result, '"numfactura": "505"', '"numfactura": "505-' + Timestamp + '"', [rfReplaceAll]);
end;

function BuildAckJson(
  const NifEmisor: string;
  const IndiceLog: Int64
): string;
var
  Obj: TJSONObject;
begin
  Obj := TJSONObject.Create;
  try
    Obj.AddPair('nif_emisor', NifEmisor);
    Obj.AddPair('indice_log', TJSONNumber.Create(IndiceLog));
    Result := Obj.ToJSON;
  finally
    Obj.Free;
  end;
end;

end.

uVFEngine.pas

El cerebro principal. Contiene la clase TVFEngine que orquesta de forma asíncrona y transparente las peticiones a VeriFactuHub.

Ver/Ocultar Código Fuente (uVFEngine.pas)
pascal
unit uVFEngine;

interface

uses
  SysUtils, Classes,
  System.JSON, System.DateUtils, System.IOUtils,
  uConfig, uHttpVF, uVFPayload;

type
  TVFCorrelacion = record
    IdEnvio: Int64;          // tracking.cab_num_secuencia  <-> pendientes.id_envio
    LineaDetalle: Integer;   // tracking.det_linea          <-> pendientes.linea_log_detalle
    class function Empty: TVFCorrelacion; static;
    function IsValid: Boolean;
  end;

  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;
    NumFactura: string;
    FechaExpedicion: TDateTime;

    Huella: string;
    CSV: string;
    Total: Currency;
    CodigoError: Integer;
    DescripcionError: string;
    Status: Integer;

    QrVerifactu: string;
    UrlQrVerifactu: string;
  end;

  TVFPendientesResponse = record
    Ok: Boolean;
    HttpStatus: Integer;
    Items: array of TVFPendienteItem;
    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;

implementation

{ TVFCorrelacion }

class function TVFCorrelacion.Empty: TVFCorrelacion;
begin
  Result.IdEnvio := 0;
  Result.LineaDetalle := 0;
end;

function TVFCorrelacion.IsValid: Boolean;
begin
  Result := (IdEnvio > 0) and (LineaDetalle > 0);
end;

{ Helpers JSON }

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
  V: TJSONValue;
begin
  Result := 0;
  if Obj = nil then Exit;
  V := Obj.GetValue(Name);
  if V = nil then Exit;
  if V is TJSONNumber then
    Result := TJSONNumber(V).AsInt64
  else
    Result := StrToInt64Def(V.Value, 0);
end;

function JGetInt(Obj: TJSONObject; const Name: string): Integer;
var
  V: TJSONValue;
begin
  Result := 0;
  if Obj = nil then Exit;
  V := Obj.GetValue(Name);
  if V = nil then Exit;
  if V is TJSONNumber then
    Result := TJSONNumber(V).AsInt
  else
    Result := StrToIntDef(V.Value, 0);
end;

function JGetCurrency(Obj: TJSONObject; const Name: string): Currency;
var
  V: TJSONValue;
  S: string;
begin
  Result := 0;
  if Obj = nil then Exit;
  V := Obj.GetValue(Name);
  if V = nil then Exit;
  if V is TJSONNumber then
  begin
    Result := TJSONNumber(V).AsDouble;
    Exit;
  end;
  S := V.Value;
  if S = '' then Exit;
  S := StringReplace(S, ',', '.', [rfReplaceAll]);
  Result := StrToCurrDef(S, 0, TFormatSettings.Invariant);
end;

function TryParseAnyDate(const S: string): TDateTime;
var
  FS: TFormatSettings;
  S2: string;
begin
  Result := 0;
  if S = '' then Exit;

  FS := TFormatSettings.Invariant;
  FS.DateSeparator := '-';
  FS.TimeSeparator := ':';
  FS.ShortDateFormat := 'yyyy-mm-dd';
  FS.LongDateFormat := 'yyyy-mm-dd';
  FS.ShortTimeFormat := 'hh:nn:ss';
  FS.LongTimeFormat := 'hh:nn:ss';

  S2 := StringReplace(S, 'T', ' ', [rfReplaceAll]);
  S2 := StringReplace(S2, 'Z', '', [rfReplaceAll]);

  if Pos('.', S2) > 0 then
    S2 := Copy(S2, 1, Pos('.', S2) - 1);

  if not TryStrToDateTime(S2, Result, FS) then
  begin
    if not TryStrToDate(S2, Result, FS) then
    begin
       try
         Result := ISO8601ToDate(S, False);
       except
         Result := 0;
       end;
    end;
  end;
end;

function JGetDateISO(const S: string): TDateTime;
begin
  Result := TryParseAnyDate(S);
end;

function JGetDateTimeISO(const S: string): TDateTime;
begin
  Result := TryParseAnyDate(S);
end;

function ParseSqlTimestamp(const S: string): TDateTime;
begin
  Result := TryParseAnyDate(S);
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
    // 200 OK, 201 Created, etc. y también 422/409 (Duplicado = éxito lógico)
    Result := (Code = 200) or (Code = 201) or (Code = 422) or (Code = 409) or (Code = 403) or (Code = 400) or (Code = 401) 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 := TVFCorrelacion.Empty;

  for Attempt := 1 to MAX_RETRIES do
  begin
    try
      Resp := FCli.PostJson('/v1/ingesta', JsonIngesta);
      Result := ParseIngestaResponse(Resp);

      // Si el parseo fue ok y el status es éxito o "ya existe", terminamos
      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; // Lo damos por bueno
           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;

        // Si es el último intento, no hacemos sleep
        if Attempt < MAX_RETRIES then
          Sleep(1000); // Esperar 1s antes de reintentar
      end;
    end;
  end;
end;

function TVFEngine.IngestaFromFile(const FileName: string): TVFIngestaResponse;
var
  Json: string;
begin
  if not FileExists(FileName) then
  begin
    Result.Ok := False;
    Result.HttpStatus := -1;
    Result.ErrorMsg := 'No existe el archivo: ' + FileName;
    Exit;
  end;

  Json := TFile.ReadAllText(FileName, TEncoding.UTF8);
  Result := IngestaFromJson(Json);
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);

  // --- CAMBIO CLAVE ---
  // Para que p_n_ultimos sea NULL en el SP, NO se debe enviar el parámetro n_ultimos.
  // Eso solo lo hacemos cuando estamos en modo correlación (id_envio + linea_log_detalle).
  if TieneCorrelacion then
  begin
    // URL SIN n_ultimos => p_n_ultimos = NULL en servidor
    Url := Format('/verifactu/pendientes?nif_emisor=%s&id_envio=%d&linea_log_detalle=%d',
      [UrlEncode(FCfg.NifEmisor), IdEnvio, LineaDetalle]);
  end
  else
  begin
    // Modo listado: respetamos n_ultimos (default 50 si viene <=0)
    N := NUltimos;
    if N < 1 then N := 50;

    Url := Format('/verifactu/pendientes?nif_emisor=%s&n_ultimos=%d',
      [UrlEncode(FCfg.NifEmisor), N]);
  end;
  // --- FIN CAMBIO CLAVE ---

  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 Result.Ingesta.Correlacion.IsValid then
  begin
    Result.ErrorMsg := 'No se pudo obtener correlación (IdEnvio/LineaDetalle) desde la respuesta de ingesta.';
    Exit;
  end;

  T0 := TThread.GetTickCount;

  while True do
  begin
    // Optimizacion MAXIMA: Buscamos especificamente este ID envio y linea detalle
    // (ahora además: NO mandamos n_ultimos => p_n_ultimos llega NULL al SP)
    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 (TThread.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 Corr.IsValid 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];
      Exit(True);
    end;
  end;
end;

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 := TVFCorrelacion.Empty;
  Result.TrackingTimestamp := 0;

  V := TJSONObject.ParseJSONValue(RespJson);
  try
    if not (V is TJSONObject) then
    begin
      Result.ErrorMsg := 'Respuesta de ingesta no es JSON object.';
      Exit;
    end;

    Obj := TJSONObject(V);

    Result.Mensaje := JGetStr(Obj, 'mensaje');
    if Result.Mensaje = '' then
      Result.Mensaje := JGetStr(Obj, 'detail');

    Result.Id := JGetStr(Obj, 'id');
    Result.IdFormateado := JGetStr(Obj, 'id_formateado');
    Result.OperacionRealizada := JGetStr(Obj, 'operacion_realizada');

    Result.Ok := SameText(JGetStr(Obj, 'status'), 'ok');

    Trk := Obj.GetValue('tracking') as TJSONObject;
    if Trk <> nil then
    begin
      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(V as TJSONObject, 'mensaje');
        if Result.ErrorMsg = '' then Result.ErrorMsg := JGetStr(V as TJSONObject, 'detail');
      end;
      if Result.ErrorMsg = '' then
        Result.ErrorMsg := 'Respuesta de pendientes 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
      FillChar(It, SizeOf(It), 0);

      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');
      It.NumFactura := JGetStr(Obj, 'num_serie_factura_numero'); 
      if It.NumFactura = '' then It.NumFactura := JGetStr(Obj, 'numero_factura');
      if It.NumFactura = '' then It.NumFactura := JGetStr(Obj, 'num_factura');

      // Unificamos para la UI: Si hay serie y numero, los juntamos. Si no, lo que haya.
      if (It.NumSerieFactura <> '') and (It.NumFactura <> '') then
        It.NumSerieFactura := It.NumSerieFactura + '-' + It.NumFactura
      else if It.NumFactura <> '' then
        It.NumSerieFactura := It.NumFactura; 
      // Si solo hay serie (raro), se queda serie.

      SFecha := JGetStr(Obj, 'fecha_expedicion_factura');
      It.FechaExpedicion := JGetDateISO(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.';
      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.