Saltar a contenido

Código AL: objetos auxiliares completos

Estos objetos acompañan al codeunit de firma dentro de tu misma extensión. Crea un archivo por bloque en src/PdfSigning/ y conserva cada objeto completo. La guía manual indica el orden de preparación, adaptación y configuración.

Las dos tablas, el helper, las dos páginas de administración y los dos permission sets forman la configuración y autorización de la integración. Incluye también la página Sign PDF: el permission set de usuario la referencia y requiere que exista para compilar estos bloques completos. Permite probar con subida y descarga; puedes reutilizar su acción en tu proceso de negocio.

SigningSecrets.Codeunit.al

Guarda y recupera el bearer cifrado por empresa. Access = Internal requiere que viva en la misma extensión que el codeunit y la página de configuración.

namespace Baltoya.PdfSigning;

codeunit 50101 "BC PDF Signing Secrets"
{
    Access = Internal;

    [NonDebuggable]
    procedure SetToken(Token: SecretText)
    var
        Setup: Record "BC PDF Signing Setup";
    begin
        if not Setup.WritePermission() then
            Error('PDF signing administrator permissions are required to change the service token.');
        if Token.IsEmpty() then
            Error('Enter a service token.');
        IsolatedStorage.SetEncrypted(TokenKeyLbl, Token, DataScope::Company);
    end;

    [NonDebuggable]
    procedure GetToken() Token: SecretText
    begin
        if not IsolatedStorage.Get(TokenKeyLbl, DataScope::Company, Token) then
            Error('Configure the service token in PDF Signing Setup.');
        if Token.IsEmpty() then
            Error('Configure the service token in PDF Signing Setup.');
    end;

    procedure HasToken(): Boolean
    begin
        exit(IsolatedStorage.Contains(TokenKeyLbl, DataScope::Company));
    end;

    var
        TokenKeyLbl: Label 'BC-PDF-SIGN-BEARER-V1', Locked = true;
}

SigningSetup.Table.al

Guarda el origen HTTPS y el timeout. El token no se almacena en esta tabla.

namespace Baltoya.PdfSigning;

table 50100 "BC PDF Signing Setup"
{
    Caption = 'PDF Signing Setup';
    DataClassification = CustomerContent;
    fields
    {
        field(1; "Primary Key"; Code[10]) { Caption = 'Primary Key'; }
        field(2; "Service URL"; Text[250])
        {
            Caption = 'Service URL';
            trigger OnValidate()
            begin
                ValidateServiceUrl();
            end;
        }
        field(3; "Timeout Seconds"; Integer)
        {
            Caption = 'Timeout (seconds)';
            InitValue = 100;
            MinValue = 10;
            MaxValue = 300;
        }
    }
    keys { key(PK; "Primary Key") { Clustered = true; } }

    procedure ValidateServiceUrl()
    begin
        "Service URL" := DelChr("Service URL", '>', '/');
        if (StrLen("Service URL") <= 8) or (LowerCase(CopyStr("Service URL", 1, 8)) <> 'https://') then
            Error('Enter the HTTPS origin of the PDF service, for example https://api.example.com.');
        if (StrPos(CopyStr("Service URL", 9), '/') > 0) or
           (StrPos("Service URL", '@') > 0) or (StrPos("Service URL", '?') > 0) or
           (StrPos("Service URL", '#') > 0) or (StrPos("Service URL", ' ') > 0)
        then
            Error('The service URL must be an HTTPS origin without credentials, paths, queries or fragments.');
    end;
}

CertificateAlias.Table.al

Relaciona el usuario y alias con el código del certificado estándar dentro de la empresa actual.

namespace Baltoya.PdfSigning;

using System.Security.AccessControl;
using System.Security.Encryption;

