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.