Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.24% covered (warning)
72.24%
294 / 407
25.00% covered (danger)
25.00%
4 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
SwinvoiceService
72.17% covered (warning)
72.17%
293 / 406
25.00% covered (danger)
25.00%
4 / 16
468.29
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolvePlatformAgency
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 ensureInvoice
75.71% covered (warning)
75.71%
53 / 70
0.00% covered (danger)
0.00%
0 / 1
24.17
 signInvoice
78.72% covered (warning)
78.72%
37 / 47
0.00% covered (danger)
0.00%
0 / 1
18.47
 backfillFiscalQr
68.00% covered (warning)
68.00%
17 / 25
0.00% covered (danger)
0.00%
0 / 1
10.10
 persistSignFields
68.18% covered (warning)
68.18%
15 / 22
0.00% covered (danger)
0.00%
0 / 1
5.81
 persistSignedXml
61.90% covered (warning)
61.90%
13 / 21
0.00% covered (danger)
0.00%
0 / 1
7.99
 allocateNumber
86.67% covered (success)
86.67%
26 / 30
0.00% covered (danger)
0.00%
0 / 1
7.12
 dispatchAsync
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 prepareInvoice
84.31% covered (success)
84.31%
43 / 51
0.00% covered (danger)
0.00%
0 / 1
20.39
 prepareAfterPaid
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
3.19
 processInvoice
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 linkPaymentSwinvoice
70.00% covered (warning)
70.00%
28 / 40
0.00% covered (danger)
0.00%
0 / 1
19.29
 sendInvoice
