Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.22% covered (warning)
65.22%
90 / 138
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
LicenseConfirm
65.22% covered (warning)
65.22%
90 / 138
25.00% covered (danger)
25.00%
1 / 4
130.21
0.00% covered (danger)
0.00%
0 / 1
 metadataFromStripeObject
46.15% covered (danger)
46.15%
6 / 13
0.00% covered (danger)
0.00%
0 / 1
14.65
 expectedTotalCents
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 applyPaidLicense
70.11% covered (warning)
70.11%
61 / 87
0.00% covered (danger)
0.00%
0 / 1
44.04
 applyExpiredLicense
59.46% covered (warning)
59.46%
22 / 37
0.00% covered (danger)
0.00%
0 / 1
19.06
1<?php
2
3declare(strict_types=1);
4
5namespace UppServices\stripe;
6
7use Psr\Log\LoggerInterface;
8
9/**
10 * Shared license activation / failure after Stripe reports Checkout outcome.
11 *
12 * Used by Webhook (push) and Reconcile (UI-driven pull). Idempotent when PAYMENT
13 * is already DN (paid) or ER (expired / failed).
14 */
15class LicenseConfirm
16{
17    /**
18     * Reads Stripe metadata from a Checkout Session or PaymentIntent object.
19     *
20     * Prefer toArray() — casting StripeObject with (array) loses public keys.
21     *
22     * @return array<string, mixed>
23     */
24    public static function metadataFromStripeObject(object $object): array
25    {
26        if (!isset($object->metadata)) {
27            return [];
28        }
29
30        $rawMeta = $object->metadata;
31        if (is_object($rawMeta) && method_exists($rawMeta, 'toArray')) {
32            return $rawMeta->toArray();
33        }
34        if (is_array($rawMeta)) {
35            return $rawMeta;
36        }
37        if (is_object($rawMeta)) {
38            $metadata = [];
39            foreach ($rawMeta as $key => $value) {
40                $metadata[(string) $key] = $value;
41            }
42            return $metadata;
43        }
44
45        return [];
46    }
47
48    /**
49     * Expected Stripe amount_total in cents from PAYMENT base + taxrate.
50     */
51    public static function expectedTotalCents(float $base, float $taxRate): int
52    {
53        return (int) round(($base + ($base * $taxRate / 100.0)) * 100);
54    }
55
56    /**
57     * Marks license PAYMENT as DN and extends PLACE.expires (central), then changelog.
58     *
59     * Idempotent: if the payment is already DN (license), returns Success + confirmed.
60     *
61     * @return array{errorcode: int, status?: string}
62     */
63    public static function applyPaidLicense(
64        \ConxHelper $conx,
65        string $placeId,
66        string $paymentId,
67        ?int $amountCents,
68        ?string $currency,
69        ?string $txnid,
70        LoggerInterface $logger
71    ): array {
72        $payments = [];
73        $res = $conx->tenant->query('PAYMENT', ['objid' => $paymentId], $payments);
74        if (!$res) {
75            return ['errorcode' => \ApplicationError::Database];
76        }
77
78        if (count($payments) === 0) {
79            $logger->error("Payment '".$paymentId."' not found.");
80            return ['errorcode' => \ApplicationError::Invalid];
81        }
82
83        $payment = $payments[0];
84
85        if ((string) ($payment['type'] ?? '') !== 'LICENSE') {
86            $logger->warning('Ticket payment confirm not implemented in license service yet; ignoring.');
87            return ['errorcode' => \ApplicationError::Success, 'status' => 'pending'];
88        }
89
90        if (($payment['status'] ?? '') === 'DN') {
91            \UppServices\SwinvoiceService::prepareAfterPaid($conx, (int) $placeId, (int) $paymentId);
92            \UppServices\SwinvoiceService::dispatchAsync((int) $placeId, (int) $paymentId);
93            return ['errorcode' => \ApplicationError::Success, 'status' => 'confirmed'];
94        }
95
96        if (($payment['status'] ?? '') !== 'PA') {
97            $logger->error("Payment '".$paymentId."' is not pending (status=".($payment['status'] ?? '').').');
98            return ['errorcode' => \ApplicationError::Invalid];
99        }
100
101        $taxRate = isset($payment['taxrate']) ? (float) $payment['taxrate'] : 0.0;
102        $base = (float) ($payment['amount'] ?? 0);
103        $expectedCents = self::expectedTotalCents($base, $taxRate);
104        if ($amountCents !== null && $amountCents !== $expectedCents) {
105            $logger->error("Unexpected amount (expected {$expectedCents}, received {$amountCents}).");
106            return ['errorcode' => \ApplicationError::Invalid];
107        }
108
109        if ($currency !== null && strtoupper((string) $payment['currency']) !== strtoupper($currency)) {
110            $logger->error('Unexpected currency received.');
111            return ['errorcode' => \ApplicationError::Invalid];
112        }
113
114        if ($txnid) {
115            $dup = [];
116            $res = $conx->tenant->query('PAYMENT', ['txnid' => $txnid], $dup);
117            if (!$res) {
118                return ['errorcode' => \ApplicationError::Database];
119            }
120            foreach ($dup as $row) {
121                if ((string) ($row['objid'] ?? '') !== (string) $paymentId) {
122                    $logger->error("Duplicated transaction reference: '{$txnid}'.");
123                    return ['errorcode' => \ApplicationError::Invalid];
124                }
125            }
126        }
127
128        $period = (string) ($payment['period'] ?? '');
129        $extendExpr = match ($period) {
130            '1M' => \AppConstants::Extend_Period_1M,
131            '3M' => \AppConstants::Extend_Period_3M,
132            '1Y' => \AppConstants::Extend_Period_1Y,
133            default => null,
134        };
135
136        if ($extendExpr === null) {
137            $logger->error("Unrecognized payment period '{$period}'.");
138            return ['errorcode' => \ApplicationError::Invalid];
139        }
140
141        $places = [];
142        $res = $conx->global->query('PLACE', ['objid' => $placeId], $places);
143        if (!$res || count($places) === 0) {
144            $logger->error("Place '{$placeId}' not found for license extend.");
145            return ['errorcode' => \ApplicationError::NotFound];
146        }
147        $placeinfo = $places[0];
148
149        $now = $conx->global->now();
150        $expiresBase = $placeinfo['expires'] ?? $now;
151        $dt1 = date('Y-m-d H:i:s', strtotime($extendExpr, strtotime($now)));
152        $dt2 = date('Y-m-d H:i:s', strtotime($extendExpr, strtotime((string) $expiresBase)));
153        $newExpires = ($dt1 > $dt2) ? $dt1 : $dt2;
154
155        if (!$conx->begin()) {
156            return ['errorcode' => \ApplicationError::Database];
157        }
158
159        $placeTx = $conx->trn('PLACE');
160        $payTx = $conx->trn('PAYMENT');
161
162        if ($placeTx === null || $payTx === null) {
163            $conx->flush(false);
164            return ['errorcode' => \ApplicationError::Database];
165        }
166
167        $ok = $placeTx->update('PLACE', [
168            'objid' => $placeinfo['objid'],
169            'expires' => $newExpires,
170        ]);
171        if (!$ok) {
172            $conx->flush(false);
173            return ['errorcode' => \ApplicationError::Database];
174        }
175
176        $payUpdate = [
177            'objid' => $paymentId,
178            'status' => 'DN',
179        ];
180
181        if ($txnid) {
182            $payUpdate['txnid'] = $txnid;
183        }
184
185        $ok = $payTx->update('PAYMENT', $payUpdate);
186        if (!$ok) {
187            $conx->flush(false);
188            return ['errorcode' => \ApplicationError::Database];
189        }
190
191        if (!$conx->flush(true)) {
192            return ['errorcode' => \ApplicationError::Database];
193        }
194
195        // Sign + link SWINVOICE (QR) before DN sync so the POS can print fiscal QR
196        // without waiting for Hacienda. Failure does not roll back commercial (D-LICFISC-03).
197        \UppServices\SwinvoiceService::prepareAfterPaid($conx, (int) $placeId, (int) $paymentId);
198
199        Webhook::publishLicensePaymentAndPlace($conx, $placeId, $paymentId, $logger);        // Fire-and-forget institutional POST only (prepare already ran sync).
200        \UppServices\SwinvoiceService::dispatchAsync((int) $placeId, (int) $paymentId);
201
202        return ['errorcode' => \ApplicationError::Success, 'status' => 'confirmed'];
203    }
204
205    /**
206     * Marks a license PAYMENT as ER when the Stripe Checkout Session is expired.
207     *
208     * Idempotent: already ER → failed; already DN → confirmed (does not downgrade).
209     * Does not touch PLACE.expires. Publishes PAYMENT via changelog (RN-LIC-14).
210     *
211     * @return array{errorcode: int, status?: string}
212     */
213    public static function applyExpiredLicense(
214        \ConxHelper $conx,
215        string $placeId,
216        string $paymentId,
217        LoggerInterface $logger
218    ): array {
219        $payments = [];
220        $res = $conx->tenant->query('PAYMENT', ['objid' => $paymentId], $payments);
221        if (!$res) {
222            return ['errorcode' => \ApplicationError::Database];
223        }
224
225        if (count($payments) === 0) {
226            $logger->error("Payment '".$paymentId."' not found for expired checkout.");
227            return ['errorcode' => \ApplicationError::NotFound];
228        }
229
230        $payment = $payments[0];
231        if ((string) ($payment['type'] ?? '') !== 'LICENSE') {
232            $logger->error('applyExpiredLicense is for license payments only.');
233            return ['errorcode' => \ApplicationError::Invalid];
234        }
235
236        if (($payment['status'] ?? '') === 'ER') {
237            return ['errorcode' => \ApplicationError::Success, 'status' => 'failed'];
238        }
239
240        if (($payment['status'] ?? '') === 'DN') {
241            return ['errorcode' => \ApplicationError::Success, 'status' => 'confirmed'];
242        }
243
244        if (($payment['status'] ?? '') !== 'PA') {
245            $logger->error(
246                "Payment '".$paymentId."' cannot expire (status=".($payment['status'] ?? '').').'
247            );
248            return ['errorcode' => \ApplicationError::Invalid];
249        }
250
251        if (!$conx->begin()) {
252            return ['errorcode' => \ApplicationError::Database];
253        }
254
255        $payTx = $conx->trn('PAYMENT');
256        if ($payTx === null) {
257            $conx->flush(false);
258            return ['errorcode' => \ApplicationError::Database];
259        }
260
261        $ok = $payTx->update('PAYMENT', [
262            'objid' => $paymentId,
263            'status' => 'ER',
264        ]);
265        if (!$ok) {
266            $conx->flush(false);
267            return ['errorcode' => \ApplicationError::Database];
268        }
269
270        if (!$conx->flush(true)) {
271            return ['errorcode' => \ApplicationError::Database];
272        }
273
274        Checkout::publishPendingLicensePayment($conx, $placeId, $paymentId, $logger);
275
276        return ['errorcode' => \ApplicationError::Success, 'status' => 'failed'];
277    }
278}