Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
71.55% |
83 / 116 |
|
33.33% |
1 / 3 |
CRAP | |
0.00% |
0 / 1 |
| Webhook | |
71.55% |
83 / 116 |
|
33.33% |
1 / 3 |
58.07 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| handle | |
66.32% |
63 / 95 |
|
0.00% |
0 / 1 |
57.96 | |||
| publishLicensePaymentAndPlace | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
4.00 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace UppServices\stripe; |
| 6 | |
| 7 | use Psr\Log\LoggerInterface; |
| 8 | use UppServices\LoggerFactory; |
| 9 | |
| 10 | /** |
| 11 | * Processes Stripe webhooks (license and guest ticket payments). |
| 12 | * |
| 13 | * Routes on metadata.action: LICENSE (default) vs TICKET (aligned with PAYMENT.type). |
| 14 | */ |
| 15 | class Webhook |
| 16 | { |
| 17 | private readonly LoggerFactory $loggerFactory; |
| 18 | |
| 19 | /** @var callable|null Optional: fn(string $payload, string $sig): object for tests */ |
| 20 | private $constructEvent; |
| 21 | |
| 22 | public function __construct(LoggerFactory $loggerFactory, ?callable $constructEvent = null) |
| 23 | { |
| 24 | require_once __DIR__.'/../legacy/ddbb/conx.php'; |
| 25 | require_once __DIR__.'/../legacy/model/syncchanges.php'; |
| 26 | |
| 27 | $this->loggerFactory = $loggerFactory; |
| 28 | $this->constructEvent = $constructEvent; |
| 29 | } |
| 30 | |
| 31 | /** |
| 32 | * Handle raw webhook body + Stripe-Signature header. |
| 33 | * |
| 34 | * @return array{errorcode: int, received?: bool} |
| 35 | */ |
| 36 | public function handle(string $rawBody, string $signatureHeader): array |
| 37 | { |
| 38 | $logger = $this->loggerFactory->for(__FILE__); |
| 39 | |
| 40 | if ($rawBody === '' || $signatureHeader === '') { |
| 41 | $logger->warning('Stripe webhook missing body or signature.'); |
| 42 | return ['errorcode' => \ApplicationError::Parameters]; |
| 43 | } |
| 44 | |
| 45 | try { |
| 46 | if ($this->constructEvent !== null) { |
| 47 | $event = ($this->constructEvent)($rawBody, $signatureHeader); |
| 48 | } else { |
| 49 | $event = \Stripe\Webhook::constructEvent( |
| 50 | $rawBody, |
| 51 | $signatureHeader, |
| 52 | \AppConstants::Stripe_HookSign() |
| 53 | ); |
| 54 | } |
| 55 | } catch (\UnexpectedValueException $e) { |
| 56 | $logger->warning('Stripe webhook invalid payload: '.$e->getMessage()); |
| 57 | return ['errorcode' => \ApplicationError::Invalid]; |
| 58 | } catch (\Stripe\Exception\SignatureVerificationException $e) { |
| 59 | $logger->warning('Stripe webhook invalid signature: '.$e->getMessage()); |
| 60 | return ['errorcode' => \ApplicationError::Invalid]; |
| 61 | } catch (\Throwable $e) { |
| 62 | $logger->warning('Stripe webhook constructEvent failed: '.$e->getMessage()); |
| 63 | return ['errorcode' => \ApplicationError::Invalid]; |
| 64 | } |
| 65 | |
| 66 | $type = is_object($event) ? (string) ($event->type ?? '') : ''; |
| 67 | if ($type !== 'checkout.session.completed' && $type !== 'payment_intent.succeeded') { |
| 68 | // Acknowledge unrelated events without writing. |
| 69 | return ['errorcode' => \ApplicationError::Success, 'received' => true]; |
| 70 | } |
| 71 | |
| 72 | $object = is_object($event) ? ($event->data->object ?? null) : null; |
| 73 | if (!is_object($object)) { |
| 74 | return ['errorcode' => \ApplicationError::Invalid]; |
| 75 | } |
| 76 | |
| 77 | $metadata = LicenseConfirm::metadataFromStripeObject($object); |
| 78 | |
| 79 | $paymentId = isset($metadata['payment']) ? (string) $metadata['payment'] : ''; |
| 80 | $placeId = isset($metadata['place']) ? (string) $metadata['place'] : ''; |
| 81 | if ($paymentId === '' || $placeId === '') { |
| 82 | $logger->error('Stripe webhook missing payment/place metadata.'); |
| 83 | return ['errorcode' => \ApplicationError::Parameters]; |
| 84 | } |
| 85 | |
| 86 | $amountCents = null; |
| 87 | if (isset($object->amount_total)) { |
| 88 | $amountCents = (int) $object->amount_total; |
| 89 | } elseif (isset($object->amount)) { |
| 90 | $amountCents = (int) $object->amount; |
| 91 | } |
| 92 | |
| 93 | $currency = isset($object->currency) ? strtoupper((string) $object->currency) : null; |
| 94 | $txnid = isset($object->payment_intent) ? (string) $object->payment_intent : (isset($object->id) ? (string) $object->id : null); |
| 95 | $action = isset($metadata['action']) ? (string) $metadata['action'] : 'LICENSE'; |
| 96 | |
| 97 | $logger->info(sprintf( |
| 98 | 'Stripe webhook received type=%s action=%s place=%s payment=%s amount_cents=%s currency=%s txnid=%s', |
| 99 | $type, |
| 100 | $action, |
| 101 | $placeId, |
| 102 | $paymentId, |
| 103 | $amountCents === null ? 'n/a' : (string) $amountCents, |
| 104 | $currency ?? 'n/a', |
| 105 | $txnid ?? 'n/a' |
| 106 | )); |
| 107 | |
| 108 | $conx = new \ConxHelper(__FILE__); |
| 109 | try { |
| 110 | $conx->tenant = $placeId; |
| 111 | if ($conx->tenant === null) { |
| 112 | $logger->error('Stripe webhook: tenant connection failed for place '.$placeId); |
| 113 | return ['errorcode' => \ApplicationError::Database]; |
| 114 | } |
| 115 | |
| 116 | if ($action === 'TICKET') { |
| 117 | $sessionMeta = isset($metadata['session']) ? (string) $metadata['session'] : null; |
| 118 | $result = TicketConfirm::applyPaidTickets( |
| 119 | $conx, |
| 120 | $placeId, |
| 121 | $paymentId, |
| 122 | $amountCents, |
| 123 | $currency, |
| 124 | $txnid, |
| 125 | $sessionMeta, |
| 126 | $logger |
| 127 | ); |
| 128 | } else { |
| 129 | $result = LicenseConfirm::applyPaidLicense( |
| 130 | $conx, |
| 131 | $placeId, |
| 132 | $paymentId, |
| 133 | $amountCents, |
| 134 | $currency, |
| 135 | $txnid, |
| 136 | $logger |
| 137 | ); |
| 138 | } |
| 139 | |
| 140 | if (($result['errorcode'] ?? 1) !== \ApplicationError::Success) { |
| 141 | $logger->error(sprintf( |
| 142 | 'Stripe webhook apply failed action=%s place=%s payment=%s errorcode=%s', |
| 143 | $action, |
| 144 | $placeId, |
| 145 | $paymentId, |
| 146 | (string) ($result['errorcode'] ?? '') |
| 147 | )); |
| 148 | return ['errorcode' => $result['errorcode']]; |
| 149 | } |
| 150 | |
| 151 | $status = isset($result['status']) ? (string) $result['status'] : 'confirmed'; |
| 152 | $logger->info(sprintf( |
| 153 | 'Stripe webhook applied OK action=%s place=%s payment=%s status=%s', |
| 154 | $action, |
| 155 | $placeId, |
| 156 | $paymentId, |
| 157 | $status |
| 158 | )); |
| 159 | |
| 160 | return ['errorcode' => \ApplicationError::Success, 'received' => true]; |
| 161 | } finally { |
| 162 | $conx->release(); |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | /** |
| 167 | * Registers PAYMENT (confirmed) and PLACE (new expires) in _CHANGESLOG (RN-LIC-14). |
| 168 | * |
| 169 | * Best-effort per RN-CHGLOG-02: logs push failures; commits when at least one update |
| 170 | * was registered. Session origin is null so the paying POS is notified. |
| 171 | * |
| 172 | * @param int|string $paymentId |
| 173 | */ |
| 174 | public static function publishLicensePaymentAndPlace( |
| 175 | \ConxHelper $conx, |
| 176 | string $placeId, |
| 177 | int|string $paymentId, |
| 178 | LoggerInterface $logger |
| 179 | ): void { |
| 180 | $updates = []; |
| 181 | $paymentChange = [ |
| 182 | 'table' => 'PAYMENT', |
| 183 | 'objid' => $paymentId, |
| 184 | ]; |
| 185 | $placeChange = [ |
| 186 | 'table' => 'PLACE', |
| 187 | 'objid' => $placeId, |
| 188 | ]; |
| 189 | |
| 190 | $payRet = \push_change($conx, $paymentChange, $updates); |
| 191 | $placeRet = \push_change($conx, $placeChange, $updates); |
| 192 | if (!\success($payRet)) { |
| 193 | $logger->error('Could not register PAYMENT change for changelog after license webhook.'); |
| 194 | } |
| 195 | |
| 196 | if (!\success($placeRet)) { |
| 197 | $logger->error('Could not register PLACE expires change for changelog after license webhook.'); |
| 198 | } |
| 199 | |
| 200 | if (count($updates) > 0) { |
| 201 | \commit_changes($conx, $placeId, null, $updates); |
| 202 | } |
| 203 | } |
| 204 | } |