table 50101 "BC PDF Certificate Alias"
{
    Caption = 'PDF Certificate Alias';
    DataClassification = CustomerContent;
    LookupPageId = "BC PDF Certificate Aliases";
    fields
    {
        field(1; "User Security ID"; Guid)
        {
            Caption = 'User Security ID';
            DataClassification = EndUserIdentifiableInformation;
            TableRelation = User."User Security ID";
        }
        field(2; Alias; Code[100])
        {
            Caption = 'Alias';
            trigger OnValidate()
            begin
                ValidateAlias();
            end;
        }
        field(3; "Certificate Code"; Code[20])
        {
            Caption = 'Certificate Code';
            TableRelation = "Isolated Certificate".Code;
        }
        field(4; Enabled; Boolean) { Caption = 'Enabled'; }
    }
    keys { key(PK; "User Security ID", Alias) { Clustered = true; } }

    procedure ValidateAlias()
    var
        Index: Integer;
        Allowed: Text;
    begin
        TestField(Alias);
        Allowed := 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
        if StrPos(Allowed, CopyStr(Alias, 1, 1)) = 0 then
            Error('The alias must start with a letter or number.');
        Allowed += '._-';
        for Index := 1 to StrLen(Alias) do
            if StrPos(Allowed, CopyStr(Alias, Index, 1)) = 0 then
                Error('The alias can contain letters, numbers, dots, underscores and hyphens only.');
    end;
}

SigningSetup.Page.al

Página PDF Signing Setup: configura URL/timeout, guarda el token y abre la administración de alias.

namespace Baltoya.PdfSigning;

page 50100 "BC PDF Signing Setup"
{
    Caption = 'PDF Signing Setup';
    PageType = Card;
    SourceTable = "BC PDF Signing Setup";
    ApplicationArea = All;
    UsageCategory = Administration;
    InsertAllowed = false;
    DeleteAllowed = false;
    layout
    {
        area(Content)
        {
            group(Service)
            {
                field("Service URL"; Rec."Service URL")
                {
                    ApplicationArea = All;
                    ToolTip = 'Specifies the HTTPS origin of the PDF service, for example https://api.example.com.';
                }
                field("Timeout Seconds"; Rec."Timeout Seconds")
                {
                    ApplicationArea = All;
                    ToolTip = 'Specifies the maximum wait for each HTTP request. Each request may be attempted three times.';
                }
                field(TokenConfigured; TokenConfigured)
                {
                    ApplicationArea = All;
                    Caption = 'Token Configured';
                    Editable = false;
                    ToolTip = 'Specifies whether this company has a service token in encrypted isolated storage.';
                }
                field(NewToken; NewToken)
                {
                    ApplicationArea = All;
                    Caption = 'New Service Token';
                    ExtendedDatatype = Masked;
                    ToolTip = 'Enter an ASCII service bearer token (maximum 215 characters), then choose Save Token. The stored token is never displayed.';
                }
            }
        }
    }
    actions
    {
        area(Processing)
        {
            action(SaveToken)
            {
                ApplicationArea = All;
                Caption = 'Save Token';
                Image = EncryptionKeys;
                ToolTip = 'Stores the new service token encrypted for the current company.';
                trigger OnAction()
                begin
                    SaveNewToken();
                end;
            }
            action(Aliases)
            {
                ApplicationArea = All;
                Caption = 'Certificate Aliases';
                RunObject = page "BC PDF Certificate Aliases";
                ToolTip = 'Manage the certificate aliases authorized for each user in this company.';
            }
        }
    }
    trigger OnOpenPage()
    var
        Secrets: Codeunit "BC PDF Signing Secrets";
    begin
        if not Rec.WritePermission() then
            Error('PDF signing administrator permissions are required.');
        if not Rec.Get('') then begin
            Rec.Init();
            Rec.Insert(true);
        end;
        TokenConfigured := Secrets.HasToken();
    end;

    [NonDebuggable]
    local procedure SaveNewToken()
    var
        Secrets: Codeunit "BC PDF Signing Secrets";
        SecretToken: SecretText;
    begin
        if NewToken = '' then
            Error('Enter the service token.');
        SecretToken := NewToken;
        Clear(NewToken);
        Secrets.SetToken(SecretToken);
        Clear(SecretToken);
        TokenConfigured := true;
    end;

    var
        NewToken: Text[215];
        TokenConfigured: Boolean;
}

CertificateAliases.Page.al

Página PDF Certificate Aliases: crea, habilita y retira las asignaciones por usuario.

namespace Baltoya.PdfSigning;

