Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.29% covered (success)
84.29%
118 / 140
0.00% covered (danger)
0.00%
0 / 1
CRAP
n/a
0 / 0
verifactu_audit_retry_run
83.94% covered (success)
83.94%
115 / 137
0.00% covered (danger)
0.00%
0 / 1
54.76
1<?php
2
3declare(strict_types=1);
4
5require_once __DIR__.'/../legacy/model/spec/verifactu.php';
6require_once __DIR__.'/../legacy/model/spec/audit.php';
7
8/**
9 * Retry pending/failed VeriFactu rows for one closed audit period.
10 *
11 * Period bounds come from {@see _audit_period_bounds} (same till-scoped window as
12 * AUDIT.report recalculation). Only VERIFACTU rows with `valid is null` are resent
13 * via {@see update_verifactu_request}, preserving `TICKETCHANGE.objid desc` order so
14 * AEAT rate-limit waits (`waitseconds`) apply between chained sends. After at least
15 * one send attempt, AUDIT.report is refreshed and published so clients sync fiscal
16 * counters.
17 *
18 * Rows that already have `error` set are included (unlike v3 `audit-vft.php`, which
19 * required `error is null`): `update_verifactu_request` clears prior error/valid
20 * before dispatch, and operator retry from a KO audit thumb must recover failed
21 * registries (same selection rule as TicketBAI audit retry).
22 *
23 * @param array<string, mixed> $query Query params (device, session, place, audit).
24 * @param \ConxHelper $conx Connection helper.
25 * @param \Psr\Log\LoggerInterface $logger Logger.
26 * @param \UppServices\SessionService $sessionService Session service.
27 * @return array{errorcode: int, ticketsok: list<array{0: string, 1: mixed}>, ticketsko: list<array{0: string, 1: mixed}>}
28 */
29function verifactu_audit_retry_run(
30    array $query,
31    \ConxHelper $conx,
32    \Psr\Log\LoggerInterface $logger,
33    \UppServices\SessionService $sessionService,
34): array {
35    $retval = \ApplicationError::Success;
36    $ticketsok = [];
37    $ticketsko = [];
38    $userid = null;
39    $sessionObjid = null;
40    /** @var array<string, mixed> $auditRow */
41    $auditRow = [];
42    $pending = [];
43    $attempted = 0;
44
45    $device = isset($query['device']) ? (string) $query['device'] : '';
46    $sessionToken = isset($query['session']) ? (string) $query['session'] : '';
47    $placeId = isset($query['place']) ? (string) $query['place'] : '';
48    $auditId = isset($query['audit']) ? (string) $query['audit'] : '';
49
50    // validate mandatory query parameters
51    if (success($retval)) {
52        if ($device === '') {
53            $logger->error('No device provided on URL. Operation cancelled.');
54            $retval = \ApplicationError::Parameters;
55        }
56    }
57
58    if (success($retval)) {
59        if ($sessionToken === '') {
60            $logger->error('No session provided on URL. Operation cancelled.');
61            $retval = \ApplicationError::Parameters;
62        }
63    }
64
65    if (success($retval)) {
66        if ($placeId === '' || $placeId === '0') {
67            $logger->error("Mandatory parameter 'place' not provided. Operation cancelled.");
68            $retval = \ApplicationError::Parameters;
69        }
70    }
71
72    if (success($retval)) {
73        if ($auditId === '' || $auditId === '0') {
74            $logger->error("Mandatory parameter 'audit' not provided. Operation cancelled.");
75            $retval = \ApplicationError::Parameters;
76        }
77    }
78
79    // validate session
80    if (success($retval)) {
81        $retval = $sessionService->checkSession($conx, $sessionToken, $device, $userid, $sessionObjid);
82        if (!success($retval)) {
83            $logger->error('Session check failed for verifactu/audit.');
84        }
85    }
86
87    // open tenant for the place
88    if (success($retval)) {
89        $conx->tenant = $placeId;
90        $conn = $conx->getTenant();
91        if ($conn === null) {
92            $logger->error('Tenant connection not available for audit VeriFactu retry.');
93            $retval = \ApplicationError::Database;
94        }
95    }
96
97    // load the closed audit row
98    if (success($retval)) {
99        $results = [];
100        $res = $conx->tenant->query('AUDIT', [ 'objid' => $auditId ], $results);
101        if ($res) {
102            if (count($results) > 0) {
103                $auditRow = $results[0];
104            }
105            else {
106                $logger->error("No audit entry found with objid '".$auditId."'");
107                $retval = \ApplicationError::NotFound;
108            }
109        }
110        else {
111            $retval = \ApplicationError::Database;
112        }
113    }
114
115    // resolve till-scoped period bounds (same source as AUDIT.report)
116    $iniDate = null;
117    $endDate = null;
118    if (success($retval)) {
119        $bounds = \_audit_period_bounds($conx->tenant, $auditRow);
120        if ($bounds === null) {
121            $logger->error("Audit [".$auditId."] has no till; cannot resolve VeriFactu retry period.");
122            $retval = \ApplicationError::Invalid;
123        }
124        else {
125            list($iniDate, $endDate) = $bounds;
126            \addlog(__FILE__, \LogLevel::Information, 'VeriFactu audit retry period ['.$iniDate.' - '.$endDate.']');
127        }
128    }
129
130    // select pending/failed VERIFACTU rows in the audit period (chain order preserved)
131    if (success($retval)) {
132        $tillObjid = \_audit_till_objid($auditRow);
133        $sql = "select TICKETCHANGE.series, TICKETCHANGE.invoice, VERIFACTU.* ";
134        $sql .= "from TICKETCHANGE ";
135        $sql .= "inner join TICKET on TICKETCHANGE.ticket = TICKET.objid ";
136        $sql .= "inner join VERIFACTU on VERIFACTU.tckchg = TICKETCHANGE.objid ";
137        $sql .= "and VERIFACTU.valid is null ";
138        $sql .= "where TICKET.till = ".$tillObjid." ";
139        $sql .= "and (";
140        $sql .= "(TICKET.status not in ('DE', 'CC') and TICKET.paidon is not NULL and ";
141        $sql .= "TICKET.paidon > '".$iniDate."' and TICKET.paidon <= '".$endDate."') or ";
142        $sql .= "(TICKET.status in ('DE', 'CC') and ";
143        $sql .= "TICKET.updated > '".$iniDate."' and TICKET.updated <= '".$endDate."')";
144        $sql .= ") ";
145        $sql .= "order by TICKETCHANGE.objid desc";
146
147        \addlog(__FILE__, \LogLevel::Debug, 'VeriFactu audit retry query: '.PHP_EOL.$sql.PHP_EOL);
148
149        $pending = [];
150        $res = $conx->tenant->runsql([ 'TICKET', 'TICKETCHANGE', 'VERIFACTU' ], $sql, $pending);
151        if (!$res) {
152            $retval = \ApplicationError::Database;
153        }
154    }
155
156    // resend each pending VeriFactu entry, honouring AEAT waitseconds between sends
157    if (success($retval)) {
158        $indexed = [];
159        $index = 1;
160        $total = count($pending);
161
162        foreach ($pending as &$verifactu) {
163            \addlog(
164                __FILE__,
165                \LogLevel::Information,
166                "Retrying VeriFactu XML file: '".($verifactu['xml'] ?? '')."' (".$index.' of '.$total.')'
167            );
168
169            $ticketinvoice = (string) ($verifactu['series'] ?? '').(string) ($verifactu['invoice'] ?? '');
170            if (!array_key_exists($ticketinvoice, $indexed)) {
171                $indexed[$ticketinvoice] = null;
172            }
173
174            $attempted++;
175            $errormsg = null;
176            $waitseconds = 0;
177
178            if (!empty($verifactu['xml'])) {
179                $result = \update_verifactu_request($conx, $sessionObjid, $verifactu, $errormsg, $waitseconds);
180                if (!success($result)) {
181                    $indexed[$ticketinvoice] = "Could not send verifactu XML file [errorcode: '".$result."']";
182                    \addlog(__FILE__, \LogLevel::Warning, $indexed[$ticketinvoice]);
183                }
184                else if ($errormsg !== null) {
185                    // AEAT / validation rejection must surface in ticketsko (not silent)
186                    $indexed[$ticketinvoice] = $errormsg;
187                    \addlog(__FILE__, \LogLevel::Warning, $indexed[$ticketinvoice]);
188                }
189            }
190            else {
191                $indexed[$ticketinvoice] = 'No XML file found for verifactu instance ['.($verifactu['objid'] ?? '').']';
192                \addlog(__FILE__, \LogLevel::Warning, $indexed[$ticketinvoice]);
193            }
194
195            // rate-limit before the next chained send (last item has nothing after it)
196            if ($index < $total && is_int($waitseconds) && $waitseconds > 0) {
197                \addlog(__FILE__, \LogLevel::Debug, 'Waiting '.$waitseconds.' seconds before next VeriFactu audit retry send');
198                sleep($waitseconds);
199            }
200
201            $index++;
202        }
203        unset($verifactu);
204
205        foreach ($indexed as $ivce => $error) {
206            if ($error === null) {
207                $ticketsok[] = [ $ivce, $error ];
208            }
209            else {
210                $ticketsko[] = [ $ivce, $error ];
211            }
212        }
213
214        \addlog(__FILE__, \LogLevel::Information, 'OK tickets: '.PHP_EOL.print_r($ticketsok, true).PHP_EOL);
215        \addlog(__FILE__, \LogLevel::Information, 'KO tickets: '.PHP_EOL.print_r($ticketsko, true).PHP_EOL);
216    }
217
218    // refresh AUDIT.report after send attempts so fiscal counters sync to clients
219    if (success($retval) && ($attempted > 0)) {
220        $delayed = new \SyncDelayed();
221        $updates = [];
222        $ok = $conx->transactional(function (\ConxHelper $conx) use ($sessionObjid, &$auditRow, $delayed, $placeId, &$retval, &$updates) {
223            $retval = \update_audit_info($conx, $sessionObjid, $auditRow, $delayed);
224            if (success($retval)) {
225                foreach ($delayed->touched() as $change) {
226                    $retval = \push_change($conx, $change, $updates);
227                    if (!success($retval)) {
228                        break;
229                    }
230                }
231            }
232            
233            if (success($retval)) {
234                $retval = \stage_changes($conx, $placeId, $updates);
235            }
236            
237            return success($retval);
238        });
239        
240        if (success($retval) && !$ok) {
241            $retval = \ApplicationError::Database;
242            $logger->error('AUDIT refresh commit failed at COMMIT time after VeriFactu retry.');
243        }
244        
245        if (success($retval)) {
246            \notify_changes($conx, $sessionObjid, $updates);
247        }
248    }
249
250    return [
251        'errorcode' => $retval,
252        'ticketsok' => $ticketsok,
253        'ticketsko' => $ticketsko,
254    ];
255}
256
257/**
258 * Action callable for VerifactuService::audit (same signature as TicketBAI audit retry).
259 *
260 * @return callable(array, \ConxHelper, \Psr\Log\LoggerInterface, \UppServices\SessionService): array
261 */
262return \Closure::fromCallable('verifactu_audit_retry_run');