Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.54% covered (warning)
76.54%
199 / 260
27.27% covered (danger)
27.27%
3 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
TicketConfirm
76.54% covered (warning)
76.54%
199 / 260
27.27% covered (danger)
27.27%
3 / 11
165.73
0.00% covered (danger)
0.00%
0 / 1
 applyPaidTickets
78.57% covered (warning)
78.57%
77 / 98
0.00% covered (danger)
0.00%
0 / 1
25.34
 ensureFiscalCloseForPayment
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 runFiscalClose
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
3.19
 allocateDefinitiveNumbering
87.88% covered (success)
87.88%
29 / 33
0.00% covered (danger)
0.00%
0 / 1
15.40
 markPaidFlags
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 markToPayFlags
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 clearToPayFlags
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 applyExpiredTicketPayment
70.59% covered (warning)
70.59%
48 / 68
0.00% covered (danger)
0.00%
0 / 1
24.35
 expiredTicketRestoreStatus
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
9.86
 publishTicketPayment
83.33% covered (success)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
5.12
 registerActivities
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
10.50
1<?php
2
3declare(strict_types=1);
4
5namespace UppServices\stripe;
6
7use Psr\Log\LoggerInterface;
8use UppServices\fiscal\TicketFiscal;
9
10/**
11 * Closes guest ticket payments after Stripe reports Checkout paid.
12 *
13 * Discriminator: metadata.action=TICKET and PAYMENT.type=TICKET (license uses LICENSE).
14 * Converts provisional X numbering to definitive S/T (or C/C with invoice),
15 * marks PD + PAYLINE, and publishes changelog. Then TicketFiscal creates the
16 * TICKETCHANGE F, registry update, and optional TBAI/VFTU emission + dispatch.
17 */
18class TicketConfirm
19{
20    /**
21     * Marks PAYMENT DN and linked TICKET rows PD with definitive invoice numbers.
22     *
23     * Idempotent when PAYMENT is already DN. Serial is taken from TICKET.series
24     * (characters after the type letter), never from Stripe metadata device id.
25     *
26     * @return array{errorcode: int, status?: string}
27     */
28    public static function applyPaidTickets(
29        \ConxHelper $conx,
30        string $placeId,
31        string $paymentId,
32        ?int $amountCents,
33        ?string $currency,
34        ?string $txnid,
35        ?string $sessionObjid,
36        LoggerInterface $logger
37    ): array {
38        $payments = [];
39        $res = $conx->tenant->query('PAYMENT', ['objid' => $paymentId], $payments);
40        if (!$res) {
41            return ['errorcode' => \ApplicationError::Database];
42        }
43        if (count($payments) === 0) {
44            $logger->error("Payment '".$paymentId."' not found.");
45            return ['errorcode' => \ApplicationError::Invalid];
46        }
47
48        $payment = $payments[0];
49        if ((string) ($payment['type'] ?? '') !== 'TICKET') {
50            $logger->error('applyPaidTickets is for ticket payments only.');
51            return ['errorcode' => \ApplicationError::Invalid];
52        }
53        if (($payment['status'] ?? '') === 'DN') {
54            // Payment already confirmed: still run TicketFiscal so empty proforma
55            // commercial.payments is sealed (and change F / fiscal retried) when a
56            // prior close failed or reconcile races the webhook.
57            self::ensureFiscalCloseForPayment(
58                $conx,
59                $placeId,
60                $paymentId,
61                $sessionObjid,
62                $logger
63            );
64            return ['errorcode' => \ApplicationError::Success, 'status' => 'confirmed'];
65        }
66        if (($payment['status'] ?? '') !== 'PA') {
67            $logger->error("Payment '".$paymentId."' is not pending (status=".($payment['status'] ?? '').').');
68            return ['errorcode' => \ApplicationError::Invalid];
69        }
70
71        $expectedCents = (int) round(((float) ($payment['amount'] ?? 0)) * 100);
72        if ($amountCents !== null && $amountCents !== $expectedCents) {
73            $logger->error("Unexpected amount (expected {$expectedCents}, received {$amountCents}).");
74            return ['errorcode' => \ApplicationError::Invalid];
75        }
76        if ($currency !== null && strtoupper((string) ($payment['currency'] ?? '')) !== strtoupper($currency)) {
77            $logger->error('Unexpected currency received.');
78            return ['errorcode' => \ApplicationError::Invalid];
79        }
80
81        $tickets = [];
82        $res = $conx->tenant->query('TICKET', ['transaction' => $paymentId], $tickets);
83        if (!$res) {
84            return ['errorcode' => \ApplicationError::Database];
85        }
86        if (count($tickets) === 0) {
87            $logger->error("No tickets linked to payment '".$paymentId."'.");
88            return ['errorcode' => \ApplicationError::NotFound];
89        }
90
91        if (!$conx->begin()) {
92            return ['errorcode' => \ApplicationError::Database];
93        }
94
95        $payTx = $conx->trn('PAYMENT');
96        $ticketTx = $conx->trn('TICKET');
97        if ($payTx === null || $ticketTx === null) {
98            $conx->flush(false);
99            return ['errorcode' => \ApplicationError::Database];
100        }
101
102        $paidon = $conx->tenant->now();
103        /** @var list<array<string, mixed>> $paidTickets */
104        $paidTickets = [];
105
106        if (!function_exists('get_ticket_invoice')) {
107            require_once __DIR__.'/../legacy/ticket/invoiceno.php';
108        }
109
110        foreach ($tickets as $ticket) {
111            $numbering = self::allocateDefinitiveNumbering($conx, $placeId, $ticket, $logger);
112            if (($numbering['errorcode'] ?? \ApplicationError::Invalid) !== \ApplicationError::Success) {
113                $conx->flush(false);
114                return ['errorcode' => $numbering['errorcode']];
115            }
116
117            $flags = self::markPaidFlags((string) ($ticket['flags'] ?? ''));
118            $ok = $ticketTx->update('TICKET', [
119                'objid' => $ticket['objid'],
120                'status' => 'PD',
121                'payment' => 'PAYLINE',
122                'paidon' => $paidon,
123                'paidby' => $sessionObjid,
124                'invceno' => $numbering['invceno'],
125                'series' => $numbering['series'],
126                'flags' => $flags,
127            ]);
128            if (!$ok) {
129                $conx->flush(false);
130                return ['errorcode' => \ApplicationError::Database];
131            }
132
133            $paid = $ticket;
134            $paid['status'] = 'PD';
135            $paid['payment'] = 'PAYLINE';
136            $paid['paidon'] = $paidon;
137            $paid['paidby'] = $sessionObjid;
138            $paid['invceno'] = $numbering['invceno'];
139            $paid['series'] = $numbering['series'];
140            $paid['flags'] = $flags;
141            // TICKET has no place column in tenant; activity/regstr need the place id.
142            $paid['place'] = $placeId;
143            $paidTickets[] = $paid;
144        }
145
146        $ok = $payTx->update('PAYMENT', [
147            'objid' => $paymentId,
148            'status' => 'DN',
149            'txnid' => $txnid,
150        ]);
151        if (!$ok) {
152            $conx->flush(false);
153            return ['errorcode' => \ApplicationError::Database];
154        }
155
156        if (!$conx->flush(true)) {
157            return ['errorcode' => \ApplicationError::Database];
158        }
159
160        self::publishTicketPayment($conx, $placeId, $paymentId, $paidTickets, $logger);
161        self::registerActivities($conx, $sessionObjid, $paidTickets, $logger);
162
163        // Fiscal close seals commercial.payments (PAYLINE); retriable, never undoes cobro.
164        self::runFiscalClose($conx, $placeId, $paymentId, $paidTickets, $sessionObjid, $logger);
165
166        $logger->info(sprintf(
167            'Ticket payment confirmed payment=%s place=%s tickets=%s amount=%s',
168            $paymentId,
169            $placeId,
170            implode(',', array_map(static fn (array $t): string => (string) ($t['objid'] ?? ''), $paidTickets)),
171            (string) ($payment['amount'] ?? '')
172        ));
173
174        return ['errorcode' => \ApplicationError::Success, 'status' => 'confirmed'];
175    }
176
177    /**
178     * Loads tickets for a DN payment and runs TicketFiscal (idempotent seal / heal).
179     */
180    private static function ensureFiscalCloseForPayment(
181        \ConxHelper $conx,
182        string $placeId,
183        string $paymentId,
184        ?string $sessionObjid,
185        LoggerInterface $logger
186    ): void {
187        $tickets = [];
188        $res = $conx->tenant->query('TICKET', ['transaction' => $paymentId], $tickets);
189        if (!$res || count($tickets) === 0) {
190            return;
191        }
192
193        $paidTickets = [];
194        foreach ($tickets as $ticket) {
195            $ticket['place'] = $placeId;
196            $paidTickets[] = $ticket;
197        }
198
199        self::runFiscalClose($conx, $placeId, $paymentId, $paidTickets, $sessionObjid, $logger);
200    }
201
202    /**
203     * @param list<array<string, mixed>> $paidTickets
204     */
205    private static function runFiscalClose(
206        \ConxHelper $conx,
207        string $placeId,
208        string $paymentId,
209        array $paidTickets,
210        ?string $sessionObjid,
211        LoggerInterface $logger
212    ): void {
213        $fiscal = TicketFiscal::closePaidTickets($conx, $placeId, $paidTickets, $sessionObjid, $logger);
214        if (($fiscal['errorcode'] ?? \ApplicationError::Invalid) !== \ApplicationError::Success) {
215            $logger->error(
216                'Stripe ticket fiscal close failed after payment confirm (payment='
217                .$paymentId.', errorcode='.$fiscal['errorcode'].').'
218            );
219        }
220    }
221
222    /**
223     * Resolves S/T (or C/C) numbering from provisional X or keeps an already definitive pair.
224     *
225     * @param array<string, mixed> $ticket
226     * @return array{errorcode: int, series?: string, invceno?: string}
227     */
228    public static function allocateDefinitiveNumbering(
229        \ConxHelper $conx,
230        string $placeId,
231        array $ticket,
232        LoggerInterface $logger
233    ): array {
234        $series = (string) ($ticket['series'] ?? '');
235        $invceno = (string) ($ticket['invceno'] ?? '');
236        $prefix = $series !== '' ? substr($series, 0, 1) : '';
237        $serial = strlen($series) > 1 ? strtoupper(substr($series, 1)) : '';
238
239        $invoicePrefix = $invceno !== '' ? substr($invceno, 0, 1) : '';
240        $alreadyDefinitive = ($prefix === 'S' || $prefix === 'C')
241            && ($invoicePrefix === 'T' || $invoicePrefix === 'C');
242
243        if ($alreadyDefinitive) {
244            return [
245                'errorcode' => \ApplicationError::Success,
246                'series' => $series,
247                'invceno' => $invceno,
248            ];
249        }
250
251        if ($prefix !== 'X' || preg_match('/^[0-9A-F]{8}$/', $serial) !== 1) {
252            $logger->error(
253                'Ticket '.$ticket['objid'].' has unexpected series/invceno for Stripe renumber (series='
254                .$series.', invceno='.$invceno.').'
255            );
256            return ['errorcode' => \ApplicationError::Invalid];
257        }
258
259        $type = !empty($ticket['invoice']) ? 'C' : 'T';
260        $seriesPrefix = ($type === 'C') ? 'C' : 'S';
261        $invoice = null;
262        $numRet = \get_ticket_invoice($conx, $placeId, $type, $serial, $invoice);
263        if (!\success($numRet) || $invoice === null) {
264            $logger->error('Could not allocate definitive invoice for ticket '.$ticket['objid'].'.');
265            return [
266                'errorcode' => $numRet !== \ApplicationError::Success ? $numRet : \ApplicationError::Database,
267            ];
268        }
269
270        return [
271            'errorcode' => \ApplicationError::Success,
272            'series' => $seriesPrefix.$serial,
273            'invceno' => $invoice,
274        ];
275    }
276
277    /**
278     * PaidFlag on, ToPayFlag off (flag indexes 4 and 3).
279     */
280    public static function markPaidFlags(string $flags): string
281    {
282        $padded = str_pad($flags, 7, '0');
283        $padded[3] = '0';
284        $padded[4] = '1';
285        return $padded;
286    }
287
288    /**
289     * ToPayFlag on, PaidFlag off (flag indexes 3 and 4) â€” pending PAYLINE request.
290     */
291    public static function markToPayFlags(string $flags): string
292    {
293        $padded = str_pad($flags, 7, '0');
294        $padded[3] = '1';
295        $padded[4] = '0';
296        return $padded;
297    }
298
299    /**
300     * ToPayFlag off (index 3) â€” release a pending PAYLINE request without marking paid.
301     */
302    public static function clearToPayFlags(string $flags): string
303    {
304        $padded = str_pad($flags, 7, '0');
305        $padded[3] = '0';
306        return $padded;
307    }
308
309    /**
310     * Marks a pending ticket PAYMENT as ER when Checkout expired and clears
311     * linked TICKET PAYLINE requests (status AC/PR, payment/transaction null,
312     * ToPayFlag off) so the guest can retry without a client-side CancelPayment.
313     * Idempotent on ER/DN.
314     *
315     * @return array{errorcode: int, status?: string}
316     */
317    public static function applyExpiredTicketPayment(
318        \ConxHelper $conx,
319        string $placeId,
320        string $paymentId,
321        LoggerInterface $logger
322    ): array {
323        $payments = [];
324        $res = $conx->tenant->query('PAYMENT', ['objid' => $paymentId], $payments);
325        if (!$res) {
326            return ['errorcode' => \ApplicationError::Database];
327        }
328        if (count($payments) === 0) {
329            $logger->error("Payment '".$paymentId."' not found for expired ticket checkout.");
330            return ['errorcode' => \ApplicationError::NotFound];
331        }
332
333        $payment = $payments[0];
334        if ((string) ($payment['type'] ?? '') !== 'TICKET') {
335            $logger->error('applyExpiredTicketPayment is for ticket payments only.');
336            return ['errorcode' => \ApplicationError::Invalid];
337        }
338
339        if (($payment['status'] ?? '') === 'ER') {
340            return ['errorcode' => \ApplicationError::Success, 'status' => 'failed'];
341        }
342        if (($payment['status'] ?? '') === 'DN') {
343            return ['errorcode' => \ApplicationError::Success, 'status' => 'confirmed'];
344        }
345        if (($payment['status'] ?? '') !== 'PA') {
346            $logger->error(
347                "Payment '".$paymentId."' cannot expire (status=".($payment['status'] ?? '').').'
348            );
349            return ['errorcode' => \ApplicationError::Invalid];
350        }
351
352        $tickets = [];
353        $res = $conx->tenant->query('TICKET', ['transaction' => $paymentId], $tickets);
354        if (!$res) {
355            return ['errorcode' => \ApplicationError::Database];
356        }
357
358        $restoreStatus = self::expiredTicketRestoreStatus($conx, $placeId, $tickets);
359
360        if (!$conx->begin()) {
361            return ['errorcode' => \ApplicationError::Database];
362        }
363
364        $payTx = $conx->trn('PAYMENT');
365        $ticketTx = $conx->trn('TICKET');
366        if ($payTx === null || $ticketTx === null) {
367            $conx->flush(false);
368            return ['errorcode' => \ApplicationError::Database];
369        }
370
371        /** @var list<array<string, mixed>> $releasedTickets */
372        $releasedTickets = [];
373        foreach ($tickets as $ticket) {
374            if (($ticket['status'] ?? '') !== 'PP' && ($ticket['payment'] ?? '') !== 'PAYLINE') {
375                continue;
376            }
377            $flags = self::clearToPayFlags((string) ($ticket['flags'] ?? ''));
378            $ok = $ticketTx->update('TICKET', [
379                'objid' => $ticket['objid'],
380                'status' => $restoreStatus,
381                'payment' => null,
382                'paidby' => null,
383                'paidon' => null,
384                'paidin' => null,
385                'till' => null,
386                'transaction' => null,
387                'flags' => $flags,
388            ]);
389            if (!$ok) {
390                $conx->flush(false);
391                return ['errorcode' => \ApplicationError::Database];
392            }
393            $released = $ticket;
394            $released['status'] = $restoreStatus;
395            $released['payment'] = null;
396            $released['transaction'] = null;
397            $released['flags'] = $flags;
398            $releasedTickets[] = $released;
399        }
400
401        $ok = $payTx->update('PAYMENT', [
402            'objid' => $paymentId,
403            'status' => 'ER',
404        ]);
405        if (!$ok) {
406            $conx->flush(false);
407            return ['errorcode' => \ApplicationError::Database];
408        }
409
410        if (!$conx->flush(true)) {
411            return ['errorcode' => \ApplicationError::Database];
412        }
413
414        self::publishTicketPayment($conx, $placeId, $paymentId, $releasedTickets, $logger);
415
416        return ['errorcode' => \ApplicationError::Success, 'status' => 'failed'];
417    }
418
419    /**
420     * Status after clearing a failed PAYLINE request: PR when the place requires prepay, else AC.
421     *
422     * @param list<array<string, mixed>> $tickets
423     */
424    private static function expiredTicketRestoreStatus(
425        \ConxHelper $conx,
426        string $placeId,
427        array $tickets
428    ): string {
429        unset($placeId);
430        foreach (['optionPrePayment', 'optionQrPrePayment'] as $opkey) {
431            $opts = [];
432            $res = $conx->tenant->query('PLACEOPT', ['opkey' => $opkey], $opts);
433            if (!$res || count($opts) === 0) {
434                continue;
435            }
436            $value = (string) ($opts[0]['opvalue'] ?? $opts[0]['value'] ?? '');
437            if ($value === '1' || strtolower($value) === 'true') {
438                return 'PR';
439            }
440        }
441
442        foreach ($tickets as $ticket) {
443            if (($ticket['status'] ?? '') === 'PR') {
444                return 'PR';
445            }
446        }
447
448        return 'AC';
449    }
450
451    /**
452     * @param list<array<string, mixed>> $tickets
453     */
454    public static function publishTicketPayment(
455        \ConxHelper $conx,
456        string $placeId,
457        int|string $paymentId,
458        array $tickets,
459        LoggerInterface $logger
460    ): void {
461        $updates = [];
462        $payChange = ['table' => 'PAYMENT', 'objid' => $paymentId];
463        $payRet = \push_change($conx, $payChange, $updates);
464        if (!\success($payRet)) {
465            $logger->error('Could not register PAYMENT change after ticket webhook.');
466        }
467        foreach ($tickets as $ticket) {
468            $tickChange = ['table' => 'TICKET', 'objid' => $ticket['objid']];
469            $tickRet = \push_change($conx, $tickChange, $updates);
470            if (!\success($tickRet)) {
471                $logger->error('Could not register TICKET change after ticket webhook.');
472            }
473        }
474        if (count($updates) > 0) {
475            \commit_changes($conx, $placeId, null, $updates);
476        }
477    }
478
479    /**
480     * @param list<array<string, mixed>> $tickets
481     */
482    private static function registerActivities(
483        \ConxHelper $conx,
484        ?string $sessionObjid,
485        array $tickets,
486        LoggerInterface $logger
487    ): void {
488        if (!function_exists('register_ticket_activity')) {
489            $path = __DIR__.'/../regstr/ticket.php';
490            if (is_file($path)) {
491                require_once $path;
492            }
493        }
494        if (!function_exists('register_ticket_activity')) {
495            return;
496        }
497        foreach ($tickets as $ticket) {
498            $ret = \register_ticket_activity($conx, $sessionObjid, $ticket);
499            if (!\success($ret)) {
500                $logger->error('Could not register ticket activity for '.$ticket['objid']);
501            }
502        }
503    }
504}