Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.49% covered (warning)
77.49%
358 / 462
13.33% covered (danger)
13.33%
2 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
TicketFiscal
77.49% covered (warning)
77.49%
358 / 462
13.33% covered (danger)
13.33%
2 / 15
330.76
0.00% covered (danger)
0.00%
0 / 1
 closePaidTickets
83.33% covered (success)
83.33%
90 / 108
0.00% covered (danger)
0.00%
0 / 1
25.45
 emitFiscalDocuments
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 commercialSnapshotAmounts
63.33% covered (warning)
63.33%
19 / 30
0.00% covered (danger)
0.00%
0 / 1
9.42
 updateTicketDataRegistry
76.60% covered (warning)
76.60%
36 / 47
0.00% covered (danger)
0.00%
0 / 1
16.51
 applyPaylinePayments
81.82% covered (success)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
 ensurePaylinePayments
75.86% covered (warning)
75.86%
22 / 29
0.00% covered (danger)
0.00%
0 / 1
10.14
 emitTicketBaiRow
82.00% covered (success)
82.00%
41 / 50
0.00% covered (danger)
0.00%
0 / 1
10.58
 emitVerifactuRow
71.43% covered (warning)
71.43%
35 / 49
0.00% covered (danger)
0.00%
0 / 1
12.33
 persistFiscalRow
71.74% covered (warning)
71.74%
33 / 46
0.00% covered (danger)
0.00%
0 / 1
20.08
 updateTicketDataFiscalQr
76.09% covered (warning)
76.09%
35 / 46
0.00% covered (danger)
0.00%
0 / 1
23.94
 isPlaceOptionEnabled
