Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
17.34% |
82 / 473 |
|
25.00% |
2 / 8 |
CRAP | |
0.00% |
0 / 1 |
| Reconcile | |
17.34% |
82 / 473 |
|
25.00% |
2 / 8 |
10124.95 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| licensePayStatus | |
36.73% |
54 / 147 |
|
0.00% |
0 / 1 |
364.17 | |||
| ticketPayStatus | |
7.65% |
13 / 170 |
|
0.00% |
0 / 1 |
1431.48 | |||
| reconcilePendingPayment | |
0.00% |
0 / 87 |
|
0.00% |
0 / 1 |
600 | |||
| payStatusResult | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| findCheckoutSession | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
462 | |||
| canManageLicense | |
13.33% |
2 / 15 |
|
0.00% |
0 / 1 |
29.43 | |||
| 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\LoggerFactory; |
| 9 | use 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 | */ |
| 15 | class 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 | } |