68.97% covered (warning)
68.97%
20 / 29
0.00% covered (danger)
0.00%
0 / 1
11.42
 appendPlatformFingerprint
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 async
70.37% covered (warning)
70.37%
19 / 27
0.00% covered (danger)
0.00%
0 / 1
9.66
1<?php
2
3declare(strict_types=1);
4
5namespace UppServices;
6
7use Psr\Log\LoggerInterface;
8
9require_once __DIR__.'/swinvoice/Fiscal.php';
10
11/**
12 * Central SWINVOICE rows for Gekkotech license fiscal invoices.
13 *
14 * Emisor is always the software house (AppConstants Contact*). Never writes
15 * tenant place fiscal tables (ticketbai / verifactu). Idempotent on (place, payment).
16 */
17class SwinvoiceService
18{
19    /** Fixed fiscal series for Gekkotech license invoices (RN-LICFISC-08). */
20    public const SERIES = 'LIC';
21
22    /** Append-only fingerprint log basename under SwlcnsPath (RN-LICFISC-04). */
23    public const CHAIN_LOG = '_chain.log';
24
25    public function __construct(
26        private readonly LoggerFactory $loggerFactory,
27    ) {
28    }
29
30    /**
31     * Resolves agency + TicketBAI region from Gekkotech fiscal address (ContactPost).
32     *
33     * @return array{agency: string|null, region: string|null}
34     */
35    public static function resolvePlatformAgency(): array
36    {
37        $address = \AppConstants::ContactPost;
38        return [
39            'agency' => \swinvoice_agency_from_address($address),
40            'region' => \swinvoice_ticketbai_region($address),
41        ];
42    }
43
44    /**
45     * Returns an existing SWINVOICE for (place, payment) or inserts a new row with series/number.
46     *
47     * Duplicate (place, payment) returns the existing row (RN-LICFISC-05). Does not write
48     * place-tenant fiscal registry tables.
49     *
50     * @param array{
51     *   amount?: float|null,
52     *   taxrate?: float|null,
53     *   concepts?: string|null,
54     *   agency?: string|null,
55     *   region?: string|null,
56     *   issued?: string|null
57     * } $snapshot Optional field snapshot from PAYMENT / agency resolution.
58     * @return array{errorcode: int, swinvoice?: array<string, mixed>, created?: bool}
59     */
60    public function ensureInvoice(\ConxHelper $conx, int $placeId, int $paymentId, array $snapshot = []): array
61    {
62        $logger = $this->loggerFactory->for(__CLASS__);
63        $retval = \ApplicationError::Success;
64        $row = null;
65        $created = false;
66
67        if ($conx->global === null) {
68            $logger->error('Central connection not available for SwinvoiceService::ensureInvoice.');
69            $retval = \ApplicationError::Database;
70        }
71
72        if (\success($retval)) {
73            $existing = [];
74            $ok = $conx->global->query('SWINVOICE', [
75                'place' => $placeId,
76                'payment' => $paymentId,
77            ], $existing);
78            if (!$ok) {
79                $retval = \ApplicationError::Database;
80            } 
81            elseif (count($existing) > 0) {
82                $row = $existing[0];
83            }
84        }
85
86        if (\success($retval) && $row === null) {
87            if (!$conx->begin()) {
88                $retval = \ApplicationError::Database;
89            }
90        }
91
92        if (\success($retval) && $row === null) {
93            $series = self::SERIES;
94            $number = 0;
95            $numRet = $this->allocateNumber($conx, $series, $number, $logger);
96            if (!\success($numRet)) {
97                $conx->flush(false);
98                $retval = $numRet;
99            } 
100            else {
101                if (!isset($snapshot['agency'])) {
102                    $resolved = self::resolvePlatformAgency();
103                    $snapshot['agency'] = $resolved['agency'];
104                    $snapshot['region'] = $snapshot['region'] ?? $resolved['region'];
105                }
106
107                $item = [
108                    'place' => $placeId,
109                    'payment' => $paymentId,
110                    'series' => $series,
111                    'number' => $number,
112                    'issued' => $snapshot['issued'] ?? gmdate('Y-m-d H:i:s'),
113                    'amount' => $snapshot['amount'] ?? null,
114                    'taxrate' => $snapshot['taxrate'] ?? null,
115                    'concepts' => $snapshot['concepts'] ?? null,
116                    'agency' => $snapshot['agency'] ?? null,
117                    'region' => $snapshot['region'] ?? null,
118                ];
119                $objid = 0;
120                
121                $trn = $conx->trn('SWINVOICE');
122                if ($trn === null) {
123                    $conx->flush(false);
124                    $retval = \ApplicationError::Database;
125                } 
126                else {
127                    $ok = $trn->insert('SWINVOICE', $item, $objid);
128                    if (!$ok) {
129                        $conx->flush(false);
130                        // Race: UNIQUE(place,payment) — re-read existing row.
131                        $existing = [];
132                        $re = $conx->global->query('SWINVOICE', [
133                            'place' => $placeId,
134                            'payment' => $paymentId,
135                        ], $existing);
136                        if ($re && count($existing) > 0) {
137                            $row = $existing[0];
138                        } else {
139                            $logger->error('SWINVOICE insert failed for place='.$placeId.' payment='.$paymentId);
140                            $retval = \ApplicationError::Database;
141                        }
142                    } elseif (!$conx->flush(true)) {
143                        $retval = \ApplicationError::Database;
144                    } else {
145                        $item['objid'] = $objid;
146                        $row = $item;
147                        $created = true;
148                    }
149                }
150            }
151        }
152
153        $out = ['errorcode' => $retval];
154        if (\success($retval) && is_array($row)) {
155            $out['swinvoice'] = $row;
156            $out['created'] = $created;
157        }
158
159        return $out;
160    }
161
162    /**
163     * Builds and signs fiscal XML for a SWINVOICE row; persists xml path + id/qr/sign.
164     *
165     * Idempotent when `xml`, `sign` and `qr` are already set. If signed but `qr` is empty
166     * (TicketBAI/VeriFactu), backfills the QR URL without re-signing the XML.
167     * Does not POST to the institution.
168     *
169     * @param array<string, mixed> $swinvoice Central SWINVOICE row (updated in place on success).
170     * @param array<string, mixed> $payment Tenant PAYMENT snapshot (amount/taxrate/concepts).
171     * @param array<string, mixed> $placeCentral Central PLACE fiscal fields (business/taxid).
172     * @return array{errorcode: int, swinvoice?: array<string, mixed>}
173     */
174    public function signInvoice(\ConxHelper $conx, array &$swinvoice, array $payment, array $placeCentral): array
175    {
176        $logger = $this->loggerFactory->for(__CLASS__);
177
178        if ($conx->global === null) {
179            $logger->error('Central connection not available for SwinvoiceService::signInvoice.');
180            return ['errorcode' => \ApplicationError::Database];
181        }
182
183        $agency = (string) ($swinvoice['agency'] ?? '');
184        $region = $swinvoice['region'] ?? null;
185        if ($agency === '') {
186            $resolved = self::resolvePlatformAgency();
187            $agency = (string) ($resolved['agency'] ?? '');
188            $region = $region ?? $resolved['region'];
189            $swinvoice['agency'] = $agency !== '' ? $agency : null;
190            $swinvoice['region'] = $region;
191        }
192
193        $existingXml = $swinvoice['xml'] ?? null;
194        $existingSign = $swinvoice['sign'] ?? null;
195        $existingQr = $swinvoice['qr'] ?? null;
196        $hasSigned = is_string($existingXml) && $existingXml !== ''
197            && is_string($existingSign) && $existingSign !== '';
198        $hasQr = is_string($existingQr) && trim($existingQr) !== '';
199
200        if ($hasSigned && $hasQr) {
201            return ['errorcode' => \ApplicationError::Success, 'swinvoice' => $swinvoice];
202        }
203
204        if ($hasSigned && !$hasQr) {
205            return $this->backfillFiscalQr($conx, $swinvoice, $payment, $placeCentral, $agency, $region, $logger);
206        }
207
208        $invoiceMeta = [
209            'series' => (string) ($swinvoice['series'] ?? self::SERIES),
210            'number' => (int) ($swinvoice['number'] ?? 0),
211            'issued' => $swinvoice['issued'] ?? gmdate('c'),
212            'agency' => $agency,
213            'region' => $region,
214        ];
215
216        $built = \swinvoice_build_payload($payment, $placeCentral, $invoiceMeta);
217        if (!\success($built['errorcode'] ?? \ApplicationError::Invalid) || !isset($built['payload'])) {
218            $logger->error('Swinvoice payload build failed for swinvoice='.($swinvoice['objid'] ?? '?'));
219            return ['errorcode' => $built['errorcode'] ?? \ApplicationError::Invalid];
220        }
221
222        $signed = \swinvoice_sign_payload($built['payload']);
223        if (!\success($signed['errorcode'] ?? \ApplicationError::Invalid)) {
224            $logger->error('Swinvoice sign failed for swinvoice='.($swinvoice['objid'] ?? '?'));
225            return ['errorcode' => $signed['errorcode'] ?? \ApplicationError::Invalid];
226        }
227
228        $xmlBody = (string) ($signed['xml'] ?? '');
229        $filename = null;
230        $saveRet = $this->persistSignedXml($swinvoice, $xmlBody, $filename, $logger);
231        if (!\success($saveRet)) {
232            return ['errorcode' => $saveRet];
233        }
234
235        $swinvoice['xml'] = $filename;
236        $swinvoice['sign'] = $signed['sign'] ?? null;
237        $swinvoice['id'] = $signed['id'] ?? null;
238        $swinvoice['qr'] = $signed['qr'] ?? null;
239
240        return $this->persistSignFields($conx, $swinvoice, $logger);
241    }
242
243    /**
244     * Rebuilds fiscal QR when xml/sign exist but qr is empty (no XML re-sign).
245     *
246     * @param array<string, mixed> $swinvoice
247     * @param array<string, mixed> $payment
248     * @param array<string, mixed> $placeCentral
249     * @return array{errorcode: int, swinvoice?: array<string, mixed>}
250     */
251    private function backfillFiscalQr(
252        \ConxHelper $conx,
253        array &$swinvoice,
254        array $payment,
255        array $placeCentral,
256        string $agency,
257        mixed $region,
258        LoggerInterface $logger
259    ): array {
260        $invoiceMeta = [
261            'series' => (string) ($swinvoice['series'] ?? self::SERIES),
262            'number' => (int) ($swinvoice['number'] ?? 0),
263            'issued' => $swinvoice['issued'] ?? gmdate('c'),
264            'agency' => $agency,
265            'region' => $region,
266        ];
267
268        $built = \swinvoice_build_payload($payment, $placeCentral, $invoiceMeta);
269        if (!\success($built['errorcode'] ?? \ApplicationError::Invalid) || !isset($built['payload'])) {
270            $logger->error('Swinvoice QR backfill payload failed for swinvoice='.($swinvoice['objid'] ?? '?'));
271            return ['errorcode' => $built['errorcode'] ?? \ApplicationError::Invalid];
272        }
273
274        $payload = $built['payload'];
275        $sign = (string) ($swinvoice['sign'] ?? '');
276
277        if ($agency === \SWINVOICE_AGENCY_TICKETBAI) {
278            $qrInfo = \swinvoice_ticketbai_qr($payload, $sign, (string) ($region ?: 'Bizkaia'));
279            $swinvoice['id'] = $qrInfo['id'] ?? $swinvoice['id'] ?? null;
280            $swinvoice['qr'] = $qrInfo['qr'] ?? null;
281        } elseif ($agency === \SWINVOICE_AGENCY_VERIFACTU) {
282            $swinvoice['qr'] = \_verifactu_registry_qr_url($payload);
283        } else {
284            $logger->error('Swinvoice QR backfill unknown agency for swinvoice='.($swinvoice['objid'] ?? '?'));
285            return ['errorcode' => \ApplicationError::Invalid];
286        }
287
288        if (!is_string($swinvoice['qr'] ?? null) || trim((string) $swinvoice['qr']) === '') {
289            $logger->error('Swinvoice QR backfill produced empty qr for swinvoice='.($swinvoice['objid'] ?? '?'));
290            return ['errorcode' => \ApplicationError::Invalid];
291        }
292
293        return $this->persistSignFields($conx, $swinvoice, $logger);
294    }
295
296    /**
297     * Persists agency/region/xml/sign/id/qr after sign or QR backfill.
298     *
299     * @param array<string, mixed> $swinvoice
300     * @return array{errorcode: int, swinvoice?: array<string, mixed>}
301     */
302    private function persistSignFields(\ConxHelper $conx, array &$swinvoice, LoggerInterface $logger): array
303    {
304        if (!$conx->begin()) {
305            return ['errorcode' => \ApplicationError::Database];
306        }
307
308        $trn = $conx->trn('SWINVOICE');
309        if ($trn === null) {
310            $conx->flush(false);
311            return ['errorcode' => \ApplicationError::Database];
312        }
313
314        $update = [
315            'objid' => $swinvoice['objid'],
316            'agency' => $swinvoice['agency'],
317            'region' => $swinvoice['region'],
318            'xml' => $swinvoice['xml'],
319            'sign' => $swinvoice['sign'],
320            'id' => $swinvoice['id'],
321            'qr' => $swinvoice['qr'],
322        ];
323
324        if (!$trn->update('SWINVOICE', $update)) {
325            $conx->flush(false);
326            $logger->error('SWINVOICE update after sign failed objid='.$swinvoice['objid']);
327            return ['errorcode' => \ApplicationError::Database];
328        }
329
330        if (!$conx->flush(true)) {
331            return ['errorcode' => \ApplicationError::Database];
332        }
333
334        return ['errorcode' => \ApplicationError::Success, 'swinvoice' => $swinvoice];
335    }
336
337    /**
338     * Writes signed XML under SwlcnsPath; sets $filename to basename stored in DB.
339     *
340     * @return int ApplicationError code
341     */
342    private function persistSignedXml(array $swinvoice, string $xmlBody, ?string &$filename, LoggerInterface $logger): int
343    {
344        $filename = null;
345        if ($xmlBody === '') {
346            $logger->error('Swinvoice signed XML body is empty.');
347            return \ApplicationError::Invalid;
348        }
349
350        $dstpath = \AppConstants::SwlcnsPath();
351        if (!file_exists($dstpath) && !@mkdir($dstpath, 0775, true)) {
352            $logger->error("Could not create swinvoice XML folder '".$dstpath."'");
353            return \ApplicationError::Unknown;
354        }
355        
356        if (!is_dir($dstpath)) {
357            $logger->error("Swinvoice XML path is not a directory: '".$dstpath."'");
358            return \ApplicationError::Unknown;
359        }
360
361        $place = (int) ($swinvoice['place'] ?? 0);
362        $series = (string) ($swinvoice['series'] ?? self::SERIES);
363        $number = (int) ($swinvoice['number'] ?? 0);
364        $name = sprintf('softinv_%04d_%s_%d.xml', $place, $series, $number);
365        
366        $full = $dstpath.'/'.$name;
367        if (@file_put_contents($full, $xmlBody) === false) {
368            $logger->error("Could not write swinvoice XML '".$full."'");
369            return \ApplicationError::Unknown;
370        }
371
372        $filename = $name;
373        return \ApplicationError::Success;
374    }
375
376    /**
377     * Allocates the next correlative for series under central _SWINVOICE (FOR UPDATE).
378     *
379     * Caller must already have opened the transaction batch via begin().
380     * Opens trn('_SWINVOICE') before SELECT … FOR UPDATE (RN-LICFISC-09).
381     *
382     * @return int ApplicationError code
383     */
384    public function allocateNumber(\ConxHelper $conx, string $series, int &$number, ?LoggerInterface $logger = null): int
385    {
386        $logger = $logger ?? $this->loggerFactory->for(__CLASS__);
387
388        if ($conx->global === null) {
389            $logger->error('Central connection not available for SwinvoiceService::allocateNumber.');
390            return \ApplicationError::Database;
391        }
392
393        $trn = $conx->trn('_SWINVOICE');
394        if ($trn === null) {
395            return \ApplicationError::Database;
396        }
397
398        $results = [];
399        
400        $sql = "select * from _SWINVOICE where series='".addslashes($series)."' for update";
401        $ok = $conx->global->runsql([ '_SWINVOICE' ], $sql, $results);
402        if (!$ok) {
403            return \ApplicationError::Database;
404        }
405
406        if (count($results) > 0) {
407            $iteminfo = [
408                'objid' => $results[0]['objid'],
409                'series' => $series,
410                'invoice' => ((int) $results[0]['invoice']) + 1,
411            ];
412            if (!$trn->update('_SWINVOICE', $iteminfo)) {
413                return \ApplicationError::Database;
414            }
415            $number = (int) $iteminfo['invoice'];
416        } 
417        else {
418            $iteminfo = [
419                'series' => $series,
420                'invoice' => 1,
421            ];
422            
423            $objid = 0;
424            if (!$trn->insert('_SWINVOICE', $iteminfo, $objid)) {
425                return \ApplicationError::Database;
426            }
427            $number = 1;
428        }
429
430        return \ApplicationError::Success;
431    }
432
433    /**
434     * Fire-and-forget server self-call for institutional send (parity with ticketbai/async).
435     * POS clients must not invoke this path; only LicenseConfirm after prepare/DN.
436     * Async handler runs processInvoice (prepare if needed + send).
437     *
438     * Failure to dispatch is logged; never rolls back the commercial webhook.
439     */
440    public static function dispatchAsync(int $placeId, int $paymentId): int
441    {
442        require_once __DIR__.'/swinvoice/Send.php';
443        return \swinvoice_dispatch_async_request($placeId, $paymentId);
444    }
445
446    /**
447     * Ensure + sign + link for one license payment (no institutional POST).
448     *
449     * Used synchronously from LicenseConfirm so PAYMENT DN sync can carry swinvoice.qr
450     * before Hacienda send. Commercial DN / PLACE.expires are never rolled back.
451     *
452     * @return array{errorcode: int, swinvoice?: array<string, mixed>}
453     */
454    public function prepareInvoice(\ConxHelper $conx, int $placeId, int $paymentId): array
455    {
456        $logger = $this->loggerFactory->for(__CLASS__);
457
458        if ($conx->global === null) {
459            $logger->error('Central connection not available for SwinvoiceService::prepareInvoice.');
460            return ['errorcode' => \ApplicationError::Database];
461        }
462
463        if ($conx->tenant === null) {
464            $conx->tenant = (string) $placeId;
465            if ($conx->tenant === null) {
466                $logger->error('Tenant connection not available for swinvoice place='.$placeId);
467                return ['errorcode' => \ApplicationError::Database];
468            }
469        }
470
471        $payments = [];
472        if (!$conx->tenant->query('PAYMENT', ['objid' => $paymentId], $payments) || count($payments) === 0) {
473            $logger->error('PAYMENT not found for swinvoice payment='.$paymentId);
474            return ['errorcode' => \ApplicationError::NotFound];
475        }
476
477        $payment = $payments[0];
478        if (($payment['status'] ?? '') !== 'DN') {
479            $logger->error('Swinvoice process requires PAYMENT status DN.');
480            return ['errorcode' => \ApplicationError::Invalid];
481        }
482
483        $ticket = $payment['ticket'] ?? null;
484        if ($ticket !== null && $ticket !== '') {
485            $logger->error('Swinvoice process is for license payments only.');
486            return ['errorcode' => \ApplicationError::Invalid];
487        }
488
489        $places = [];
490        if (!$conx->global->query('PLACE', ['objid' => $placeId], $places) || count($places) === 0) {
491            $logger->error('PLACE not found for swinvoice place='.$placeId);
492            return ['errorcode' => \ApplicationError::NotFound];
493        }
494
495        $placeCentral = $places[0];
496
497        $resolved = self::resolvePlatformAgency();
498        $ensure = $this->ensureInvoice($conx, $placeId, $paymentId, [
499            'amount' => $payment['amount'] ?? null,
500            'taxrate' => $payment['taxrate'] ?? null,
501            'concepts' => is_string($payment['concepts'] ?? null)
502                ? $payment['concepts']
503                : (isset($payment['concepts']) ? json_encode($payment['concepts']) : null),
504            'agency' => $resolved['agency'],
505            'region' => $resolved['region'],
506            'issued' => $payment['created'] ?? gmdate('Y-m-d H:i:s'),
507        ]);
508
509        if (!\success($ensure['errorcode']) || !isset($ensure['swinvoice'])) {
510            return ['errorcode' => $ensure['errorcode']];
511        }
512        $swinvoice = $ensure['swinvoice'];
513
514        // Already accepted by institution — idempotent success (QR already published).
515        if (!empty($swinvoice['valid']) && empty($swinvoice['error'])) {
516            return ['errorcode' => \ApplicationError::Success, 'swinvoice' => $swinvoice];
517        }
518
519        $sign = $this->signInvoice($conx, $swinvoice, $payment, $placeCentral);
520        if (!\success($sign['errorcode']) || !isset($sign['swinvoice'])) {
521            return ['errorcode' => $sign['errorcode']];
522        }
523        $swinvoice = $sign['swinvoice'];
524
525        // Link tenant PAYMENT → central SWINVOICE for POS sync/print (D-LICFISC-04); best-effort.
526        $this->linkPaymentSwinvoice($conx, $placeId, $paymentId, $swinvoice, $logger);
527
528        return [
529            'errorcode' => \ApplicationError::Success,
530            'swinvoice' => $swinvoice,
531        ];
532    }
533
534    /**
535     * Best-effort prepare from LicenseConfirm (static entry; never throws to caller).
536     *
537     * Logs failures; commercial path must continue regardless (D-LICFISC-03).
538     */
539    public static function prepareAfterPaid(\ConxHelper $conx, int $placeId, int $paymentId): int
540    {
541        try {
542            $svc = new self(new LoggerFactory());
543            $out = $svc->prepareInvoice($conx, $placeId, $paymentId);
544            return $out['errorcode'] ?? \ApplicationError::Unknown;
545        } catch (\Throwable $e) {
546            $logger = (new LoggerFactory())->for(__CLASS__);
547            $logger->error(
548                'prepareAfterPaid failed place='.$placeId.' payment='.$paymentId.': '.$e->getMessage()
549            );
550            return \ApplicationError::Unknown;
551        }
552    }
553
554    /**
555     * Ensure + sign + link + institutional send for one license payment (idempotent).
556     *
557     * Async worker path: prepare if needed, then POST. Commercial PAYMENT DN /
558     * PLACE.expires are never rolled back from this path.
559     *
560     * @return array{errorcode: int, swinvoice?: array<string, mixed>}
561     */
562    public function processInvoice(\ConxHelper $conx, int $placeId, int $paymentId): array
563    {
564        $prepare = $this->prepareInvoice($conx, $placeId, $paymentId);
565        if (!\success($prepare['errorcode']) || !isset($prepare['swinvoice'])) {
566            return ['errorcode' => $prepare['errorcode']];
567        }
568        $swinvoice = $prepare['swinvoice'];
569
570        // Already accepted — prepare returned early; nothing to send.
571        if (!empty($swinvoice['valid']) && empty($swinvoice['error'])) {
572            return ['errorcode' => \ApplicationError::Success, 'swinvoice' => $swinvoice];
573        }
574
575        $send = $this->sendInvoice($conx, $swinvoice);
576        // Persist KO state even when POST fails (CA-05-02); commercial side untouched.
577        return [
578            'errorcode' => $send['errorcode'],
579            'swinvoice' => $swinvoice,
580        ];
581    }
582
583    /**
584     * Sets PAYMENT.swinvoice to the central SWINVOICE objid and publishes changelog
585     * for both PAYMENT and SWINVOICE (tenant → global relation, like QRCODE → QRSCAN).
586     *
587     * @param array<string, mixed> $swinvoice
588     */
589    public function linkPaymentSwinvoice(
590        \ConxHelper $conx,
591        int $placeId,
592        int $paymentId,
593        array $swinvoice,
594        ?LoggerInterface $logger = null
595    ): int {
596        $logger = $logger ?? $this->loggerFactory->for(__CLASS__);
597
598        $swinvoiceId = isset($swinvoice['objid']) ? (int) $swinvoice['objid'] : 0;
599        if ($swinvoiceId <= 0) {
600            $logger->error('SWINVOICE objid missing for PAYMENT link payment='.$paymentId);
601            return \ApplicationError::Invalid;
602        }
603
604        if ($conx->tenant === null) {
605            $conx->tenant = (string) $placeId;
606            if ($conx->tenant === null) {
607                $logger->error('Tenant not available for swinvoice PAYMENT link.');
608                return \ApplicationError::Database;
609            }
610        }
611
612        if (!$conx->begin()) {
613            return \ApplicationError::Database;
614        }
615        
616        $trn = $conx->trn('PAYMENT');
617        if ($trn === null) {
618            $conx->flush(false);
619            return \ApplicationError::Database;
620        }
621
622        $ok = $trn->update('PAYMENT', [
623            'objid' => $paymentId,
624            'swinvoice' => $swinvoiceId,
625        ]);
626        
627        if (!$ok) {
628            $conx->flush(false);
629            $logger->error('PAYMENT swinvoice link update failed payment='.$paymentId);
630            return \ApplicationError::Database;
631        }
632        
633        if (!$conx->flush(true)) {
634            return \ApplicationError::Database;
635        }
636
637        // Same load pattern as stripe/Checkout|Webhook: tests may stub push_change first.
638        if (!\function_exists('push_change') || !\function_exists('commit_changes')) {
639            require_once __DIR__.'/legacy/model/syncchanges.php';
640        }
641
642        $updates = [];
643        $softChange = ['table' => 'SWINVOICE', 'objid' => $swinvoiceId];
644        $softRet = \push_change($conx, $softChange, $updates);
645        
646        if (!\success($softRet)) {
647            $logger->error('Could not register SWINVOICE in changelog.');
648        }
649        $payChange = ['table' => 'PAYMENT', 'objid' => $paymentId];
650        $payRet = \push_change($conx, $payChange, $updates);
651        
652        if (!\success($payRet)) {
653            $logger->error('Could not register PAYMENT swinvoice link in changelog.');
654        } 
655        elseif (count($updates) > 0) {
656            \commit_changes($conx, (string) $placeId, null, $updates);
657        }
658
659        return \ApplicationError::Success;
660    }
661
662    /**
663     * Institutional POST + persist sent/valid/error on SWINVOICE.
664     *
665     * @param array<string, mixed> $swinvoice
666     * @return array{errorcode: int, swinvoice?: array<string, mixed>}
667     */
668    public function sendInvoice(\ConxHelper $conx, array &$swinvoice): array
669    {
670        $logger = $this->loggerFactory->for(__CLASS__);
671
672        if (!empty($swinvoice['valid']) && empty($swinvoice['error'])) {
673            return ['errorcode' => \ApplicationError::Success, 'swinvoice' => $swinvoice];
674        }
675
676        require_once __DIR__.'/swinvoice/Send.php';
677        $result = \swinvoice_send_to_agency($swinvoice);
678        $errorcode = $result['errorcode'] ?? \ApplicationError::Unknown;
679
680        if ($conx->global === null) {
681            return ['errorcode' => \ApplicationError::Database, 'swinvoice' => $swinvoice];
682        }
683
684        if (!$conx->begin()) {
685            return ['errorcode' => \ApplicationError::Database, 'swinvoice' => $swinvoice];
686        }
687        
688        $trn = $conx->trn('SWINVOICE');
689        if ($trn === null) {
690            $conx->flush(false);
691            return ['errorcode' => \ApplicationError::Database, 'swinvoice' => $swinvoice];
692        }
693        
694        $update = [
695            'objid' => $swinvoice['objid'],
696            'sent' => $swinvoice['sent'] ?? null,
697            'valid' => $swinvoice['valid'] ?? null,
698            'error' => $swinvoice['error'] ?? null,
699        ];
700        
701        if (!$trn->update('SWINVOICE', $update)) {
702            $conx->flush(false);
703            $logger->error('SWINVOICE update after send failed objid='.$swinvoice['objid']);
704            return ['errorcode' => \ApplicationError::Database, 'swinvoice' => $swinvoice];
705        }
706        
707        if (!$conx->flush(true)) {
708            return ['errorcode' => \ApplicationError::Database, 'swinvoice' => $swinvoice];
709        }
710
711        if (\success($errorcode)) {
712            $this->appendPlatformFingerprint($swinvoice, $logger);
713        }
714
715        return ['errorcode' => $errorcode, 'swinvoice' => $swinvoice];
716    }
717
718    /**
719     * Appends a fingerprint line under SwlcnsPath after successful institutional send (post-COMMIT).
720     *
721     * @param array<string, mixed> $swinvoice
722     */
723    private function appendPlatformFingerprint(array $swinvoice, LoggerInterface $logger): void
724    {
725        $dir = \AppConstants::SwlcnsPath();
726        if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
727            $logger->error("Could not create Lcc XML dir '".$dir."'");
728            return;
729        }
730       
731        $line = implode('|', [
732            gmdate('c'),
733            (string) ($swinvoice['objid'] ?? ''),
734            (string) ($swinvoice['series'] ?? ''),
735            (string) ($swinvoice['number'] ?? ''),
736            (string) ($swinvoice['id'] ?? ''),
737            (string) ($swinvoice['sign'] ?? ''),
738            (string) ($swinvoice['agency'] ?? ''),
739        ])."\n";
740        
741        $file = $dir.'/'.self::CHAIN_LOG;
742        if (@file_put_contents($file, $line, FILE_APPEND | LOCK_EX) === false) {
743            $logger->error("Could not append swinvoice fingerprint to '".$file."'");
744        }
745    }
746
747    /**
748     * POST /swinvoice/async — body handler for the LocalUrl self-call from dispatchAsync.
749     * Not a POS product API (same role as ticketbai/async).
750     *
751     * @return array{output: string, contentType: string}
752     */
753    public function async(string $rawBody): array
754    {
755        $logger = $this->loggerFactory->for(__CLASS__);
756        $retval = \ApplicationError::Success;
757        $placeId = null;
758        $paymentId = null;
759        $conx = new \ConxHelper(__FILE__);
760
761        try {
762            if ($rawBody === '') {
763                $retval = \ApplicationError::Parameters;
764                $logger->error('Empty request body for swinvoice/async.');
765            }
766
767            if (\success($retval)) {
768                $data = json_decode($rawBody, true);
769                if (!is_array($data) || !isset($data['place'], $data['payment'])) {
770                    $retval = \ApplicationError::Parameters;
771                    $logger->error('Invalid swinvoice/async payload.');
772                } else {
773                    $placeId = (int) $data['place'];
774                    $paymentId = (int) $data['payment'];
775                }
776            }
777
778            if (\success($retval) && $placeId !== null && $paymentId !== null) {
779                ini_set('max_execution_time', '120');
780                ini_set('ignore_user_abort', '1');
781                $logger->info("Swinvoice async started place={$placeId} payment={$paymentId}");
782                $out = $this->processInvoice($conx, $placeId, $paymentId);
783                $retval = $out['errorcode'];
784                $logger->info("Swinvoice async finished place={$placeId} payment={$paymentId} errorcode={$retval}");
785            }
786        } 
787        finally {
788            $conx->release();
789        }
790
791        return [
792            'output' => json_encode(['errorcode' => $retval]),
793            'contentType' => 'application/json; charset=utf-8',
794        ];
795    }
796
797}