Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.37% covered (warning)
71.37%
187 / 262
16.67% covered (danger)
16.67%
1 / 6
CRAP
n/a
0 / 0
authorization_normalize_taxid
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
authorization_extract_certificate_taxids
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
12.02
authorization_read_dn_string
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
17.58
authorization_build_certificate_signature_data
73.58% covered (warning)
73.58%
39 / 53
0.00% covered (danger)
0.00%
0 / 1
27.37
authorization_extract_certificate_data
25.71% covered (danger)
25.71%
9 / 35
0.00% covered (danger)
0.00%
0 / 1
107.24
authorization_generate_signed_pdf
86.05% covered (success)
86.05%
37 / 43
0.00% covered (danger)
0.00%
0 / 1
6.10
1<?php
2
3/**
4 * @file
5 * Fiscal action: generate and sign fiscal authorization PDF.
6 */
7
8declare(strict_types=1);
9
10require_once __DIR__.'/../../../../vendor/autoload.php';
11require_once __DIR__.'/Template.php';
12
13use Dompdf\Dompdf;
14
15/**
16 * Normalize a tax ID value to compare certificates and form input.
17 *
18 * @param string $taxid Input tax ID.
19 * @return string Normalized tax ID (uppercase, alphanumeric only).
20 */
21function authorization_normalize_taxid(string $taxid): string
22{
23    $normalized = strtoupper(trim($taxid));
24    return preg_replace('/[^A-Z0-9]/', '', $normalized) ?? '';
25}
26
27/**
28 * Extract tax ID candidates from certificate subject data.
29 *
30 * @param string $certificatePem PEM-encoded certificate.
31 * @return array<int, string> Distinct normalized tax ID candidates.
32 */
33function authorization_extract_certificate_taxids(string $certificatePem): array
34{
35    $taxids = [];
36    $parsed = @openssl_x509_parse($certificatePem);
37    if (!is_array($parsed)) {
38        return $taxids;
39    }
40
41    $values = [];
42    if (isset($parsed['subject']) && is_array($parsed['subject'])) {
43        foreach ($parsed['subject'] as $value) {
44            if (is_string($value)) {
45                $values[] = $value;
46            }
47        }
48    }
49    foreach (['name', 'subject', 'issuer'] as $key) {
50        if (isset($parsed[$key]) && is_string($parsed[$key])) {
51            $values[] = $parsed[$key];
52        }
53    }
54
55    $pattern = '/([A-Z][0-9]{7}[A-Z0-9]|[0-9]{8}[A-Z])/';
56    foreach ($values as $value) {
57        if (preg_match_all($pattern, strtoupper($value), $matches)) {
58            foreach ($matches[1] as $candidate) {
59                $taxids[] = authorization_normalize_taxid($candidate);
60            }
61        }
62    }
63
64    return array_values(array_unique(array_filter($taxids)));
65}
66
67/**
68 * Read a subject/issuer string field from a parsed certificate section.
69 *
70 * @param array<string, mixed> $section Parsed certificate section.
71 * @param string $key Field key.
72 * @return string
73 */
74function authorization_read_dn_string(array $section, string $key): string
75{
76    if (!isset($section[$key])) {
77        return '';
78    }
79
80    $value = $section[$key];
81    if (is_string($value)) {
82        return trim($value);
83    }
84
85    if (is_array($value)) {
86        foreach ($value as $item) {
87            if (is_string($item) && trim($item) !== '') {
88                return trim($item);
89            }
90        }
91    }
92
93    return '';
94}
95
96/**
97 * Build human-readable signer certificate data to embed in the visible PDF HTML.
98 *
99 * @param string $certificatePem PEM-encoded certificate.
100 * @return array<string, string>
101 */
102function authorization_build_certificate_signature_data(string $certificatePem): array
103{
104    $empty = [
105        'subjectName' => '',
106        'subjectTaxId' => '',
107        'serialNumber' => '',
108        'issuerName' => '',
109        'validFrom' => '',
110        'validTo' => '',
111        'fingerprintSha256' => '',
112    ];
113
114    $parsed = @openssl_x509_parse($certificatePem);
115    if (!is_array($parsed)) {
116        return $empty;
117    }
118
119    $subject = isset($parsed['subject']) && is_array($parsed['subject']) ? $parsed['subject'] : [];
120    $issuer = isset($parsed['issuer']) && is_array($parsed['issuer']) ? $parsed['issuer'] : [];
121
122    $subjectName = authorization_read_dn_string($subject, 'CN');
123    if ($subjectName === '') {
124        $subjectName = authorization_read_dn_string($subject, 'commonName');
125    }
126    if ($subjectName === '') {
127        $subjectName = trim(
128            authorization_read_dn_string($subject, 'givenName').' '.authorization_read_dn_string($subject, 'surname')
129        );
130    }
131    if ($subjectName === '') {
132        $subjectName = authorization_read_dn_string($subject, 'O');
133    }
134
135    $subjectTaxId = authorization_read_dn_string($subject, 'serialNumber');
136    if ($subjectTaxId === '') {
137        $taxids = authorization_extract_certificate_taxids($certificatePem);
138        $subjectTaxId = isset($taxids[0]) ? (string) $taxids[0] : '';
139    }
140
141    $serialNumber = isset($parsed['serialNumberHex']) && is_string($parsed['serialNumberHex'])
142        ? trim($parsed['serialNumberHex'])
143        : '';
144    if ($serialNumber === '' && isset($parsed['serialNumber'])) {
145        $serialNumber = is_string($parsed['serialNumber'])
146            ? trim($parsed['serialNumber'])
147            : (string) $parsed['serialNumber'];
148    }
149
150    $issuerName = authorization_read_dn_string($issuer, 'CN');
151    if ($issuerName === '') {
152        $issuerName = authorization_read_dn_string($issuer, 'O');
153    }
154
155    $validFromTimestamp = (int) ($parsed['validFrom_time_t'] ?? 0);
156    $validToTimestamp = (int) ($parsed['validTo_time_t'] ?? 0);
157    $validFrom = $validFromTimestamp > 0 ? date('c', $validFromTimestamp) : '';
158    $validTo = $validToTimestamp > 0 ? date('c', $validToTimestamp) : '';
159
160    $fingerprintSha256 = @openssl_x509_fingerprint($certificatePem, 'sha256');
161    if (!is_string($fingerprintSha256)) {
162        $fingerprintSha256 = '';
163    }
164
165    return [
166        'subjectName' => $subjectName,
167        'subjectTaxId' => $subjectTaxId,
168        'serialNumber' => $serialNumber,
169        'issuerName' => $issuerName,
170        'validFrom' => $validFrom,
171        'validTo' => $validTo,
172        'fingerprintSha256' => strtoupper($fingerprintSha256),
173    ];
174}
175
176/**
177 * Parse certificate payload and extract certificate + private key.
178 *
179 * @param string $certificateData Raw file content from uploaded certificate.
180 * @param string $certificatePassword Certificate password.
181 * @param string|null $certificatePem Output cert PEM.
182 * @param string|null $privateKeyPem Output private key PEM.
183 * @param \Psr\Log\LoggerInterface $logger Logger.
184 * @return int ApplicationError code.
185 */
186function authorization_extract_certificate_data(string $certificateData, string $certificatePassword, ?string &$certificatePem, ?string &$privateKeyPem, \Psr\Log\LoggerInterface $logger): int
187{
188    $retval = \ApplicationError::Success;
189    $certificatePem = null;
190    $privateKeyPem = null;
191
192    $certs = [];
193    if (@openssl_pkcs12_read($certificateData, $certs, $certificatePassword)) {
194        $certificatePem = isset($certs['cert']) ? (string) $certs['cert'] : null;
195        $privateKeyPem = isset($certs['pkey']) ? (string) $certs['pkey'] : null;
196        if (!$certificatePem || !$privateKeyPem) {
197            $retval = \ApplicationError::Certificate;
198            $logger->error("Uploaded PKCS12 certificate does not contain cert and private key.");
199        }
200    }
201    else {
202        $certResource = @openssl_x509_read($certificateData);
203        if ($certResource === false) {
204            $retval = \ApplicationError::Certificate;
205            $logger->error("Uploaded certificate could not be parsed as PKCS12 or PEM.");
206        }
207
208        if (success($retval)) {
209            $exported = '';
210            if (!@openssl_x509_export($certResource, $exported)) {
211                $retval = \ApplicationError::Certificate;
212                $logger->error("Could not export PEM certificate for signing.");
213            }
214            else {
215                $certificatePem = $exported;
216            }
217        }
218
219        if (success($retval)) {
220            $privateKey = @openssl_pkey_get_private($certificateData, $certificatePassword);
221            if ($privateKey === false) {
222                $privateKey = @openssl_pkey_get_private($certificateData);
223            }
224            if ($privateKey === false) {
225                $retval = \ApplicationError::Certificate;
226                $logger->error("Could not extract private key from uploaded PEM certificate.");
227            }
228            else {
229                $keyExported = '';
230                if (!@openssl_pkey_export($privateKey, $keyExported, $certificatePassword)) {
231                    if (!@openssl_pkey_export($privateKey, $keyExported)) {
232                        $retval = \ApplicationError::Certificate;
233                        $logger->error("Could not export private key for PDF signature.");
234                    }
235                }
236                if (success($retval)) {
237                    $privateKeyPem = $keyExported;
238                }
239            }
240        }
241    }
242
243    return $retval;
244}
245
246/**
247 * Generate signed PDF from HTML content.
248 *
249 * @param string $destinationPath Output PDF path.
250 * @param string $html PDF HTML source.
251 * @param string $certificatePem Certificate PEM.
252 * @param string $privateKeyPem Private key PEM.
253 * @param string $certificatePassword Private key password.
254 * @param array<string, mixed> $payload Request payload.
255 * @param \Psr\Log\LoggerInterface $logger Logger.
256 * @return int ApplicationError code.
257 */
258function authorization_generate_signed_pdf(string $destinationPath, string $html, string $certificatePem, string $privateKeyPem, string $certificatePassword, array $payload, \Psr\Log\LoggerInterface $logger): int
259{
260    $retval = \ApplicationError::Success;
261    try {
262        $dompdf = new Dompdf();
263        $dompdf->loadHtml($html, 'UTF-8');
264        $dompdf->setPaper('A4', 'portrait');
265        $dompdf->render();
266
267        $canvas = $dompdf->getCanvas();
268        if ($canvas instanceof \Dompdf\Adapter\CPDF) {
269            $cpdf = $canvas->get_cpdf();
270            $cpdf->signatureMaxLen = 65536;
271            if (!isset($cpdf->acroFormId)) {
272                $cpdf->addForm(3, false);
273            }
274            $cpdf->currentNode = 3;
275            $cpdf->selectFont('Helvetica');
276
277            $fieldSignatureId = $cpdf->addFormField(
278                \Dompdf\Cpdf::ACROFORM_FIELD_SIG,
279                'AuthorizationSignature',
280                0,
281                0,
282                0,
283                0,
284                \Dompdf\Cpdf::ACROFORM_FIELD_READONLY
285            );
286            $signatureId = $cpdf->addSignature(
287                $certificatePem,
288                $privateKeyPem,
289                $certificatePassword,
290                (string) ($payload['authorizerName'] ?? ''),
291                (string) ($payload['signatureCity'] ?? ''),
292                'Autorizacion de representacion fiscal',
293                (string) ($payload['authorizerEmail'] ?? '')
294            );
295            $cpdf->setFormFieldRefValue($fieldSignatureId, $signatureId);
296        }
297        else {
298            $retval = \ApplicationError::Certificate;
299            $logger->error("Dompdf canvas does not expose Cpdf object for digital signature.");
300        }
301
302        if (success($retval)) {
303            $output = $dompdf->output();
304            if (file_put_contents($destinationPath, $output) === false) {
305                $retval = \ApplicationError::Unknown;
306                $logger->error("Could not write signed authorization PDF to '".$destinationPath."'.");
307            }
308        }
309    }
310    catch (\Throwable $error) {
311        $retval = \ApplicationError::Unknown;
312        $logger->error("Could not generate signed authorization PDF: ".$error->getMessage());
313    }
314
315    return $retval;
316}
317
318/**
319 * Action callable: generate and sign fiscal authorization PDF.
320 *
321 * @param string $body Request body with JSON-encoded fields.
322 * @param array<string, mixed> $query Query params: device, session, place.
323 * @param \ConxHelper $conx Connection helper.
324 * @param \Psr\Log\LoggerInterface $logger Logger.
325 * @param \UppServices\SessionService $sessionService Session service for check.
326 * @return array{output: string, contentType: string}
327 */
328return function (string $body, array $query, \ConxHelper $conx, \Psr\Log\LoggerInterface $logger, \UppServices\SessionService $sessionService): array {
329    $retval = \ApplicationError::Success;
330    $payload = [];
331    $place = null;
332
333    if (success($retval)) {
334        $payload = json_decode($body, true);
335        if (!is_array($payload)) {
336            $retval = \ApplicationError::Parameters;
337            $logger->error("Invalid JSON payload for authorization endpoint.");
338        }
339    }
340
341    $device = $query['device'] ?? null;
342    if (success($retval) && !$device) {
343        $retval = \ApplicationError::Parameters;
344        $logger->error("No device provided on URL. Operation cancelled.");
345    }
346
347    if (success($retval)) {
348        $session = $query['session'] ?? null;
349        if (!$session) {
350            $retval = \ApplicationError::Parameters;
351            $logger->error("No session provided on URL. Operation cancelled.");
352        }
353        else {
354            $userid = null;
355            $retval = $sessionService->checkSession($conx, (string) $session, (string) $device, $userid);
356        }
357    }
358
359    if (success($retval)) {
360        $place = $query['place'] ?? null;
361        if (!$place) {
362            $retval = \ApplicationError::Parameters;
363            $logger->error("Mandatory parameter 'place' not provided on URL. Operation cancelled.");
364        }
365    }
366
367    $requiredFields = [
368        'authorizerName', 'authorizerTaxId', 'authorizerAddress',
369        'authorizerEmail', 'authorizerPhone',
370        'signatureCity',
371        'certificateContents',
372    ];
373    if (success($retval)) {
374        foreach ($requiredFields as $field) {
375            $value = (string) ($payload[$field] ?? '');
376            if (trim($value) === '') {
377                $retval = \ApplicationError::Parameters;
378                $logger->error("Mandatory field '".$field."' not provided on authorization payload.");
379                break;
380            }
381        }
382    }
383
384    if (success($retval)) {
385        $isLegalRepresentative = ((string) ($payload['isLegalRepresentative'] ?? '0')) === '1';
386        if ($isLegalRepresentative) {
387            foreach (['legalRepresentativeName', 'legalRepresentativeTaxId'] as $field) {
388                $value = (string) ($payload[$field] ?? '');
389                if (trim($value) === '') {
390                    $retval = \ApplicationError::Parameters;
391                    $logger->error("Missing legal representative field '".$field."' in authorization payload.");
392                    break;
393                }
394            }
395        }
396    }
397
398    $certificateRawData = '';
399    if (success($retval)) {
400        $certificateRawData = base64_decode((string) ($payload['certificateContents'] ?? ''), true);
401        if ($certificateRawData === false || $certificateRawData === '') {
402            $retval = \ApplicationError::Parameters;
403            $logger->error("Could not decode uploaded certificate content from payload.");
404        }
405    }
406
407    $certificatePem = null;
408    $privateKeyPem = null;
409    if (success($retval)) {
410        $retval = authorization_extract_certificate_data(
411            $certificateRawData,
412            (string) $payload['certificatePassword'],
413            $certificatePem,
414            $privateKeyPem,
415            $logger
416        );
417    }
418
419    if (success($retval)) {
420        $expectedTaxId = authorization_normalize_taxid((string) $payload['authorizerTaxId']);
421        $isLegalRepresentative = ((string) ($payload['isLegalRepresentative'] ?? '0')) === '1';
422        if ($isLegalRepresentative) {
423            $expectedTaxId = authorization_normalize_taxid((string) ($payload['legalRepresentativeTaxId'] ?? ''));
424        }
425
426        $taxids = authorization_extract_certificate_taxids((string) $certificatePem);
427        if ($expectedTaxId === '' || !in_array($expectedTaxId, $taxids, true)) {
428            $retval = \ApplicationError::Certificate;
429            $logger->error("Uploaded certificate tax ID does not match expected signer tax ID.");
430        }
431    }
432
433    $destinationPath = null;
434    $destinationFilename = null;
435    if (success($retval)) {
436        $legalFolder = \AppConstants::LegalPath();
437        if ($legalFolder === '' || (!is_dir($legalFolder) && !@mkdir($legalFolder, 0755, true) && !is_dir($legalFolder))) {
438            $retval = \ApplicationError::NotFound;
439            $logger->error("Legal folder was not found or could not be created at '".$legalFolder."'.");
440        }
441        else {
442            $destinationFilename = str_pad((string) ((int) $place), 8, '0', STR_PAD_LEFT).'_autorizacion_firmada.pdf';
443            $destinationPath = $legalFolder.'/'.$destinationFilename;
444        }
445    }
446
447    if (success($retval) && $destinationPath !== null) {
448        $certificateSignatureData = authorization_build_certificate_signature_data((string) $certificatePem);
449        $html = authorization_build_html($payload, $certificateSignatureData);
450        $retval = authorization_generate_signed_pdf(
451            $destinationPath,
452            $html,
453            (string) $certificatePem,
454            (string) $privateKeyPem,
455            (string) $payload['certificatePassword'],
456            $payload,
457            $logger
458        );
459    }
460
461    $result = [
462        "errorcode" => $retval,
463        "filename" => success($retval) ? $destinationFilename : null,
464    ];
465    return ['output' => json_encode($result), 'contentType' => 'application/json; charset=utf-8'];
466};
467?>