page 50101 "BC PDF Certificate Aliases"
{
    Caption = 'PDF Certificate Aliases';
    PageType = List;
    SourceTable = "BC PDF Certificate Alias";
    ApplicationArea = All;
    UsageCategory = Administration;
    layout
    {
        area(Content)
        {
            repeater(Aliases)
            {
                field("User Security ID"; Rec."User Security ID")
                {
                    ApplicationArea = All;
                    ToolTip = 'Specifies the user who may use this alias in the current company.';
                }
                field(Alias; Rec.Alias)
                {
                    ApplicationArea = All;
                    ToolTip = 'Specifies the alias passed to FirmarPdf. AL Code values are normalized to uppercase.';
                }
                field("Certificate Code"; Rec."Certificate Code")
                {
                    ApplicationArea = All;
                    ToolTip = 'Specifies the generated code of an imported Business Central certificate with a private key.';
                }
                field(Enabled; Rec.Enabled)
                {
                    ApplicationArea = All;
                    ToolTip = 'Specifies whether the selected user may sign using this alias.';
                }
            }
        }
    }
    trigger OnNewRecord(BelowxRec: Boolean)
    begin
        Rec."User Security ID" := UserSecurityId();
    end;
}

SigningUser.PermissionSet.al

Permisos BC PDF SIGN USER para leer configuración y certificados y ejecutar la firma.

namespace Baltoya.PdfSigning;

using System.Security.Encryption;

permissionset 50101 "BC PDF SIGN USER"
{
    Caption = 'PDF signing user';
    Assignable = true;
    Permissions =
        tabledata "BC PDF Signing Setup" = R,
        tabledata "BC PDF Certificate Alias" = R,
        tabledata "Isolated Certificate" = R,
        table "BC PDF Signing Setup" = X,
        table "BC PDF Certificate Alias" = X,
        codeunit "BC PDF Signing" = X,
        codeunit "BC PDF Signing Secrets" = X,
        codeunit "Certificate Management" = X,
        codeunit "Cryptography Management" = X,
        page "BC PDF Sign Document" = X;
}

SigningAdmin.PermissionSet.al

Permisos BC PDF SIGN ADMIN para modificar configuración y alias; incluye el conjunto de usuario.

namespace Baltoya.PdfSigning;

permissionset 50100 "BC PDF SIGN ADMIN"
{
    Caption = 'PDF signing administrator';
    Assignable = true;
    IncludedPermissionSets = "BC PDF SIGN USER";
    Permissions =
        tabledata "BC PDF Signing Setup" = RIMD,
        tabledata "BC PDF Certificate Alias" = RIMD,
        page "BC PDF Signing Setup" = X,
        page "BC PDF Certificate Aliases" = X;
}

SignPdf.Page.al

Página Sign PDF: sube un PDF, firma y descarga el resultado. Permite reintroducir Operation ID para recuperar una operación.

namespace Baltoya.PdfSigning;

using System.Text;
using System.Utilities;

