Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
28.11% |
149 / 530 |
|
33.33% |
5 / 15 |
CRAP | |
0.00% |
0 / 1 |
| Checkout | |
28.11% |
149 / 530 |
|
33.33% |
5 / 15 |
9313.86 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| licenseCheckout | |
17.80% |
34 / 191 |
|
0.00% |
0 / 1 |
974.61 | |||
| parseTicketIds | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
8 | |||
| applicationFeeCents | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| ticketCheckout | |
17.50% |
35 / 200 |
|
0.00% |
0 / 1 |
1753.58 | |||
| publishPendingTicketCheckout | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
30 | |||
| dispatchSweep | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| storeCheckoutSessionTxnId | |
83.33% |
10 / 12 |
|
0.00% |
0 / 1 |
4.07 | |||
| publishPendingLicensePayment | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| validateClientAmounts | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
| expectedWebhookUrl | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| hasConfiguredLicenseWebhook | |
95.65% |
22 / 23 |
|
0.00% |
0 / 1 |
11 | |||
| assertLicenseWebhookReady | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
156 | |||
| canManageLicense | |
71.43% |
10 / 14 |
|
0.00% |
0 / 1 |
6.84 | |||
| createStripeClient | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace UppServices\stripe; |
| 6 | |
| 7 | use Psr\Log\LoggerInterface; |
| 8 | use UppServices\LicenseService; |
| 9 | use UppServices\LoggerFactory; |
| 10 | use UppServices\SessionService; |
| 11 | use 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 | */ |
| 21 | class 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 | } |