Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
28.11% covered (danger)
28.11%
149 / 530
33.33% covered (danger)
33.33%
5 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Checkout
28.11% covered (danger)
28.11%
149 / 530
33.33% covered (danger)
33.33%
5 / 15
9313.86
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
 licenseCheckout
17.80% covered (danger)
17.80%
34 / 191
0.00% covered (danger)
0.00%
0 / 1
974.61
 parseTicketIds
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 applicationFeeCents
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 ticketCheckout
17.50% covered (danger)
17.50%
35 / 200
0.00% covered (danger)
0.00%
0 / 1
1753.58
 publishPendingTicketCheckout
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 dispatchSweep
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 storeCheckoutSessionTxnId
83.33% covered (success)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
4.07
 publishPendingLicensePayment
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 validateClientAmounts
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 expectedWebhookUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasConfiguredLicenseWebhook
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
11
 assertLicenseWebhookReady
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
156
 canManageLicense
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
6.84
 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\LicenseService;
9use UppServices\LoggerFactory;
10use UppServices\SessionService;
11use UppServices\stripe\async\Sweep;
12
13/**
14 * Creates Stripe Checkout sessions.
15 *
16 * License mode: direct charge to the platform (no Connect transfer).
17 * Refuses to open Checkout when the Stripe webhook endpoint is not ready
18 * (signing secret + enabled URL for /stripe/webhook with checkout.session.completed).
19 * Ticket guest checkout uses Connect destination + application_fee (ticketCheckout).
20 */
21class Checkout
22{
23    public const LICENCE_MANAGE_BIT = 6;
24
25    /**
26     * Checkout Session lifetime (Stripe allows 30 min … 24 h; platform default is 24 h).
27     * Abandoned license checkouts should not linger as PAYMENT.PA for a full day.
28     */
29    public const CHECKOUT_EXPIRE_SECONDS = 30 * 60;
30
31    private readonly SessionService $sessionService;
32    private readonly LoggerFactory $loggerFactory;
33    private readonly LicenseService $licenseService;
34
35    /** @var callable|null Optional factory for tests: fn(): object with checkout->sessions->create */
36    private $stripeClientFactory;
37
38    public function __construct(
39        SessionService $sessionService,
40        LoggerFactory $loggerFactory,
41        LicenseService $licenseService,
42        ?callable $stripeClientFactory = null
43    ) {
44        require_once __DIR__.'/../legacy/ddbb/conx.php';
45        require_once __DIR__.'/../legacy/model/syncchanges.php';
46
47        $this->sessionService = $sessionService;
48        $this->loggerFactory = $loggerFactory;
49        $this->licenseService = $licenseService;
50        $this->stripeClientFactory = $stripeClientFactory;
51    }
52
53    /**
54     * Create Stripe Checkout for a license renewal.
55     *
56     * @param array<string, mixed> $query Query: device, session, place, period, amount, lang?, totalWithTax?
57     * @return array{errorcode: int, url?: string, payment?: string}
58     */
59    public function licenseCheckout(array $query): array
60    {
61        $logger = $this->loggerFactory->for(__FILE__);
62        $conx = new \ConxHelper(__FILE__);
63        $url = null;
64
65        try {
66            $device = isset($query['device']) ? (string) $query['device'] : '';
67            $session = isset($query['session']) ? (string) $query['session'] : '';
68            $placeId = isset($query['place']) ? (string) $query['place'] : '';
69            $period = isset($query['period']) ? (string) $query['period'] : '';
70            $lang = isset($query['lang']) ? (string) $query['lang'] : 'ES';
71            $clientAmountRaw = $query['amount'] ?? null;
72            $clientTotalWithTaxRaw = $query['totalWithTax'] ?? null;
73
74            if ($device === '' || $session === '' || $placeId === '' || $period === '' || $clientAmountRaw === null || $clientAmountRaw === '') {
75                $logger->error('Missing device/session/place/period/amount for license checkout.');
76                return ['errorcode' => \ApplicationError::Parameters];
77            }
78
79            if (!is_numeric($clientAmountRaw)) {
80                $logger->error('Non-numeric amount for license checkout.');
81                return ['errorcode' => \ApplicationError::Parameters];
82            }
83            $clientAmount = (float) $clientAmountRaw;
84            $clientTotalWithTax = null;
85            if ($clientTotalWithTaxRaw !== null && $clientTotalWithTaxRaw !== '') {
86                if (!is_numeric($clientTotalWithTaxRaw)) {
87                    $logger->error('Non-numeric totalWithTax for license checkout.');
88                    return ['errorcode' => \ApplicationError::Parameters];
89                }
90                $clientTotalWithTax = (float) $clientTotalWithTaxRaw;
91            }
92
93            if (!in_array($period, ['1M', '3M', '1Y'], true)) {
94                $logger->error("Unrecognized license period '".$period."'.");
95                return ['errorcode' => \ApplicationError::Parameters];
96            }
97
98            $userid = null;
99            $retval = $this->sessionService->checkSession($conx, $session, $device, $userid);
100            if (!\success($retval)) {
101                return ['errorcode' => $retval];
102            }
103
104            $results = [];
105            $res = $conx->global->query('PLACE', ['objid' => $placeId, 'status' => ['PA', 'AC']], $results);
106            if (!$res) {
107                return ['errorcode' => \ApplicationError::Database];
108            }
109            if (count($results) === 0) {
110                $logger->error("No active place found with objid '".$placeId."'.");
111                return ['errorcode' => \ApplicationError::NotFound];
112            }
113            $placeinfo = $results[0];
114
115            $conx->tenant = $placeId;
116            if ($conx->tenant === null) {
117                $logger->error('Connection to place database failed.');
118                return ['errorcode' => \ApplicationError::Database];
119            }
120
121            if (!$this->canManageLicense($conx, $placeinfo, (string) $userid)) {
122                $logger->error('User '.$userid.' is not allowed to manage license for place '.$placeId);
123                return ['errorcode' => \ApplicationError::Unauthorized];
124            }
125
126            $activeTills = $this->licenseService->countActiveTills($conx);
127            $resolved = $this->licenseService->resolveConcepts($activeTills, $period);
128            if (count($resolved['concepts']) === 0 || $resolved['totalWithTax'] <= 0) {
129                $logger->error('License checkout resolved empty or zero total for period '.$period);
130                return ['errorcode' => \ApplicationError::Invalid];
131            }
132
133            $amountCheck = self::validateClientAmounts(
134                (float) $resolved['total'],
135                (float) $resolved['totalWithTax'],
136                $clientAmount,
137                $clientTotalWithTax
138            );
139            if ($amountCheck !== \ApplicationError::Success) {
140                $logger->error(
141                    'License checkout amount mismatch for place '.$placeId
142                    .' period '.$period
143                    .' clientAmount='.$clientAmount
144                    .' serverTotal='.$resolved['total']
145                    .' clientTotalWithTax='.($clientTotalWithTax === null ? 'null' : (string) $clientTotalWithTax)
146                    .' serverTotalWithTax='.$resolved['totalWithTax']
147                );
148                return ['errorcode' => $amountCheck];
149            }
150
151            // Fail closed: no Checkout Session (and no PAYMENT row) if webhook cannot activate the license.
152            try {
153                $stripe = $this->createStripeClient();
154            } 
155            catch (\Throwable $e) {
156                $logger->error('Stripe client init failed: '.$e->getMessage());
157                return ['errorcode' => \ApplicationError::Generic];
158            }
159            
160            $webhookCheck = $this->assertLicenseWebhookReady($stripe, $logger);
161            if ($webhookCheck !== \ApplicationError::Success) {
162                return ['errorcode' => $webhookCheck];
163            }
164
165            $reasonPrefix = match ($period) {
166                '1M' => \AppConstants::Extend_Reason_1M,
167                '3M' => \AppConstants::Extend_Reason_3M,
168                default => \AppConstants::Extend_Reason_1Y,
169            };
170            $reason = $reasonPrefix."'".($placeinfo['name'] ?? '')."'";
171
172            $conceptsSnapshot = array_map(static function (array $c): array {
173                return [
174                    'id' => $c['id'],
175                    'labelKey' => $c['labelKey'],
176                    'quantity' => $c['quantity'],
177                    'unitPrice' => $c['unitPrice'],
178                    'amount' => $c['amount'],
179                ];
180            }, $resolved['concepts']);
181
182            $paymentId = null;
183            $iteminfo = [
184                'status' => 'PA',
185                'payer' => \AppConstants::Extend_Business,
186                'ticket' => null,
187                'reason' => $reason,
188                'amount' => $resolved['total'],
189                'currency' => \AppConstants::Currency,
190                'type' => 'LICENSE',
191                'period' => $period,
192                'concepts' => json_encode($conceptsSnapshot, JSON_UNESCAPED_UNICODE),
193                'taxrate' => $resolved['taxRate'],
194            ];
195
196            $existing = [];
197            $res = $conx->tenant->query('PAYMENT', [
198                'status' => 'PA',
199                'ticket' => null,
200                'type' => 'LICENSE',
201                'period' => $period,
202                'amount' => $resolved['total'],
203            ], $existing);
204            if (!$res) {
205                return ['errorcode' => \ApplicationError::Database];
206            }
207
208            if (count($existing) > 0) {
209                $paymentId = $existing[0]['objid'];
210                $logger->warning('Reusing previous uncompleted license payment '.$paymentId);
211                $ok = $conx->tenant->transactional(function (\transaction $tx) use ($iteminfo, $paymentId): bool {
212                    $update = $iteminfo;
213                    $update['objid'] = $paymentId;
214                    return (bool) $tx->update('PAYMENT', $update);
215                });
216                if (!$ok) {
217                    return ['errorcode' => \ApplicationError::Database];
218                }
219            } else {
220                $insertedId = null;
221                $ok = $conx->tenant->transactional(function (\transaction $tx) use ($iteminfo, &$insertedId): bool {
222                    return (bool) $tx->insert('PAYMENT', $iteminfo, $insertedId);
223                });
224                if (!$ok || $insertedId === null) {
225                    return ['errorcode' => \ApplicationError::Database];
226                }
227                $paymentId = $insertedId;
228            }
229
230            $successUrl = \AppConstants::IonUrl().'on-license?license_result=SUCCESS&place='
231                .urlencode((string) $placeId).'&payment='.urlencode((string) $paymentId).'&lang='.urlencode($lang);
232            $cancelUrl = \AppConstants::IonUrl().'on-license?license_result=FAILURE&place='
233                .urlencode((string) $placeId).'&payment='.urlencode((string) $paymentId).'&lang='.urlencode($lang);
234
235            $lineItems = [];
236            foreach ($resolved['concepts'] as $concept) {
237                $lineItems[] = [
238                    'price_data' => [
239                        'currency' => strtolower(\AppConstants::Currency),
240                        'product_data' => [
241                            'name' => (string) $concept['id'].' × '.$concept['quantity'],
242                        ],
243                        'unit_amount' => (int) round(((float) $concept['amount']) * 100),
244                    ],
245                    'quantity' => 1,
246                ];
247            }
248            if ($resolved['taxTotal'] > 0) {
249                $lineItems[] = [
250                    'price_data' => [
251                        'currency' => strtolower(\AppConstants::Currency),
252                        'product_data' => [
253                            'name' => 'IVA ('.$resolved['taxRate'].'%)',
254                        ],
255                        'unit_amount' => (int) round(((float) $resolved['taxTotal']) * 100),
256                    ],
257                    'quantity' => 1,
258                ];
259            }
260
261            $metadata = [
262                'host' => \AppConstants::AppDomain(),
263                'place' => (string) $placeId,
264                'payment' => (string) $paymentId,
265                'action' => 'LICENSE',
266            ];
267
268            try {
269                $checkout = $stripe->checkout->sessions->create([
270                    'line_items' => $lineItems,
271                    'mode' => 'payment',
272                    'expires_at' => time() + self::CHECKOUT_EXPIRE_SECONDS,
273                    'success_url' => $successUrl,
274                    'cancel_url' => $cancelUrl,
275                    'payment_intent_data' => [
276                        'metadata' => $metadata,
277                    ],
278                    'metadata' => $metadata,
279                ]);
280                $url = is_object($checkout) ? ($checkout->url ?? null) : null;
281                $sessionId = is_object($checkout) ? ($checkout->id ?? null) : null;
282                if (!$url) {
283                    $logger->error('Failed obtaining Stripe checkout session URL');
284                    return ['errorcode' => \ApplicationError::Generic];
285                }
286            } catch (\Throwable $e) {
287                $logger->error('Stripe checkout failed: '.$e->getMessage());
288                return ['errorcode' => \ApplicationError::Generic];
289            }
290
291            $storeRet = self::storeCheckoutSessionTxnId($conx, $paymentId, is_string($sessionId) ? $sessionId : null, $logger);
292            if (!\success($storeRet)) {
293                return ['errorcode' => $storeRet];
294            }
295
296            self::publishPendingLicensePayment($conx, $placeId, $paymentId, $logger);
297            self::dispatchSweep($placeId, $logger);
298
299            return [
300                'errorcode' => \ApplicationError::Success,
301                'url' => $url,
302                'payment' => (string) $paymentId,
303            ];
304        } finally {
305            $conx->release();
306        }
307    }
308
309    /**
310     * Parse ticket objids from query (`tickets=1,2`) or JSON body (`tickets: []`).
311     *
312     * @param array<string, mixed> $query
313     * @param array<string, mixed> $payload
314     * @return list<string>
315     */
316    public static function parseTicketIds(array $query, array $payload): array
317    {
318        $raw = $payload['tickets'] ?? $query['tickets'] ?? null;
319        if (is_array($raw)) {
320            $ids = [];
321            foreach ($raw as $item) {
322                $id = trim((string) $item);
323                if ($id !== '') {
324                    $ids[] = $id;
325                }
326            }
327            return $ids;
328        }
329        if (is_string($raw) && trim($raw) !== '') {
330            $ids = [];
331            foreach (explode(',', $raw) as $item) {
332                $id = trim($item);
333                if ($id !== '') {
334                    $ids[] = $id;
335                }
336            }
337            return $ids;
338        }
339        return [];
340    }
341
342    /**
343     * Stripe application_fee_amount in cents (v3: amount_euros * percent + fixed cents).
344     */
345    public static function applicationFeeCents(float $amountEuros): int
346    {
347        return (int) round($amountEuros * \AppConstants::Stripe_ApplicationFee_Percent)
348            + (int) \AppConstants::Stripe_ApplicationFee_Fixed;
349    }
350
351    /**
352     * POST /guest/payment/checkout — Stripe Checkout for one or more guest tickets (Connect).
353     *
354     * @param array<string, mixed> $query device, session, tickets?, amount, lang?, table?, validQR?
355     * @param array<string, mixed> $payload optional JSON { tickets: [] }
356     * @return array{errorcode: int, url?: string, payment?: string}
357     */
358    public function ticketCheckout(array $query, array $payload = []): array
359    {
360        $logger = $this->loggerFactory->for(__FILE__);
361        $conx = new \ConxHelper(__FILE__);
362        $url = null;
363
364        try {
365            $device = isset($query['device']) ? (string) $query['device'] : '';
366            $session = isset($query['session']) ? (string) $query['session'] : '';
367            $lang = isset($query['lang']) ? (string) $query['lang'] : 'ES';
368            $tableParam = isset($query['table']) ? (string) $query['table'] : '';
369            $validQr = isset($query['validQR']) ? (string) $query['validQR'] : '';
370            $clientAmountRaw = $query['amount'] ?? ($payload['amount'] ?? null);
371            $ticketIds = self::parseTicketIds($query, $payload);
372
373            if ($device === '' || $session === '' || count($ticketIds) === 0 || $clientAmountRaw === null || $clientAmountRaw === '') {
374                $missing = [];
375
376                if ($device === '') {
377                    $missing[] = 'device';
378                }
379                
380                if ($session === '') {
381                    $missing[] = 'session';
382                }
383                
384                if (count($ticketIds) === 0) {
385                    $missing[] = 'tickets';
386                }
387                
388                if ($clientAmountRaw === null || $clientAmountRaw === '') {
389                    $missing[] = 'amount';
390                }
391
392                $logger->error('Missing '.implode('/', $missing).' for ticket checkout.');
393                return ['errorcode' => \ApplicationError::Parameters];
394            }
395            if (!is_numeric($clientAmountRaw)) {
396                $logger->error('Non-numeric amount for ticket checkout.');
397                return ['errorcode' => \ApplicationError::Parameters];
398            }
399            $clientAmount = (float) $clientAmountRaw;
400
401            $qrscanid = null;
402            $sessionObjid = null;
403            $retval = $this->sessionService->checkAccess($conx, $session, $device, $qrscanid, $sessionObjid);
404            if (!\success($retval)) {
405                return ['errorcode' => $retval];
406            }
407            if ($qrscanid === null || $qrscanid === '') {
408                $logger->error('Ticket checkout requires a guest table session.');
409                return ['errorcode' => \ApplicationError::Invalid];
410            }
411
412            $scans = [];
413            $res = $conx->global->query('QRSCAN', ['objid' => $qrscanid, 'status' => 'AC'], $scans);
414            if (!$res) {
415                return ['errorcode' => \ApplicationError::Database];
416            }
417            if (count($scans) === 0) {
418                $logger->error("No active QRSCAN '".$qrscanid."'.");
419                return ['errorcode' => \ApplicationError::NotFound];
420            }
421            $scan = $scans[0];
422            $placeId = (string) ($scan['place'] ?? '');
423            $tableQrcode = (string) ($scan['qrcode'] ?? '');
424            $tableNumber = (string) ($scan['number'] ?? '');
425            if ($placeId === '') {
426                return ['errorcode' => \ApplicationError::NotFound];
427            }
428
429            $conx->tenant = $placeId;
430            if ($conx->tenant === null) {
431                $logger->error('Connection to place database failed.');
432                return ['errorcode' => \ApplicationError::Database];
433            }
434
435            $idList = implode(',', array_map('intval', $ticketIds));
436            $ticketRows = [];
437            $sql = "select * from TICKET where objid in (".$idList.") and status not in ('DE', 'CC', 'PD')";
438            $res = $conx->tenant->runsql(['TICKET'], $sql, $ticketRows);
439            if (!$res) {
440                return ['errorcode' => \ApplicationError::Database];
441            }
442            if (count($ticketRows) !== count($ticketIds)) {
443                $logger->error('Not all tickets are valid for payment.');
444                return ['errorcode' => \ApplicationError::Invalid];
445            }
446
447            foreach ($ticketRows as $ticket) {
448                if ((string) ($ticket['qrcode'] ?? '') !== $tableQrcode) {
449                    $logger->error('Different table tickets. This is not supported.');
450                    return ['errorcode' => \ApplicationError::Invalid];
451                }
452            }
453
454            $amount = 0.0;
455            foreach ($ticketRows as $ticket) {
456                $amount += (float) ($ticket['price'] ?? 0);
457            }
458            $amountCheck = self::validateClientAmounts($amount, $amount, $clientAmount, null);
459            if ($amountCheck !== \ApplicationError::Success) {
460                $logger->error('Ticket checkout amount mismatch for place '.$placeId);
461                return ['errorcode' => $amountCheck];
462            }
463
464            $stripes = [];
465            $res = $conx->tenant->query('STRIPE', ['status' => 'AC'], $stripes);
466            if (!$res) {
467                return ['errorcode' => \ApplicationError::Database];
468            }
469            if (count($stripes) === 0 || empty($stripes[0]['account'])) {
470                $logger->error('No active Stripe Connect account for place '.$placeId);
471                return ['errorcode' => \ApplicationError::NotFound];
472            }
473            $destination = (string) $stripes[0]['account'];
474
475            $places = [];
476            $res = $conx->global->query('PLACE', ['objid' => $placeId, 'status' => ['PA', 'AC']], $places);
477            if (!$res || count($places) === 0) {
478                return ['errorcode' => \ApplicationError::NotFound];
479            }
480            $placeName = (string) ($places[0]['name'] ?? '');
481
482            $users = [];
483            $payer = \AppConstants::Extend_Business;
484            if (!empty($places[0]['user'])) {
485                $res = $conx->global->query('USER', ['objid' => $places[0]['user'], 'status' => 'AC'], $users);
486                if ($res && count($users) > 0 && !empty($users[0]['email'])) {
487                    $payer = (string) $users[0]['email'];
488                }
489            }
490
491            try {
492                $stripe = $this->createStripeClient();
493            } catch (\Throwable $e) {
494                $logger->error('Stripe client init failed: '.$e->getMessage());
495                return ['errorcode' => \ApplicationError::Generic];
496            }
497            $webhookCheck = $this->assertLicenseWebhookReady($stripe, $logger);
498            if ($webhookCheck !== \ApplicationError::Success) {
499                return ['errorcode' => $webhookCheck];
500            }
501
502            $reason = $placeName.' - table: '.$tableNumber.' ('.count($ticketRows).' tickets)';
503            $iteminfo = [
504                'status' => 'PA',
505                'payer' => $payer,
506                'ticket' => null,
507                'reason' => $reason,
508                'amount' => $amount,
509                'currency' => \AppConstants::Currency,
510                'type' => 'TICKET',
511            ];
512
513            $paymentId = null;
514            $ok = $conx->tenant->transactional(function (\transaction $tx) use (
515                $iteminfo,
516                $ticketRows,
517                $sessionObjid,
518                &$paymentId
519            ): bool {
520                if (!$tx->insert('PAYMENT', $iteminfo, $paymentId)) {
521                    return false;
522                }
523                foreach ($ticketRows as $ticket) {
524                    if (!$tx->update('TICKET', [
525                        'objid' => $ticket['objid'],
526                        'status' => 'PP',
527                        'payment' => 'PAYLINE',
528                        'paidby' => $sessionObjid,
529                        'paidon' => null,
530                        'paidin' => null,
531                        'till' => null,
532                        'flags' => TicketConfirm::markToPayFlags((string) ($ticket['flags'] ?? '')),
533                        'transaction' => $paymentId,
534                    ])) {
535                        return false;
536                    }
537                }
538                return true;
539            });
540            if (!$ok || $paymentId === null) {
541                return ['errorcode' => \ApplicationError::Database];
542            }
543
544            $qraBase = rtrim(\AppConstants::QraUrl(), '/').'/';
545            $returnQs = 'place='.urlencode($placeId)
546                .'&payment='.urlencode((string) $paymentId)
547                .'&lang='.urlencode($lang);
548            if ($tableParam !== '') {
549                $returnQs .= '&table='.urlencode($tableParam);
550            }
551            if ($validQr !== '') {
552                $returnQs .= '&validQR='.urlencode($validQr);
553            }
554            $successUrl = $qraBase.'on-payment?result=SUCCESS&'.$returnQs;
555            $cancelUrl = $qraBase.'on-payment?result=FAILURE&'.$returnQs;
556
557            $lineItems = [];
558            foreach ($ticketRows as $ticket) {
559                $lineItems[] = [
560                    'price_data' => [
561                        'currency' => strtolower(\AppConstants::Currency),
562                        'product_data' => [
563                            'name' => (string) ($ticket['orderno'] ?? $ticket['objid']),
564                        ],
565                        'unit_amount' => (int) round(((float) ($ticket['price'] ?? 0)) * 100),
566                    ],
567                    'quantity' => 1,
568                ];
569            }
570
571            $fee = self::applicationFeeCents($amount);
572            $metadata = [
573                'host' => \AppConstants::AppDomain(),
574                'place' => $placeId,
575                'payment' => (string) $paymentId,
576                'action' => 'TICKET',
577                'session' => (string) $sessionObjid,
578            ];
579
580            try {
581                $checkout = $stripe->checkout->sessions->create([
582                    'line_items' => $lineItems,
583                    'mode' => 'payment',
584                    'expires_at' => time() + self::CHECKOUT_EXPIRE_SECONDS,
585                    'success_url' => $successUrl,
586                    'cancel_url' => $cancelUrl,
587                    'payment_intent_data' => [
588                        'application_fee_amount' => $fee,
589                        'transfer_data' => [
590                            'destination' => $destination,
591                        ],
592                        'metadata' => $metadata,
593                    ],
594                    'metadata' => $metadata,
595                ]);
596                $url = is_object($checkout) ? ($checkout->url ?? null) : null;
597                $sessionId = is_object($checkout) ? ($checkout->id ?? null) : null;
598                if (!$url) {
599                    $logger->error('Failed obtaining Stripe checkout session URL');
600                    TicketConfirm::applyExpiredTicketPayment($conx, $placeId, (string) $paymentId, $logger);
601                    return ['errorcode' => \ApplicationError::Generic];
602                }
603            } catch (\Throwable $e) {
604                $logger->error('Stripe checkout failed: '.$e->getMessage());
605                TicketConfirm::applyExpiredTicketPayment($conx, $placeId, (string) $paymentId, $logger);
606                return ['errorcode' => \ApplicationError::Generic];
607            }
608
609            $storeRet = self::storeCheckoutSessionTxnId($conx, $paymentId, is_string($sessionId) ? $sessionId : null, $logger);
610            if (!\success($storeRet)) {
611                TicketConfirm::applyExpiredTicketPayment($conx, $placeId, (string) $paymentId, $logger);
612                return ['errorcode' => $storeRet];
613            }
614
615            self::publishPendingTicketCheckout($conx, $placeId, $paymentId, $ticketRows, $logger);
616            self::dispatchSweep($placeId, $logger);
617
618            return [
619                'errorcode' => \ApplicationError::Success,
620                'url' => $url,
621                'payment' => (string) $paymentId,
622            ];
623        } finally {
624            $conx->release();
625        }
626    }
627
628    /**
629     * Registers PAYMENT + linked TICKET rows in _CHANGESLOG after ticket checkout.
630     *
631     * Best-effort: failure is logged only; Success+url is unchanged (RN-CHGLOG-02).
632     *
633     * @param int|string $paymentId
634     * @param list<array<string, mixed>> $ticketRows
635     */
636    public static function publishPendingTicketCheckout(
637        \ConxHelper $conx,
638        string $placeId,
639        int|string $paymentId,
640        array $ticketRows,
641        LoggerInterface $logger
642    ): void {
643        $updates = [];
644        $paymentChange = [
645            'table' => 'PAYMENT',
646            'objid' => $paymentId,
647        ];
648        $pushRet = \push_change($conx, $paymentChange, $updates);
649        if (!\success($pushRet)) {
650            $logger->error('Could not register PAYMENT change after ticket checkout.');
651        }
652        foreach ($ticketRows as $ticket) {
653            $tickChange = [
654                'table' => 'TICKET',
655                'objid' => $ticket['objid'],
656            ];
657            $tickRet = \push_change($conx, $tickChange, $updates);
658            if (!\success($tickRet)) {
659                $logger->error('Could not register TICKET change after ticket checkout.');
660            }
661        }
662        if (count($updates) > 0) {
663            \commit_changes($conx, $placeId, null, $updates);
664        }
665    }
666
667    /**
668     * Start place sweep worker after Checkout URL is ready. Dispatch failure is logged only.
669     */
670    private static function dispatchSweep(string $placeId, LoggerInterface $logger): void
671    {
672        try {
673            $ret = Sweep::dispatchAsync($placeId);
674            if (!\success($ret)) {
675                $logger->warning('Stripe sweep dispatch failed for place '.$placeId.' errorcode='.$ret);
676            }
677        } catch (\Throwable $e) {
678            $logger->warning('Stripe sweep dispatch exception for place '.$placeId.': '.$e->getMessage());
679        }
680    }
681
682    /**
683     * Persists Checkout Session id (`cs_…`) on pending PAYMENT.txnid for reconcile (CA-LIC-08).
684     *
685     * Webhook / LicenseConfirm later overwrite txnid with payment_intent (`pi_…`).
686     * Empty/null session id is a no-op Success (nothing to store).
687     *
688     * @param int|string $paymentId
689     * @return int ApplicationError code
690     */
691    public static function storeCheckoutSessionTxnId(
692        \ConxHelper $conx,
693        int|string $paymentId,
694        ?string $sessionId,
695        LoggerInterface $logger
696    ): int {
697        if (!is_string($sessionId) || $sessionId === '') {
698            return \ApplicationError::Success;
699        }
700
701        $ok = $conx->tenant->transactional(function (\transaction $tx) use ($paymentId, $sessionId): bool {
702            return (bool) $tx->update('PAYMENT', [
703                'objid' => $paymentId,
704                'txnid' => $sessionId,
705            ]);
706        });
707        if (!$ok) {
708            $logger->error('Could not store Checkout session id on license PAYMENT '.$paymentId);
709            return \ApplicationError::Database;
710        }
711
712        return \ApplicationError::Success;
713    }
714
715    /**
716     * Registers PAYMENT in _CHANGESLOG after Checkout URL is ready (RN-LIC-14 / RF-LIC-05).
717     *
718     * Best-effort: on push/commit failure logs and returns; does not change the Success+url
719     * response of licenseCheckout (RN-CHGLOG-02). Session origin is null so the paying POS
720     * is notified.
721     *
722     * @param int|string $paymentId
723     */
724    public static function publishPendingLicensePayment(
725        \ConxHelper $conx,
726        string $placeId,
727        int|string $paymentId,
728        LoggerInterface $logger
729    ): void {
730        $updates = [];
731        $paymentChange = [
732            'table' => 'PAYMENT',
733            'objid' => $paymentId,
734        ];
735        $pushRet = \push_change($conx, $paymentChange, $updates);
736        if (\success($pushRet) && count($updates) > 0) {
737            \commit_changes($conx, $placeId, null, $updates);
738        } else {
739            $logger->error('Could not register PAYMENT change after license checkout.');
740        }
741    }
742
743    /**
744     * Compares client-displayed amounts with server-resolved totals (tolerance 0.01).
745     * Never use client amounts for Stripe unit_amount or PAYMENT.amount — only for anti-tamper.
746     *
747     * @return int ApplicationError::Success or ApplicationError::Invalid
748     */
749    public static function validateClientAmounts(
750        float $serverTotal,
751        float $serverTotalWithTax,
752        float $clientAmount,
753        ?float $clientTotalWithTax,
754        float $tolerance = 0.01
755    ): int {
756        if (abs($clientAmount - $serverTotal) > $tolerance) {
757            return \ApplicationError::Invalid;
758        }
759        if ($clientTotalWithTax !== null && abs($clientTotalWithTax - $serverTotalWithTax) > $tolerance) {
760            return \ApplicationError::Invalid;
761        }
762        return \ApplicationError::Success;
763    }
764
765    /**
766     * Public URL Stripe must call for license (and future ticket) webhooks.
767     */
768    public static function expectedWebhookUrl(): string
769    {
770        return rtrim(\AppConstants::AppUrl(), '/').'/stripe/webhook';
771    }
772
773    /**
774     * True when signing secret is set and an enabled endpoint matches URL + license events.
775     *
776     * Does not verify that BUILD_STRIPEPAY_SECRET equals the endpoint secret (Stripe only
777     * returns secret at create time). Fail-closed on empty secret or no matching endpoint.
778     *
779     * @param list<object|array{url?: string, status?: string, enabled_events?: list<string>}> $endpoints
780     */
781    public static function hasConfiguredLicenseWebhook(
782        string $hookSign,
783        array $endpoints,
784        string $expectedUrl
785    ): bool {
786        if ($hookSign === '') {
787            return false;
788        }
789
790        $expected = rtrim($expectedUrl, '/');
791        foreach ($endpoints as $endpoint) {
792            $url = is_object($endpoint)
793                ? (string) ($endpoint->url ?? '')
794                : (string) ($endpoint['url'] ?? '');
795
796            $status = is_object($endpoint)
797                ? (string) ($endpoint->status ?? '')
798                : (string) ($endpoint['status'] ?? '');
799
800            $events = is_object($endpoint)
801                ? ($endpoint->enabled_events ?? [])
802                : ($endpoint['enabled_events'] ?? []);
803
804            if (!is_array($events)) {
805                $events = [];
806            }
807
808            if (rtrim($url, '/') !== $expected) {
809                continue;
810            }
811
812            if ($status !== 'enabled') {
813                continue;
814            }
815
816            if (
817                in_array('*', $events, true)
818                || in_array('checkout.session.completed', $events, true)
819            ) {
820                return true;
821            }
822        }
823
824        return false;
825    }
826
827    /**
828     * Lists Stripe webhook endpoints and refuses checkout when license delivery is not ready.
829     *
830     * @return int ApplicationError::Success or ApplicationError::Configuration
831     */
832    private function assertLicenseWebhookReady(object $stripe, LoggerInterface $logger): int
833    {
834        $hookSign = (string) \AppConstants::Stripe_HookSign();
835        $expectedUrl = self::expectedWebhookUrl();
836
837        if ($hookSign === '') {
838            $logger->error('Stripe webhook signing secret is empty; refusing license checkout.');
839            return \ApplicationError::Configuration;
840        }
841
842        // Do not use isset($stripe->webhookEndpoints): StripeClient exposes services via __get
843        // without __isset, so isset() is always false on a real client.
844        try {
845            $endpointsService = $stripe->webhookEndpoints;
846            if (!is_object($endpointsService) || !is_callable([$endpointsService, 'all'])) {
847                $logger->error('Stripe client missing webhookEndpoints.all; refusing license checkout.');
848                return \ApplicationError::Configuration;
849            }
850            $list = $endpointsService->all(['limit' => 100]);
851        } 
852        catch (\Throwable $e) {
853            $logger->error('Stripe webhookEndpoints.list failed: '.$e->getMessage());
854            return \ApplicationError::Configuration;
855        }
856
857        $data = [];
858        if (is_object($list) && isset($list->data) && is_array($list->data)) {
859            $data = $list->data;
860        } 
861        elseif (is_array($list) && isset($list['data']) && is_array($list['data'])) {
862            $data = $list['data'];
863        }
864
865        if (!self::hasConfiguredLicenseWebhook($hookSign, $data, $expectedUrl)) {
866            $logger->error(
867                'No enabled Stripe webhook for '.$expectedUrl
868                .' with checkout.session.completed; refusing license checkout.'
869            );
870            return \ApplicationError::Configuration;
871        }
872
873        return \ApplicationError::Success;
874    }
875
876    /**
877     * Place owner always; employee needs LicenceManage bit in STAFF.allow.
878     *
879     * @param array<string, mixed> $placeinfo
880     */
881    private function canManageLicense(\ConxHelper $conx, array $placeinfo, string $userid): bool
882    {
883        if ((string) ($placeinfo['user'] ?? '') === $userid) {
884            return true;
885        }
886
887        if ($conx->global === null) {
888            return false;
889        }
890
891        $rows = [];
892        $res = $conx->global->query('STAFF', [
893            'place' => $placeinfo['objid'],
894            'user' => $userid,
895            'status' => 'AC',
896        ], $rows);
897        if (!$res || count($rows) === 0) {
898            return false;
899        }
900
901        $allow = (string) ($rows[0]['allow'] ?? '');
902        return isset($allow[self::LICENCE_MANAGE_BIT]) && $allow[self::LICENCE_MANAGE_BIT] === '1';
903    }
904
905    private function createStripeClient(): object
906    {
907        if ($this->stripeClientFactory !== null) {
908            return ($this->stripeClientFactory)();
909        }
910
911        return new \Stripe\StripeClient(\AppConstants::Stripe_APIKEY());
912    }
913}