page 50102 "BC PDF Sign Document"
{
    Caption = 'Sign PDF';
    PageType = Card;
    ApplicationArea = All;
    UsageCategory = Tasks;
    layout
    {
        area(Content)
        {
            group(Document)
            {
                field(CertificateAlias; CertificateAlias)
                {
                    ApplicationArea = All;
                    Caption = 'Certificate Alias';
                    ToolTip = 'Specifies a certificate alias authorized for your user in this company.';
                    Editable = CanEdit;
                    trigger OnLookup(var Text: Text): Boolean
                    var
                        Mapping: Record "BC PDF Certificate Alias";
                    begin
                        Mapping.SetRange("User Security ID", UserSecurityId());
                        Mapping.SetRange(Enabled, true);
                        if Page.RunModal(Page::"BC PDF Certificate Aliases", Mapping) = Action::LookupOK then begin
                            CertificateAlias := Mapping.Alias;
                            Text := CertificateAlias;
                            exit(true);
                        end;
                    end;
                }
                field(FileName; FileName)
                {
                    ApplicationArea = All;
                    Caption = 'Document';
                    ToolTip = 'Shows the PDF currently loaded in the preview.';
                    Editable = false;
                }
                field(OperationId; OperationId)
                {
                    ApplicationArea = All;
                    Caption = 'Operation ID';
                    ToolTip = 'Identifies this signature. Retry retains the PDF, certificate and area. New Operation permits changes.';
                    Editable = false;
                }
                field(OperationStatus; OperationStatus)
                {
                    ApplicationArea = All;
                    Caption = 'Status';
                    ToolTip = 'Shows document loading, selection and signing status.';
                    Editable = false;
                }
            }
            usercontrol(Preview; "BC PDF Signature Preview")
            {
                ApplicationArea = All;
                trigger ViewerReady()
                begin
                    PreviewReady := true;
                    if OriginalPdf.HasValue() then
                        LoadPreview();
                end;
                trigger DocumentLoaded(Generation: Integer; PageCount: Integer)
                var
                    PlacementJson: Text;
                begin
                    if Generation <> DocumentGeneration then
                        exit;
                    DocumentReady := true;
                    if Frozen then begin
                        Placement.WriteTo(PlacementJson);
                        CurrPage.Preview.RestorePlacement(DocumentGeneration, PlacementJson);
                        exit;
                    end;
                    if ConfirmationPending then
                        exit;
                    OperationStatus := 'Choose a signature area in the preview.';
                    CurrPage.Update(false);
                end;
                trigger PlacementChanged(Generation: Integer; IsValid: Boolean)
                begin
                    if (Generation <> DocumentGeneration) or Frozen or ConfirmationPending then
                        exit;
                    PlacementValid := IsValid;
                    CurrPage.Update(false);
                end;
                trigger PlacementConfirmed(Generation: Integer; PlacementJson: Text)
                begin
                    if (Generation <> DocumentGeneration) or not ConfirmationPending then
                        exit;
                    ConfirmationPending := false;
                    if not Placement.ReadFrom(PlacementJson) then begin
                        CanEdit := true;
                        CurrPage.Preview.SetLocked(false);
                        OperationStatus := 'Choose a valid signature area before signing.';
                        CurrPage.Update(false);
                        exit;
                    end;
                    // Freeze BEFORE the first HTTP call, including failures or lost responses.
                    Frozen := true;
                    CanEdit := false;
                    SignAndDownloadPdf();
                end;
                trigger ViewerError(Generation: Integer; Message: Text)
                begin
                    if Generation <> DocumentGeneration then
                        exit;
                    DocumentReady := false;
                    PlacementValid := false;
                    ConfirmationPending := false;
                    CanEdit := not Frozen;
                    OperationStatus := Message;
                    CurrPage.Update(false);
                end;
            }
        }
    }
    actions
    {
        area(Processing)
        {
            action(UploadPdf)
            {
                ApplicationArea = All;
                Caption = 'Upload PDF';
                Image = Import;
                Enabled = CanEdit;
                ToolTip = 'Loads a PDF locally in the preview so you can choose the signature position.';
                trigger OnAction()
                var
                    UploadedPdf: Codeunit "Temp Blob";
                    PdfIn: InStream;
                    PdfOut: OutStream;
                    UploadedName: Text;
                begin
                    if Frozen or ConfirmationPending then
                        Error('Choose New Operation before replacing the PDF.');
                    if not UploadIntoStream('Select PDF', '', 'PDF files (*.pdf)|*.pdf', UploadedName, PdfIn) then
                        exit;
                    UploadedPdf.CreateOutStream(PdfOut);
                    CopyStream(PdfOut, PdfIn);
                    if (UploadedPdf.Length() = 0) or (UploadedPdf.Length() > 5 * 1024 * 1024) then
                        Error('Upload a non-empty PDF no larger than 5 MB.');
                    OriginalPdf := UploadedPdf;
                    FileName := UploadedName;
                    ResetOperation();
                    if PreviewReady then
                        LoadPreview();
                end;
            }
            action(SignAndDownload)
            {
                ApplicationArea = All;
                Caption = 'Sign and Download';
                Image = Export;
                Enabled = CanSign;
                ToolTip = 'Confirms the current area and signs. On failure, retry with this same frozen operation.';
                trigger OnAction()
                var
                    Signing: Codeunit "BC PDF Signing";
                begin
                    if ConfirmationPending then
                        exit;
                    if Frozen then begin
                        SignAndDownloadPdf();
                        exit;
                    end;
                    if not DocumentReady or not PlacementValid then
                        Error('Upload a PDF and choose a valid signature area.');
                    FrozenThumbprint := Signing.CertificateThumbprint(CertificateAlias);
                    FrozenAlias := CertificateAlias;
                    ConfirmationPending := true;
                    CanEdit := false;
                    OperationStatus := 'Confirming the signature area…';
                    CurrPage.Update(false);
                    CurrPage.Preview.RequestPlacement(DocumentGeneration);
                end;
            }
            action(NewOperation)
            {
                ApplicationArea = All;
                Caption = 'New Operation';
                Image = New;
                ToolTip = 'Creates a new signature operation and unlocks the preview for a new selection.';
                trigger OnAction()
                begin
                    ResetOperation();
                    if PreviewReady and OriginalPdf.HasValue() then
                        LoadPreview();
                end;
            }
        }
    }
    trigger OnOpenPage()
    begin
        ResetOperation();
    end;
    trigger OnAfterGetCurrRecord()
    begin
        CanSign := not ConfirmationPending and (Frozen or (DocumentReady and PlacementValid));
    end;

    local procedure ResetOperation()
    begin
        OperationId := CreateGuid();
        DocumentGeneration += 1;
        Frozen := false;
        ConfirmationPending := false;
        DocumentReady := false;
        PlacementValid := false;
        CanEdit := true;
        Clear(Placement);
        Clear(FrozenAlias);
        Clear(FrozenThumbprint);
        OperationStatus := 'Upload a PDF, then choose a signature area.';
        CurrPage.Update(false);
    end;

    local procedure LoadPreview()
    var
        Base64Convert: Codeunit "Base64 Convert";
        PdfIn: InStream;
    begin
        OriginalPdf.CreateInStream(PdfIn);
        OperationStatus := 'Loading PDF…';
        CurrPage.Preview.LoadPdf(Base64Convert.ToBase64(PdfIn), DocumentGeneration);
    end;

    local procedure SignAndDownloadPdf()
    var
        SignedPdf: Codeunit "Temp Blob";
        SignedIn: InStream;
        DownloadName: Text;
    begin
        OperationStatus := 'Signing PDF…';
        CurrPage.Preview.SetLocked(true);
        ClearLastError();
        if not TrySign(SignedPdf) then begin
            OperationStatus := 'Signing failed. Retry Sign and Download, or choose New Operation.';
            CurrPage.Update(false);
            Message('%1', GetLastErrorText());
            exit;
        end;
        OperationStatus := 'Signed. You can download again with Sign and Download.';
        SignedPdf.CreateInStream(SignedIn);
        DownloadName := 'signed.pdf';
        DownloadFromStream(SignedIn, '', '', '', DownloadName);
        CurrPage.Update(false);
    end;

    [TryFunction]
    local procedure TrySign(var SignedPdf: Codeunit "Temp Blob")
    var
        Signing: Codeunit "BC PDF Signing";
        PdfIn: InStream;
    begin
        OriginalPdf.CreateInStream(PdfIn);
        Signing.FirmarPdf(PdfIn, FrozenAlias, OperationId, Placement, FrozenThumbprint, SignedPdf);
    end;

    var
        OriginalPdf: Codeunit "Temp Blob";
        Placement: JsonObject;
        CertificateAlias: Code[100];
        FrozenAlias: Code[100];
        FrozenThumbprint: Text;
        OperationId: Guid;
        FileName: Text;
        OperationStatus: Text;
        DocumentGeneration: Integer;
        PreviewReady: Boolean;
        DocumentReady: Boolean;
        PlacementValid: Boolean;
        ConfirmationPending: Boolean;
        Frozen: Boolean;
        CanEdit: Boolean;
        CanSign: Boolean;
}

