Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
17.34% covered (danger)
17.34%
82 / 473
25.00% covered (danger)
25.00%
2 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Reconcile
17.34% covered (danger)
17.34%
82 / 473
25.00% covered (danger)
25.00%
2 / 8
10124.95
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 licensePayStatus
36.73% covered (danger)
36.73%
54 / 147
0.00% covered (danger)
0.00%
0 / 1
364.17
 ticketPayStatus
7.65% covered (danger)
7.65%
13 / 170
0.00% covered (danger)
0.00%
0 / 1
1431.48
 reconcilePendingPayment
0.00% covered (danger)
0.00%
0 / 87
0.00% covered (danger)
0.00%
0 / 1
600
 payStatusResult
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 findCheckoutSession
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
462
 canManageLicense
13.33% covered (danger)
13.33%
2 / 15
0.00% covered (danger)
0.00%
0 / 1
29.43
 createStripeClient
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace UppServices\stripe;
6
7use Psr\Log\LoggerInterface;
8use UppServices\LoggerFactory;
9use UppServices\SessionService;
10
11/**
12 * UI-driven payment reconcile: asks Stripe whether a pending PAYMENT is paid
13 * and, if so, applies the same activation as the webhook (LicenseConfirm / TicketConfirm).
14 */
15class Reconcile
16{
17    public const LICENCE_MANAGE_BIT = 6;
18
19    private readonly SessionService $sessionService;
20    private readonly LoggerFactory $loggerFactory;
21
22    /** @var callable|null Optional factory for tests: fn(): object */
23    private $stripeClientFactory;
24
25    /** @var callable|null Optional factory for tests: fn(): \ConxHelper */
26    private $conxFactory;
27
28    public function __construct(
29        SessionService $sessionService,
30        LoggerFactory $loggerFactory,
31        ?callable $stripeClientFactory = null,
32        ?callable $conxFactory = null
33    ) {
34        require_once __DIR__.'/../legacy/ddbb/conx.php';
35        require_once __DIR__.'/../legacy/model/syncchanges.php';
36
37        $this->sessionService = $sessionService;
38        $this->loggerFactory = $loggerFactory;
39        $this->stripeClientFactory = $stripeClientFactory;
40        $this->conxFactory = $conxFactory;
41    }
42
43    /**
44     * POST /stripe/licensepaystatus — check / confirm a license payment.
45     *
46     * @param array<string, mixed> $query Query: device, session, place, payment
47     * @return array{errorcode: int, status?: string}
48     */
49    public function licensePayStatus(array $query): array
50    {
51        $logger = $this->loggerFactory->for(__FILE__);
52        $conx = $this->conxFactory !== null
53            ? ($this->conxFactory)()
54            : new \ConxHelper(__FILE__);
55
56        try {
57            $device = isset($query['device']) ? (string) $query['device'] : '';
58            $session = isset($query['session']) ? (string) $query['session'] : '';
59            $placeId = isset($query['place']) ? (string) $query['place'] : '';
60            $paymentId = isset($query['payment']) ? (string) $query['payment'] : '';
61
62            $logger->info('licensepaystatus → place='.$placeId.' payment='.$paymentId);
63
64            if ($device === '' || $session === '' || $placeId === '' || $paymentId === '') {
65                $logger->error('Missing device/session/place/payment for license pay status.');
66                return $this->payStatusResult($logger, $placeId, $paymentId, [
67                    'errorcode' => \ApplicationError::Parameters,
68                ]);
69            }
70
71            $userid = null;
72            $retval = $this->sessionService->checkSession($conx, $session, $device, $userid);
73            if (!\success($retval)) {
74                return $this->payStatusResult($logger, $placeId, $paymentId, [
75                    'errorcode' => $retval,
76                ]);
77            }
78
79            $placeConn = $conx->for('PLACE');
80            if ($placeConn === null) {
81                return $this->payStatusResult($logger, $placeId, $paymentId, [
82                    'errorcode' => \ApplicationError::Database,
83                ]);
84            }
85
86            $results = [];
87            $res = $placeConn->query('PLACE', ['objid' => $placeId, 'status' => ['PA', 'AC']], $results);
88            if (!$res) {
89                return $this->payStatusResult($logger, $placeId, $paymentId, [
90                    'errorcode' => \ApplicationError::Database,
91                ]);
92            }
93            if (count($results) === 0) {
94                $logger->error("No active place found with objid '".$placeId."'.");
95                return $this->payStatusResult($logger, $placeId, $paymentId, [
96                    'errorcode' => \ApplicationError::NotFound,
97                ]);
98            }
99            $placeinfo = $results[0];
100
101            $conx->tenant = $placeId;
102            $payConn = $conx->for('PAYMENT');
103            if ($payConn === null) {
104                $logger->error('Connection to place database failed.');
105                return $this->payStatusResult($logger, $placeId, $paymentId, [
106                    'errorcode' => \ApplicationError::Database,
107                ]);
108            }
109
110            if (!$this->canManageLicense($conx, $placeinfo, (string) $userid)) {
111                $logger->error('User '.$userid.' is not allowed to manage license for place '.$placeId);
112                return $this->payStatusResult($logger, $placeId, $paymentId, [
113                    'errorcode' => \ApplicationError::Unauthorized,
114                ]);
115            }
116
117            $payments = [];
118            $res = $payConn->query('PAYMENT', ['objid' => $paymentId], $payments);
119            if (!$res) {
120                return $this->payStatusResult($logger, $placeId, $paymentId, [
121                    'errorcode' => \ApplicationError::Database,
122                ]);
123            }
124            if (count($payments) === 0) {
125                return $this->payStatusResult($logger, $placeId, $paymentId, [
126                    'errorcode' => \ApplicationError::NotFound,
127                ]);
128            }
129
130            $payment = $payments[0];
131            $ticket = $payment['ticket'] ?? null;
132            if ($ticket !== null && $ticket !== '') {
133                $logger->error('licensepaystatus is for license payments only.');
134                return $this->payStatusResult($logger, $placeId, $paymentId, [
135                    'errorcode' => \ApplicationError::Invalid,
136                ]);
137            }
138
139            if (($payment['status'] ?? '') === 'DN') {
140                return $this->payStatusResult($logger, $placeId, $paymentId, [
141                    'errorcode' => \ApplicationError::Success,
142                    'status' => 'confirmed',
143                ]);
144            }
145
146            if (($payment['status'] ?? '') === 'ER') {
147                return $this->payStatusResult($logger, $placeId, $paymentId, [
148                    'errorcode' => \ApplicationError::Success,
149                    'status' => 'failed',
150                ]);
151            }
152
153            if (($payment['status'] ?? '') !== 'PA') {
154                return $this->payStatusResult($logger, $placeId, $paymentId, [
155                    'errorcode' => \ApplicationError::Invalid,
156                ]);
157            }
158
159            if ((string) ($payment['type'] ?? '') !== 'LICENSE') {
160                $logger->error(
161                    "licensepaystatus is for license payments only (type='".(string) ($payment['type'] ?? '')."')."
162                );
163                return $this->payStatusResult($logger, $placeId, $paymentId, [
164                    'errorcode' => \ApplicationError::Invalid,
165                ]);
166            }
167
168            try {
169                $stripe = $this->createStripeClient();
170            } catch (\Throwable $e) {
171                $logger->error('Stripe client init failed: '.$e->getMessage());
172                return $this->payStatusResult($logger, $placeId, $paymentId, [
173                    'errorcode' => \ApplicationError::Generic,
174                ]);
175            }
176
177            $checkoutSession = $this->findCheckoutSession($stripe, $placeId, $paymentId, $payment, $logger);
178            if ($checkoutSession === null) {
179                $logger->info('licensepaystatus Stripe session not found (still pending).');
180                return $this->payStatusResult($logger, $placeId, $paymentId, [
181                    'errorcode' => \ApplicationError::Success,
182                    'status' => 'pending',
183                ]);
184            }
185
186            $paymentStatus = is_object($checkoutSession)
187                ? (string) ($checkoutSession->payment_status ?? '')
188                : '';
189            $sessionStatus = is_object($checkoutSession)
190                ? (string) ($checkoutSession->status ?? '')
191                : '';
192            $sessionId = is_object($checkoutSession) && isset($checkoutSession->id)
193                ? (string) $checkoutSession->id
194                : '';
195
196            $logger->info(
197                'licensepaystatus Stripe session='.$sessionId
198                .' payment_status='.$paymentStatus
199                .' status='.$sessionStatus
200            );
201
202            if ($paymentStatus === 'paid') {
203                $amountCents = isset($checkoutSession->amount_total)
204                    ? (int) $checkoutSession->amount_total
205                    : null;
206                $currency = isset($checkoutSession->currency)
207                    ? strtoupper((string) $checkoutSession->currency)
208                    : null;
209                $txnid = isset($checkoutSession->payment_intent)
210                    ? (string) $checkoutSession->payment_intent
211                    : (isset($checkoutSession->id) ? (string) $checkoutSession->id : null);
212
213                $result = LicenseConfirm::applyPaidLicense(
214                    $conx,
215                    $placeId,
216                    $paymentId,
217                    $amountCents,
218                    $currency,
219                    $txnid,
220                    $logger
221                );
222
223                return $this->payStatusResult($logger, $placeId, $paymentId, $result);
224            }
225
226            // Checkout Session expired → definitive failure (PAYMENT.ER); open+unpaid stays pending.
227            if ($sessionStatus === 'expired') {
228                $result = LicenseConfirm::applyExpiredLicense(
229                    $conx,
230                    $placeId,
231                    $paymentId,
232                    $logger
233                );
234
235                return $this->payStatusResult($logger, $placeId, $paymentId, $result);
236            }
237
238            return $this->payStatusResult($logger, $placeId, $paymentId, [
239                'errorcode' => \ApplicationError::Success,
240                'status' => 'pending',
241            ]);
242        } finally {
243            $conx->release();
244        }
245    }
246
247    /**
248     * POST /guest/payment/status — check / confirm a guest ticket payment.
249     *
250     * Auth is guest table session (checkAccess). Ticket payments use type=TICKET;
251     * tickets are linked via TICKET.transaction = PAYMENT.objid.
252     *
253     * @param array<string, mixed> $query Query: device, session, payment
254     * @return array{errorcode: int, status?: string}
255     */
256    public function ticketPayStatus(array $query): array
257    {
258        $logger = $this->loggerFactory->for(__FILE__);
259        $conx = $this->conxFactory !== null
260            ? ($this->conxFactory)()
261            : new \ConxHelper(__FILE__);
262
263        try {
264            $device = isset($query['device']) ? (string) $query['device'] : '';
265            $session = isset($query['session']) ? (string) $query['session'] : '';
266            $paymentId = isset($query['payment']) ? (string) $query['payment'] : '';
267
268            $logger->info('ticketpaystatus → payment='.$paymentId);
269
270            if ($device === '' || $session === '' || $paymentId === '') {
271                $logger->error('Missing device/session/payment for ticket pay status.');
272                return $this->payStatusResult($logger, '', $paymentId, [
273                    'errorcode' => \ApplicationError::Parameters,
274                ], 'ticketpaystatus');
275            }
276
277            $qrscanid = null;
278            $sessionObjid = null;
279            $retval = $this->sessionService->checkAccess($conx, $session, $device, $qrscanid, $sessionObjid);
280            if (!\success($retval)) {
281                return $this->payStatusResult($logger, '', $paymentId, [
282                    'errorcode' => $retval,
283                ], 'ticketpaystatus');
284            }
285
286            if ($qrscanid === null || $qrscanid === '') {
287                $logger->error('Ticket pay status requires a guest table session.');
288                return $this->payStatusResult($logger, '', $paymentId, [
289                    'errorcode' => \ApplicationError::Invalid,
290                ], 'ticketpaystatus');
291            }
292
293            $scanConn = $conx->for('QRSCAN');
294            if ($scanConn === null) {
295                return $this->payStatusResult($logger, '', $paymentId, [
296                    'errorcode' => \ApplicationError::Database,
297                ], 'ticketpaystatus');
298            }
299
300            $scans = [];
301            $res = $scanConn->query('QRSCAN', ['objid' => $qrscanid, 'status' => 'AC'], $scans);
302            if (!$res) {
303                return $this->payStatusResult($logger, '', $paymentId, [
304                    'errorcode' => \ApplicationError::Database,
305                ], 'ticketpaystatus');
306            }
307
308            if (count($scans) === 0) {
309                return $this->payStatusResult($logger, '', $paymentId, [
310                    'errorcode' => \ApplicationError::NotFound,
311                ], 'ticketpaystatus');
312            }
313
314            $placeId = (string) ($scans[0]['place'] ?? '');
315            if ($placeId === '') {
316                return $this->payStatusResult($logger, '', $paymentId, [
317                    'errorcode' => \ApplicationError::NotFound,
318                ], 'ticketpaystatus');
319            }
320
321            $conx->tenant = $placeId;
322            $payConn = $conx->for('PAYMENT');
323            if ($payConn === null) {
324                return $this->payStatusResult($logger, $placeId, $paymentId, [
325                    'errorcode' => \ApplicationError::Database,
326                ], 'ticketpaystatus');
327            }
328
329            $payments = [];
330            $res = $payConn->query('PAYMENT', ['objid' => $paymentId], $payments);
331            if (!$res) {
332                return $this->payStatusResult($logger, $placeId, $paymentId, [
333                    'errorcode' => \ApplicationError::Database,
334                ], 'ticketpaystatus');
335            }
336
337            if (count($payments) === 0) {
338                return $this->payStatusResult($logger, $placeId, $paymentId, [
339                    'errorcode' => \ApplicationError::NotFound,
340                ], 'ticketpaystatus');
341            }
342
343            $payment = $payments[0];
344            if ((string) ($payment['type'] ?? '') !== 'TICKET') {
345                $logger->error('ticketpaystatus is for ticket payments only.');
346                return $this->payStatusResult($logger, $placeId, $paymentId, [
347                    'errorcode' => \ApplicationError::Invalid,
348                ], 'ticketpaystatus');
349            }
350
351            $ticketConn = $conx->for('TICKET');
352            if ($ticketConn === null) {
353                return $this->payStatusResult($logger, $placeId, $paymentId, [
354                    'errorcode' => \ApplicationError::Database,
355                ], 'ticketpaystatus');
356            }
357
358            $linked = [];
359            $res = $ticketConn->query('TICKET', ['transaction' => $paymentId], $linked);
360            if (!$res) {
361                return $this->payStatusResult($logger, $placeId, $paymentId, [
362                    'errorcode' => \ApplicationError::Database,
363                ], 'ticketpaystatus');
364            }
365
366            if (count($linked) === 0) {
367                $logger->error("No tickets linked to payment '".$paymentId."'.");
368                return $this->payStatusResult($logger, $placeId, $paymentId, [
369                    'errorcode' => \ApplicationError::NotFound,
370                ], 'ticketpaystatus');
371            }
372
373            if (($payment['status'] ?? '') === 'DN') {
374                // Re-enter applyPaidTickets so TicketFiscal can seal/heal PAYLINE
375                // payments when the webhook closed the cobro but left registry empty.
376                $paidBySession = is_string($sessionObjid) ? $sessionObjid : null;
377                $result = TicketConfirm::applyPaidTickets(
378                    $conx,
379                    $placeId,
380                    $paymentId,
381                    null,
382                    null,
383                    null,
384                    $paidBySession,
385                    $logger
386                );
387
388                return $this->payStatusResult($logger, $placeId, $paymentId, $result, 'ticketpaystatus');
389            }
390
391            if (($payment['status'] ?? '') === 'ER') {
392                return $this->payStatusResult($logger, $placeId, $paymentId, [
393                    'errorcode' => \ApplicationError::Success,
394                    'status' => 'failed',
395                ], 'ticketpaystatus');
396            }
397
398            if (($payment['status'] ?? '') !== 'PA') {
399                return $this->payStatusResult($logger, $placeId, $paymentId, [
400                    'errorcode' => \ApplicationError::Invalid,
401                ], 'ticketpaystatus');
402            }
403
404            try {
405                $stripe = $this->createStripeClient();
406            } 
407            catch (\Throwable $e) {
408                $logger->error('Stripe client init failed: '.$e->getMessage());
409                return $this->payStatusResult($logger, $placeId, $paymentId, [
410                    'errorcode' => \ApplicationError::Generic,
411                ], 'ticketpaystatus');
412            }
413
414            $checkoutSession = $this->findCheckoutSession($stripe, $placeId, $paymentId, $payment, $logger);
415            if ($checkoutSession === null) {
416                $logger->info('ticketpaystatus Stripe session not found (still pending).');
417                return $this->payStatusResult($logger, $placeId, $paymentId, [
418                    'errorcode' => \ApplicationError::Success,
419                    'status' => 'pending',
420                ], 'ticketpaystatus');
421            }
422
423            $paymentStatus = is_object($checkoutSession)
424                ? (string) ($checkoutSession->payment_status ?? '')
425                : '';
426            $sessionStatus = is_object($checkoutSession)
427                ? (string) ($checkoutSession->status ?? '')
428                : '';
429            $sessionId = is_object($checkoutSession) && isset($checkoutSession->id)
430                ? (string) $checkoutSession->id
431                : '';
432
433            $logger->info(
434                'ticketpaystatus Stripe session='.$sessionId
435                .' payment_status='.$paymentStatus
436                .' status='.$sessionStatus
437            );
438
439            if ($paymentStatus === 'paid') {
440                $amountCents = isset($checkoutSession->amount_total)
441                    ? (int) $checkoutSession->amount_total
442                    : null;
443                $currency = isset($checkoutSession->currency)
444                    ? strtoupper((string) $checkoutSession->currency)
445                    : null;
446                $txnid = isset($checkoutSession->payment_intent)
447                    ? (string) $checkoutSession->payment_intent
448                    : (isset($checkoutSession->id) ? (string) $checkoutSession->id : null);
449                $meta = LicenseConfirm::metadataFromStripeObject($checkoutSession);
450                $paidBySession = isset($meta['session']) ? (string) $meta['session'] : null;
451                if ($paidBySession === null || $paidBySession === '') {
452                    $paidBySession = is_string($sessionObjid) ? $sessionObjid : null;
453                }
454
455                $result = TicketConfirm::applyPaidTickets(
456                    $conx,
457                    $placeId,
458                    $paymentId,
459                    $amountCents,
460                    $currency,
461                    $txnid,
462                    $paidBySession,
463                    $logger
464                );
465
466                return $this->payStatusResult($logger, $placeId, $paymentId, $result, 'ticketpaystatus');
467            }
468
469            if ($sessionStatus === 'expired') {
470                $result = TicketConfirm::applyExpiredTicketPayment(
471                    $conx,
472                    $placeId,
473                    $paymentId,
474                    $logger
475                );
476
477                return $this->payStatusResult($logger, $placeId, $paymentId, $result, 'ticketpaystatus');
478            }
479
480            return $this->payStatusResult($logger, $placeId, $paymentId, [
481                'errorcode' => \ApplicationError::Success,
482                'status' => 'pending',
483            ], 'ticketpaystatus');
484        } 
485        finally {
486            $conx->release();
487        }
488    }
489
490    /**
491     * Trusted reconcile for one pending PAYMENT (no guest/staff session).
492     * Caller opens the tenant connection. Used by the place sweep worker.
493     *
494     * @param array<string, mixed> $payment Tenant PAYMENT row (status PA expected)
495     * @return array{errorcode: int, status?: string}
496     */
497    public function reconcilePendingPayment(
498        \ConxHelper $conx,
499        string $placeId,
500        array $payment,
501        ?string $paidBySession = null
502    ): array {
503        $logger = $this->loggerFactory->for(__FILE__);
504        $paymentId = isset($payment['objid']) ? (string) $payment['objid'] : '';
505        if ($placeId === '' || $paymentId === '') {
506            return ['errorcode' => \ApplicationError::Parameters];
507        }
508
509        if (($payment['status'] ?? '') === 'DN') {
510            if ((string) ($payment['type'] ?? '') === 'TICKET') {
511                return TicketConfirm::applyPaidTickets(
512                    $conx,
513                    $placeId,
514                    $paymentId,
515                    null,
516                    null,
517                    null,
518                    $paidBySession,
519                    $logger
520                );
521            }
522            return ['errorcode' => \ApplicationError::Success, 'status' => 'confirmed'];
523        }
524
525        if (($payment['status'] ?? '') === 'ER') {
526            return ['errorcode' => \ApplicationError::Success, 'status' => 'failed'];
527        }
528
529        if (($payment['status'] ?? '') !== 'PA') {
530            return ['errorcode' => \ApplicationError::Invalid];
531        }
532
533        try {
534            $stripe = $this->createStripeClient();
535        } catch (\Throwable $e) {
536            $logger->error('Stripe client init failed (sweep): '.$e->getMessage());
537            return ['errorcode' => \ApplicationError::Generic];
538        }
539
540        $checkoutSession = $this->findCheckoutSession($stripe, $placeId, $paymentId, $payment, $logger);
541        if ($checkoutSession === null) {
542            return ['errorcode' => \ApplicationError::Success, 'status' => 'pending'];
543        }
544
545        $paymentStatus = is_object($checkoutSession)
546            ? (string) ($checkoutSession->payment_status ?? '')
547            : '';
548        $sessionStatus = is_object($checkoutSession)
549            ? (string) ($checkoutSession->status ?? '')
550            : '';
551
552        if ($paymentStatus === 'paid') {
553            $amountCents = isset($checkoutSession->amount_total)
554                ? (int) $checkoutSession->amount_total
555                : null;
556            $currency = isset($checkoutSession->currency)
557                ? strtoupper((string) $checkoutSession->currency)
558                : null;
559            $txnid = isset($checkoutSession->payment_intent)
560                ? (string) $checkoutSession->payment_intent
561                : (isset($checkoutSession->id) ? (string) $checkoutSession->id : null);
562
563            if ((string) ($payment['type'] ?? '') === 'LICENSE') {
564                return LicenseConfirm::applyPaidLicense(
565                    $conx,
566                    $placeId,
567                    $paymentId,
568                    $amountCents,
569                    $currency,
570                    $txnid,
571                    $logger
572                );
573            }
574
575            if ((string) ($payment['type'] ?? '') === 'TICKET') {
576                $meta = LicenseConfirm::metadataFromStripeObject($checkoutSession);
577                $sessionFromMeta = isset($meta['session']) ? (string) $meta['session'] : '';
578                $session = $sessionFromMeta !== '' ? $sessionFromMeta : $paidBySession;
579
580                return TicketConfirm::applyPaidTickets(
581                    $conx,
582                    $placeId,
583                    $paymentId,
584                    $amountCents,
585                    $currency,
586                    $txnid,
587                    $session,
588                    $logger
589                );
590            }
591
592            $logger->error('Sweep reconcile: unknown PAYMENT type for '.$paymentId);
593            return ['errorcode' => \ApplicationError::Invalid];
594        }
595
596        if ($sessionStatus === 'expired') {
597            if ((string) ($payment['type'] ?? '') === 'LICENSE') {
598                return LicenseConfirm::applyExpiredLicense(
599                    $conx,
600                    $placeId,
601                    $paymentId,
602                    $logger
603                );
604            }
605            if ((string) ($payment['type'] ?? '') === 'TICKET') {
606                return TicketConfirm::applyExpiredTicketPayment(
607                    $conx,
608                    $placeId,
609                    $paymentId,
610                    $logger
611                );
612            }
613            return ['errorcode' => \ApplicationError::Invalid];
614        }
615
616        return ['errorcode' => \ApplicationError::Success, 'status' => 'pending'];
617    }
618
619    /**
620     * Logs the outbound result of a paystatus endpoint and returns it.
621     *
622     * @param array{errorcode: int, status?: string} $result
623     * @return array{errorcode: int, status?: string}
624     */
625    private function payStatusResult(
626        LoggerInterface $logger,
627        string $placeId,
628        string $paymentId,
629        array $result,
630        string $label = 'licensepaystatus'
631    ): array {
632        $logger->info(
633            $label.' ← place='.$placeId
634            .' payment='.$paymentId
635            .' errorcode='.(string) ($result['errorcode'] ?? '')
636            .' status='.(string) ($result['status'] ?? '')
637        );
638        return $result;
639    }
640
641    /**
642     * Prefer PAYMENT.txnid when it is a Checkout Session id (cs_…); else list recent sessions.
643     *
644     * @param array<string, mixed> $payment
645     */
646    private function findCheckoutSession(
647        object $stripe,
648        string $placeId,
649        string $paymentId,
650        array $payment,
651        LoggerInterface $logger
652    ): ?object {
653        $stored = isset($payment['txnid']) ? (string) $payment['txnid'] : '';
654        if (str_starts_with($stored, 'cs_')) {
655            try {
656                $sessions = $stripe->checkout->sessions ?? null;
657                if (!is_object($sessions) || !is_callable([$sessions, 'retrieve'])) {
658                    $logger->error('Stripe client missing checkout.sessions.retrieve.');
659                    return null;
660                }
661                $session = $sessions->retrieve($stored);
662                return is_object($session) ? $session : null;
663            } catch (\Throwable $e) {
664                $logger->warning('Stripe session retrieve failed for '.$stored.': '.$e->getMessage());
665                // Fall through to list by metadata.
666            }
667        }
668
669        try {
670            $sessions = $stripe->checkout->sessions ?? null;
671            if (!is_object($sessions) || !is_callable([$sessions, 'all'])) {
672                $logger->error('Stripe client missing checkout.sessions.all.');
673                return null;
674            }
675            $list = $sessions->all(['limit' => 40]);
676        } catch (\Throwable $e) {
677            $logger->error('Stripe checkout.sessions.list failed: '.$e->getMessage());
678            return null;
679        }
680
681        $data = [];
682        if (is_object($list) && isset($list->data) && is_array($list->data)) {
683            $data = $list->data;
684        } elseif (is_array($list) && isset($list['data']) && is_array($list['data'])) {
685            $data = $list['data'];
686        }
687
688        $paidMatch = null;
689        $anyMatch = null;
690        foreach ($data as $row) {
691            if (!is_object($row)) {
692                continue;
693            }
694            $meta = LicenseConfirm::metadataFromStripeObject($row);
695            if ((string) ($meta['payment'] ?? '') !== $paymentId) {
696                continue;
697            }
698            if ((string) ($meta['place'] ?? '') !== $placeId) {
699                continue;
700            }
701            $anyMatch = $row;
702            if ((string) ($row->payment_status ?? '') === 'paid') {
703                $paidMatch = $row;
704                break;
705            }
706        }
707
708        return $paidMatch ?? $anyMatch;
709    }
710
711    /**
712     * Place owner always; employee needs LicenceManage bit in STAFF.allow.
713     *
714     * @param array<string, mixed> $placeinfo
715     */
716    private function canManageLicense(\ConxHelper $conx, array $placeinfo, string $userid): bool
717    {
718        if ((string) ($placeinfo['user'] ?? '') === $userid) {
719            return true;
720        }
721
722        $staffConn = $conx->for('STAFF');
723        if ($staffConn === null) {
724            return false;
725        }
726
727        $rows = [];
728        $res = $staffConn->query('STAFF', [
729            'place' => $placeinfo['objid'],
730            'user' => $userid,
731            'status' => 'AC',
732        ], $rows);
733        if (!$res || count($rows) === 0) {
734            return false;
735        }
736
737        $allow = (string) ($rows[0]['allow'] ?? '');
738        return isset($allow[self::LICENCE_MANAGE_BIT]) && $allow[self::LICENCE_MANAGE_BIT] === '1';
739    }
740
741    private function createStripeClient(): object
742    {
743        if ($this->stripeClientFactory !== null) {
744            return ($this->stripeClientFactory)();
745        }
746
747        return new \Stripe\StripeClient(\AppConstants::Stripe_APIKEY());
748    }
749}