87.50% covered (success)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
8.12
 blankFiscalEntry
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 ensureTicketBaiDomain
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 ensureVerifactuDomain
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 toIso8601
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
1<?php
2
3declare(strict_types=1);
4
5namespace UppServices\fiscal;
6
7use Psr\Log\LoggerInterface;
8
9/**
10 * Server-side fiscal close for Stripe-paid GUEST tickets: TICKETCHANGE reason F,
11 * TICKETDATA.registry update, and optional TICKETBAI / VERIFACTU emission + dispatch.
12 *
13 * QR id/url generation lives in UppServices\ticketbai\FiscalQr and
14 * UppServices\verifactu\FiscalQr (via domain prev_complete_*). This class only
15 * persists fiscal.qr into TICKETDATA.registry after emission.
16 *
17 * Must run only after PAYMENT/TICKET payment commit: failures here
18 * are logged and do not undo the cobro.
19 */
20class TicketFiscal
21{
22    /**
23     * Creates TICKETCHANGE F + registry updates for paid tickets when a commercial
24     * snapshot exists, then emits fiscal rows when the place config requires it.
25     * Idempotent for change F and fiscal UNIQUE(tckchg).
26     *
27     * @param list<array<string, mixed>> $tickets Tickets with definitive series/invceno
28     * @return array{errorcode: int, changes?: int}
29     */
30    public static function closePaidTickets(
31        \ConxHelper $conx,
32        string $placeId,
33        array $tickets,
34        ?string $sessionObjid,
35        LoggerInterface $logger
36    ): array {
37        $created = 0;
38        /** @var list<array{table: string, objid: int|string}> $publish */
39        $publish = [];
40        /** @var list<array{ticketId: int|string, changeObjid: int|string}> $pendingFiscal */
41        $pendingFiscal = [];
42
43        if (!$conx->begin()) {
44            $logger->error('Could not begin transaction for Stripe ticket fiscal close.');
45            return ['errorcode' => \ApplicationError::Database];
46        }
47
48        $changeTx = $conx->trn('TICKETCHANGE');
49        $dataTx = $conx->trn('TICKETDATA');
50        if ($changeTx === null || $dataTx === null) {
51            $conx->flush(false);
52            $logger->error('Could not open TICKETCHANGE/TICKETDATA transactions for Stripe fiscal close.');
53            return ['errorcode' => \ApplicationError::Database];
54        }
55
56        foreach ($tickets as $ticket) {
57            $ticketId = $ticket['objid'] ?? null;
58            if ($ticketId === null || $ticketId === '') {
59                continue;
60            }
61
62            $existing = [];
63            $res = $conx->tenant->query('TICKETCHANGE', [
64                'ticket' => $ticketId,
65                'reason' => 'F',
66            ], $existing);
67            if (!$res) {
68                $conx->flush(false);
69                return ['errorcode' => \ApplicationError::Database];
70            }
71
72            if (count($existing) > 0) {
73                // Heal print registry when an older close left payments empty.
74                $healRet = self::ensurePaylinePayments(
75                    $conx,
76                    $dataTx,
77                    $ticketId,
78                    $ticket,
79                    $publish,
80                    $logger
81                );
82
83                if ($healRet !== \ApplicationError::Success) {
84                    $conx->flush(false);
85                    return ['errorcode' => $healRet];
86                }
87                
88                $pendingFiscal[] = [
89                    'ticketId' => $ticketId,
90                    'changeObjid' => $existing[0]['objid'],
91                ];
92                continue;
93            }
94
95            $amounts = self::commercialSnapshotAmounts($conx, $ticket, $logger);
96            if ($amounts === null) {
97                $conx->flush(false);
98                return ['errorcode' => \ApplicationError::Invalid];
99            }
100
101            $series = (string) ($ticket['series'] ?? '');
102            $invoice = (string) ($ticket['invceno'] ?? '');
103            $now = $conx->tenant->now();
104
105            $changeRow = [
106                'ticket' => $ticketId,
107                'prev' => null,
108                'session' => $sessionObjid,
109                'reason' => 'F',
110                'series' => $series,
111                'invoice' => $invoice,
112                'total' => $amounts['total'],
113                'base' => $amounts['base'],
114                'taxes' => $amounts['taxes'],
115                'client' => null,
116                'created' => $now,
117            ];
118
119            $changeObjid = null;
120            
121            $ok = $changeTx->insert('TICKETCHANGE', $changeRow, $changeObjid);
122            if (!$ok || $changeObjid === null || $changeObjid === false || $changeObjid === 0 || $changeObjid === '0') {
123                $conx->flush(false);
124                $logger->error('Could not insert TICKETCHANGE F for ticket '.$ticketId.'.');
125                return ['errorcode' => \ApplicationError::Database];
126            }
127            
128            $created++;
129            $publish[] = ['table' => 'TICKETCHANGE', 'objid' => $changeObjid];
130            $pendingFiscal[] = [
131                'ticketId' => $ticketId,
132                'changeObjid' => $changeObjid,
133            ];
134
135            $dataRet = self::updateTicketDataRegistry(
136                $conx,
137                $dataTx,
138                $ticketId,
139                $series,
140                $invoice,
141                $amounts,
142                $now,
143                $publish,
144                $logger
145            );
146            
147            if ($dataRet !== \ApplicationError::Success) {
148                $conx->flush(false);
149                return ['errorcode' => $dataRet];
150            }
151        }
152
153        if (!$conx->flush(true)) {
154            $logger->error('Could not commit Stripe ticket fiscal close.');
155            return ['errorcode' => \ApplicationError::Database];
156        }
157
158        if (count($publish) > 0) {
159            $updates = [];
160            foreach ($publish as $change) {
161                $pushRet = \push_change($conx, $change, $updates);
162                if (!\success($pushRet)) {
163                    $logger->error('Could not register '.$change['table'].' after Stripe fiscal close.');
164                }
165            }
166            if (count($updates) > 0) {
167                \commit_changes($conx, $placeId, null, $updates);
168            }
169        }
170
171        foreach ($pendingFiscal as $item) {
172            self::emitFiscalDocuments(
173                $conx,
174                $placeId,
175                $item['ticketId'],
176                $item['changeObjid'],
177                $sessionObjid,
178                $logger
179            );
180        }
181
182        return ['errorcode' => \ApplicationError::Success, 'changes' => $created];
183    }
184
185    /**
186     * Emits TICKETBAI and/or VERIFACTU for a paid change when place options require it.
187     * Failures are logged only; they must not undo the payment.
188     *
189     * @return list<array{table: string, objid: int|string}> Rows to echo on the sync COMMITED
190     *         response (GUEST Flush) so numbering + fiscal QR arrive in the same round-trip.
191     */
192    public static function emitFiscalDocuments(
193        \ConxHelper $conx,
194        string $placeId,
195        int|string $ticketId,
196        int|string $changeObjid,
197        ?string $sessionObjid,
198        LoggerInterface $logger
199    ): array {
200        /** @var list<array{table: string, objid: int|string}> $publish */
201        $publish = [];
202
203        if (self::isPlaceOptionEnabled($conx, 'optionBaiEnabled')) {
204            $publish = array_merge(
205                $publish,
206                self::emitTicketBaiRow($conx, $placeId, $ticketId, $changeObjid, $sessionObjid, $logger)
207            );
208        }
209
210        if (self::isPlaceOptionEnabled($conx, 'optionVftEnabled')) {
211            $publish = array_merge(
212                $publish,
213                self::emitVerifactuRow($conx, $placeId, $ticketId, $changeObjid, $sessionObjid, $logger)
214            );
215        }
216        
217        return $publish;
218    }
219
220    /**
221     * Reads commercial.totals from TICKETDATA.registry and checks against TICKET.total.
222     *
223     * @param array<string, mixed> $ticket
224     * @return array{total: float, base: float, taxes: float}|null
225     */
226    public static function commercialSnapshotAmounts(
227        \ConxHelper $conx,
228        array $ticket,
229        LoggerInterface $logger
230    ): ?array {
231        $ticketId = $ticket['objid'] ?? null;
232        $rows = [];
233        $res = $conx->tenant->query('TICKETDATA', ['ticket' => $ticketId], $rows);
234        if (!$res) {
235            $logger->error('Could not load TICKETDATA for ticket '.$ticketId.'.');
236            return null;
237        }
238        if (count($rows) === 0) {
239            $logger->error('TICKETDATA missing for ticket '.$ticketId.'; Stripe fiscal close aborted.');
240            return null;
241        }
242
243        $registry = json_decode((string) ($rows[0]['registry'] ?? ''), true);
244        if (!is_array($registry)) {
245            $logger->error('Invalid TICKETDATA.registry JSON for ticket '.$ticketId.'; Stripe fiscal close aborted.');
246            return null;
247        }
248
249        $totals = $registry['commercial']['totals'] ?? null;
250        if (!is_array($totals) || !isset($totals['total'], $totals['base'], $totals['amount'])) {
251            $logger->error('TICKETDATA commercial snapshot missing for ticket '.$ticketId.'; Stripe fiscal close aborted.');
252            return null;
253        }
254
255        $snapshotTotal = round((float) $totals['total'], 2);
256        // TICKET column is `price` (commercial total). Accept legacy `total` only
257        // as a fallback for in-memory test doubles that used that key.
258        $ticketTotal = round((float) ($ticket['price'] ?? $ticket['total'] ?? 0), 2);
259        if (abs($snapshotTotal - $ticketTotal) > 0.01) {
260            $logger->error(
261                'TICKETDATA commercial total mismatch for ticket '.$ticketId
262                .' (snapshot='.$snapshotTotal.', ticket='.$ticketTotal.'); Stripe fiscal close aborted.'
263            );
264            return null;
265        }
266
267        return [
268            'total' => $snapshotTotal,
269            'base' => round((float) $totals['base'], 2),
270            'taxes' => round((float) $totals['amount'], 2),
271        ];
272    }
273
274    /**
275     * @param array{total: float, base: float, taxes: float} $amounts
276     * @param list<array{table: string, objid: int|string}> $publish
277     */
278    private static function updateTicketDataRegistry(
279        \ConxHelper $conx,
280        \transaction $dataTx,
281        int|string $ticketId,
282        string $series,
283        string $invoice,
284        array $amounts,
285        string $now,
286        array &$publish,
287        LoggerInterface $logger
288    ): int {
289        $rows = [];
290        $res = $conx->tenant->query('TICKETDATA', ['ticket' => $ticketId], $rows);
291        if (!$res || count($rows) === 0) {
292            $logger->error('TICKETDATA disappeared while updating registry for ticket '.$ticketId.'.');
293            return \ApplicationError::NotFound;
294        }
295
296        $dataRow = $rows[0];
297        $registry = json_decode((string) ($dataRow['registry'] ?? ''), true);
298        if (!is_array($registry)) {
299            $logger->error('Invalid TICKETDATA.registry while updating ticket '.$ticketId.'.');
300            return \ApplicationError::Invalid;
301        }
302
303        $issuedAt = self::toIso8601($now);
304        if (!isset($registry['ticketRef']) || !is_array($registry['ticketRef'])) {
305            $registry['ticketRef'] = [];
306        }
307        $registry['ticketRef']['series'] = $series;
308        $registry['ticketRef']['invceno'] = $invoice;
309        $registry['ticketRef']['status'] = 'PD';
310
311        if (!isset($registry['fiscal']) || !is_array($registry['fiscal'])) {
312            $registry['fiscal'] = [];
313        }
314        $registry['fiscal']['issuedAt'] = $issuedAt;
315        if (!isset($registry['fiscal']['status']) || $registry['fiscal']['status'] === 'DRAFT') {
316            $registry['fiscal']['status'] = 'READY';
317        }
318
319        if (!isset($registry['changes']) || !is_array($registry['changes'])) {
320            $registry['changes'] = [];
321        }
322        $registry['changes'][] = [
323            'date' => $issuedAt,
324            'reason' => 'F',
325            'series' => $series,
326            'invoice' => $invoice,
327            'total' => $amounts['total'],
328            'base' => $amounts['base'],
329            'taxes' => $amounts['taxes'],
330            'signature' => null,
331        ];
332
333        self::applyPaylinePayments($registry, $amounts['total']);
334
335        $encoded = json_encode($registry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
336        if ($encoded === false) {
337            $logger->error('Could not encode TICKETDATA.registry for ticket '.$ticketId.'.');
338            return \ApplicationError::Invalid;
339        }
340
341        $ok = $dataTx->update('TICKETDATA', [
342            'objid' => $dataRow['objid'],
343            'registry' => $encoded,
344        ]);
345        if (!$ok) {
346            $logger->error('Could not update TICKETDATA.registry for ticket '.$ticketId.'.');
347            return \ApplicationError::Database;
348        }
349
350        $publish[] = ['table' => 'TICKETDATA', 'objid' => $dataRow['objid']];
351        return \ApplicationError::Success;
352    }
353
354    /**
355     * Freezes PAYLINE into commercial.payments when the proforma snapshot is empty.
356     *
357     * @param array<string, mixed> $registry
358     */
359    private static function applyPaylinePayments(array &$registry, float $total): void
360    {
361        if (!isset($registry['commercial']) || !is_array($registry['commercial'])) {
362            $registry['commercial'] = [];
363        }
364        $existingPayments = $registry['commercial']['payments'] ?? null;
365        if (is_array($existingPayments) && count($existingPayments) > 0) {
366            return;
367        }
368
369        $registry['commercial']['payments'] = [[
370            'method' => 'PAYLINE',
371            'amount' => $total,
372            'given' => null,
373            'change' => null,
374        ]];
375    }
376
377    /**
378     * Persists PAYLINE payments on an already-closed registry (idempotent F path).
379     *
380     * @param array<string, mixed> $ticket
381     * @param list<array{table: string, objid: int|string}> $publish
382     */
383    private static function ensurePaylinePayments(
384        \ConxHelper $conx,
385        \transaction $dataTx,
386        int|string $ticketId,
387        array $ticket,
388        array &$publish,
389        LoggerInterface $logger
390    ): int {
391        $rows = [];
392        $res = $conx->tenant->query('TICKETDATA', ['ticket' => $ticketId], $rows);
393        if (!$res || count($rows) === 0) {
394            return \ApplicationError::Success;
395        }
396
397        $dataRow = $rows[0];
398        $registry = json_decode((string) ($dataRow['registry'] ?? ''), true);
399        if (!is_array($registry)) {
400            return \ApplicationError::Success;
401        }
402
403        $existingPayments = $registry['commercial']['payments'] ?? null;
404        if (is_array($existingPayments) && count($existingPayments) > 0) {
405            return \ApplicationError::Success;
406        }
407
408        $amounts = self::commercialSnapshotAmounts($conx, $ticket, $logger);
409        $total = $amounts !== null
410            ? $amounts['total']
411            : round((float) ($ticket['price'] ?? $ticket['total'] ?? 0), 2);
412
413        self::applyPaylinePayments($registry, $total);
414        $encoded = json_encode($registry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
415        if ($encoded === false) {
416            $logger->error('Could not encode TICKETDATA.registry payments heal for ticket '.$ticketId.'.');
417            return \ApplicationError::Invalid;
418        }
419
420        $ok = $dataTx->update('TICKETDATA', [
421            'objid' => $dataRow['objid'],
422            'registry' => $encoded,
423        ]);
424        if (!$ok) {
425            $logger->error('Could not heal TICKETDATA.registry payments for ticket '.$ticketId.'.');
426            return \ApplicationError::Database;
427        }
428
429        $publish[] = ['table' => 'TICKETDATA', 'objid' => $dataRow['objid']];
430        return \ApplicationError::Success;
431    }
432
433    /**
434     * @return list<array{table: string, objid: int|string}>
435     */
436    private static function emitTicketBaiRow(
437        \ConxHelper $conx,
438        string $placeId,
439        int|string $ticketId,
440        int|string $changeObjid,
441        ?string $sessionObjid,
442        LoggerInterface $logger
443    ): array {
444        self::ensureTicketBaiDomain();
445        if (!function_exists('prev_complete_tbai_specific') || !function_exists('post_complete_ticketbai_specific')) {
446            $logger->error('TicketBAI domain functions unavailable for Stripe fiscal emission.');
447            return [];
448        }
449
450        $entrynfo = self::blankFiscalEntry('TICKETBAI', $changeObjid);
451        $operation = 'do_insert';
452        $updates = [];
453        $retval = \prev_complete_tbai_specific($conx, $placeId, null, $sessionObjid, $operation, $entrynfo, $updates);
454        if ($operation === 'do_ignore') {
455            return [];
456        }
457        if (!\success($retval) || empty($entrynfo['xml'])) {
458            if (!\success($retval) && $retval !== \ApplicationError::Unapplicable) {
459                $logger->error(
460                    'Stripe TicketBAI generation failed for tckchg '.$changeObjid
461                    .' (errorcode='.$retval.').'
462                );
463            }
464            return [];
465        }
466
467        $publish = [];
468        $persist = self::persistFiscalRow(
469            $conx,
470            $placeId,
471            $ticketId,
472            'TICKETBAI',
473            $entrynfo,
474            [
475                'system' => 'TICKETBAI',
476                'ticketbai' => [
477                    'id' => $entrynfo['id'] ?? null,
478                    'qr' => $entrynfo['qr'] ?? null,
479                ],
480            ],
481            $logger,
482            $publish
483        );
484        if ($persist !== \ApplicationError::Success) {
485            return [];
486        }
487
488        $dispatchRet = \post_complete_ticketbai_specific(
489            $conx,
490            $placeId,
491            null,
492            $sessionObjid,
493            $operation,
494            $entrynfo
495        );
496        if (!\success($dispatchRet)) {
497            $logger->error(
498                'Stripe TicketBAI dispatch failed for tckchg '.$changeObjid
499                .' (errorcode='.$dispatchRet.'); payment left intact.'
500            );
501        }
502
503        return $publish;
504    }
505
506    /**
507     * @return list<array{table: string, objid: int|string}>
508     */
509    private static function emitVerifactuRow(
510        \ConxHelper $conx,
511        string $placeId,
512        int|string $ticketId,
513        int|string $changeObjid,
514        ?string $sessionObjid,
515        LoggerInterface $logger
516    ): array {
517        self::ensureVerifactuDomain();
518        if (!function_exists('prev_complete_verifactu_specific') || !function_exists('post_complete_verifactu_specific')) {
519            $logger->error('VeriFactu domain functions unavailable for Stripe fiscal emission.');
520            return [];
521        }
522
523        $entrynfo = self::blankFiscalEntry('VERIFACTU', $changeObjid);
524        $operation = 'do_insert';
525        $updates = [];
526        $retval = \prev_complete_verifactu_specific($conx, $placeId, null, $sessionObjid, $operation, $entrynfo, $updates);
527        if ($operation === 'do_ignore') {
528            return [];
529        }
530        if (!\success($retval) || empty($entrynfo['xml'])) {
531            if (!\success($retval) && $retval !== \ApplicationError::Unapplicable) {
532                $logger->error(
533                    'Stripe VeriFactu generation failed for tckchg '.$changeObjid
534                    .' (errorcode='.$retval.').'
535                );
536            }
537            return [];
538        }
539
540        $publish = [];
541        $persist = self::persistFiscalRow(
542            $conx,
543            $placeId,
544            $ticketId,
545            'VERIFACTU',
546            $entrynfo,
547            [
548                'system' => 'VERIFACTU',
549                'verifactu' => [
550                    'qr' => $entrynfo['qr'] ?? null,
551                ],
552            ],
553            $logger,
554            $publish
555        );
556        if ($persist !== \ApplicationError::Success) {
557            return [];
558        }
559
560        $dispatchRet = \post_complete_verifactu_specific(
561            $conx,
562            $placeId,
563            null,
564            $sessionObjid,
565            $operation,
566            $entrynfo
567        );
568        if (!\success($dispatchRet)) {
569            $logger->error(
570                'Stripe VeriFactu dispatch failed for tckchg '.$changeObjid
571                .' (errorcode='.$dispatchRet.'); payment left intact.'
572            );
573        }
574
575        return $publish;
576    }
577
578    /**
579     * @param array<string, mixed> $entrynfo
580     * @param array{
581     *   system: string,
582     *   ticketbai?: array{id: mixed, qr: mixed},
583     *   verifactu?: array{qr: mixed}
584     * } $qrPatch
585     * @param list<array{table: string, objid: int|string}> $publishOut
586     */
587    private static function persistFiscalRow(
588        \ConxHelper $conx,
589        string $placeId,
590        int|string $ticketId,
591        string $table,
592        array &$entrynfo,
593        array $qrPatch,
594        LoggerInterface $logger,
595        array &$publishOut = []
596    ): int {
597        if (!$conx->begin()) {
598            $logger->error('Could not begin transaction for Stripe '.$table.' insert.');
599            return \ApplicationError::Database;
600        }
601
602        $fiscalTx = $conx->trn($table);
603        $dataTx = $conx->trn('TICKETDATA');
604        if ($fiscalTx === null || $dataTx === null) {
605            $conx->flush(false);
606            $logger->error('Could not open '.$table.'/TICKETDATA transactions for Stripe fiscal insert.');
607            return \ApplicationError::Database;
608        }
609
610        $row = [
611            'tckchg' => $entrynfo['tckchg'],
612            'invceac' => null,
613            'xml' => $entrynfo['xml'],
614            'sign' => $entrynfo['sign'] ?? null,
615            'qr' => $entrynfo['qr'] ?? null,
616            'error' => null,
617            'sent' => null,
618            'valid' => null,
619        ];
620        if ($table === 'TICKETBAI') {
621            $row['id'] = $entrynfo['id'] ?? null;
622        }
623
624        $objid = null;
625        $ok = $fiscalTx->insert($table, $row, $objid);
626        if (!$ok || $objid === null || $objid === false || $objid === 0 || $objid === '0') {
627            $conx->flush(false);
628            $logger->error('Could not insert '.$table.' for tckchg '.$entrynfo['tckchg'].'.');
629            return \ApplicationError::Database;
630        }
631        $entrynfo['objid'] = $objid;
632        $entrynfo['table'] = $table;
633
634        /** @var list<array{table: string, objid: int|string}> $publish */
635        $publish = [['table' => $table, 'objid' => $objid]];
636        $qrRet = self::updateTicketDataFiscalQr($conx, $dataTx, $ticketId, $qrPatch, $publish, $logger);
637        if ($qrRet !== \ApplicationError::Success) {
638            $conx->flush(false);
639            return $qrRet;
640        }
641
642        if (!$conx->flush(true)) {
643            $logger->error('Could not commit Stripe '.$table.' insert.');
644            return \ApplicationError::Database;
645        }
646
647        $updates = [];
648        foreach ($publish as $change) {
649            $pushRet = \push_change($conx, $change, $updates);
650            if (!\success($pushRet)) {
651                $logger->error('Could not register '.$change['table'].' after Stripe '.$table.' insert.');
652            }
653        }
654        if (count($updates) > 0) {
655            \commit_changes($conx, $placeId, null, $updates);
656        }
657
658        $publishOut = $publish;
659        return \ApplicationError::Success;
660    }
661
662    /**
663     * @param array{
664     *   system: string,
665     *   ticketbai?: array{id: mixed, qr: mixed},
666     *   verifactu?: array{qr: mixed}
667     * } $qrPatch
668     * @param list<array{table: string, objid: int|string}> $publish
669     */
670    private static function updateTicketDataFiscalQr(
671        \ConxHelper $conx,
672        \transaction $dataTx,
673        int|string $ticketId,
674        array $qrPatch,
675        array &$publish,
676        LoggerInterface $logger
677    ): int {
678        $rows = [];
679        $res = $conx->tenant->query('TICKETDATA', ['ticket' => $ticketId], $rows);
680        if (!$res || count($rows) === 0) {
681            $logger->error('TICKETDATA missing while updating fiscal.qr for ticket '.$ticketId.'.');
682            return \ApplicationError::NotFound;
683        }
684
685        $dataRow = $rows[0];
686        $registry = json_decode((string) ($dataRow['registry'] ?? ''), true);
687        if (!is_array($registry)) {
688            $logger->error('Invalid TICKETDATA.registry while updating fiscal.qr for ticket '.$ticketId.'.');
689            return \ApplicationError::Invalid;
690        }
691
692        if (!isset($registry['fiscal']) || !is_array($registry['fiscal'])) {
693            $registry['fiscal'] = [];
694        }
695        $registry['fiscal']['system'] = $qrPatch['system'];
696        if (!isset($registry['fiscal']['qr']) || !is_array($registry['fiscal']['qr'])) {
697            $registry['fiscal']['qr'] = [
698                'ticketbai' => null,
699                'verifactu' => null,
700            ];
701        }
702        if (isset($qrPatch['ticketbai'])) {
703            $registry['fiscal']['qr']['ticketbai'] = [
704                'id' => $qrPatch['ticketbai']['id'] !== null ? (string) $qrPatch['ticketbai']['id'] : null,
705                'qr' => $qrPatch['ticketbai']['qr'] !== null ? (string) $qrPatch['ticketbai']['qr'] : null,
706            ];
707        }
708        if (isset($qrPatch['verifactu'])) {
709            $registry['fiscal']['qr']['verifactu'] = [
710                'qr' => $qrPatch['verifactu']['qr'] !== null ? (string) $qrPatch['verifactu']['qr'] : null,
711            ];
712        }
713
714        $encoded = json_encode($registry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
715        if ($encoded === false) {
716            $logger->error('Could not encode TICKETDATA.registry fiscal.qr for ticket '.$ticketId.'.');
717            return \ApplicationError::Invalid;
718        }
719
720        $ok = $dataTx->update('TICKETDATA', [
721            'objid' => $dataRow['objid'],
722            'registry' => $encoded,
723        ]);
724        if (!$ok) {
725            $logger->error('Could not update TICKETDATA.fiscal.qr for ticket '.$ticketId.'.');
726            return \ApplicationError::Database;
727        }
728
729        $already = false;
730        foreach ($publish as $item) {
731            if (($item['table'] ?? '') === 'TICKETDATA' && (string) ($item['objid'] ?? '') === (string) $dataRow['objid']) {
732                $already = true;
733                break;
734            }
735        }
736        if (!$already) {
737            $publish[] = ['table' => 'TICKETDATA', 'objid' => $dataRow['objid']];
738        }
739
740        return \ApplicationError::Success;
741    }
742
743    private static function isPlaceOptionEnabled(\ConxHelper $conx, string $opkey): bool
744    {
745        if ($conx->tenant === null) {
746            return false;
747        }
748        $rows = [];
749        $res = $conx->tenant->query('PLACEOPT', ['opkey' => $opkey], $rows);
750        if (!$res || count($rows) === 0) {
751            return false;
752        }
753        $value = $rows[0]['value'] ?? null;
754        return $value !== null && $value !== '' && $value !== '0' && $value !== 0 && $value !== false;
755    }
756
757    /**
758     * @return array<string, mixed>
759     */
760    private static function blankFiscalEntry(string $table, int|string $changeObjid): array
761    {
762        return [
763            'table' => $table,
764            'tckchg' => $changeObjid,
765            'invceac' => null,
766            'xml' => null,
767            'sign' => null,
768            'id' => null,
769            'qr' => null,
770            'error' => null,
771            'sent' => null,
772            'valid' => null,
773        ];
774    }
775
776    private static function ensureTicketBaiDomain(): void
777    {
778        if (function_exists('prev_complete_tbai_specific')) {
779            return;
780        }
781        $path = dirname(__DIR__).'/legacy/model/spec/ticketbai.php';
782        if (is_file($path)) {
783            require_once $path;
784        }
785    }
786
787    private static function ensureVerifactuDomain(): void
788    {
789        if (function_exists('prev_complete_verifactu_specific')) {
790            return;
791        }
792        $path = dirname(__DIR__).'/legacy/model/spec/verifactu.php';
793        if (is_file($path)) {
794            require_once $path;
795        }
796    }
797
798    private static function toIso8601(string $mysqlDatetime): string
799    {
800        $ts = strtotime($mysqlDatetime);
801        if ($ts === false) {
802            return gmdate('c');
803        }
804        return gmdate('c', $ts);
805    }
806}