PdfPreview.ControlAddIn.al

Incluye los recursos del visor en las rutas declaradas más abajo. Las instrucciones de compilación y validación están en el README de las fuentes Business Central.

namespace Baltoya.PdfSigning;

controladdin "BC PDF Signature Preview"
{
    RequestedHeight = 600;
    MinimumHeight = 420;
    HorizontalStretch = true;
    VerticalStretch = true;
    Scripts = 'controladdin/pdf.worker.js', 'controladdin/viewer.js';
    StyleSheets = 'controladdin/viewer.css';
    StartupScript = 'viewer/startup.js';

    event ViewerReady();
    event DocumentLoaded(Generation: Integer; PageCount: Integer);
    event PlacementChanged(Generation: Integer; IsValid: Boolean);
    event PlacementConfirmed(Generation: Integer; PlacementJson: Text);
    event ViewerError(Generation: Integer; Message: Text);
    procedure LoadPdf(PdfBase64: Text; Generation: Integer);
    procedure RequestPlacement(Generation: Integer);
    procedure SetLocked(IsLocked: Boolean);
    procedure RestorePlacement(Generation: Integer; PlacementJson: Text);
}

Fuente y mantenimiento

Cada bloque es una copia literal del archivo homónimo del directorio de fuentes AL. Mantén estos bloques y el codeunit sincronizados cuando cambie el código; conserva una versión coherente de todos los objetos.

Con los archivos pegados, vuelve a adaptar y compilar tu extensión, asignar permisos y configurar certificados y alias.