Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.55% covered (warning)
71.55%
83 / 116
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
Webhook
71.55% covered (warning)
71.55%
83 / 116
33.33% covered (danger)
33.33%
1 / 3
58.07
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 handle
66.32% covered (warning)
66.32%
63 / 95
0.00% covered (danger)
0.00%
0 / 1
57.96
 publishLicensePaymentAndPlace
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
4.00
1<?php
2
3declare(strict_types=1);
4
5namespace UppServices\stripe;
6
7use Psr\Log\LoggerInterface;
8use 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 */
15class 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}