Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.29% covered (danger)
44.29%
62 / 140
12.50% covered (danger)
12.50%
1 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Sweep
44.29% covered (danger)
44.29%
62 / 140
12.50% covered (danger)
12.50%
1 / 8
362.77
0.00% covered (danger)
0.00%
0 / 1
 __construct
83.33% covered (success)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
2.02
 dispatchAsync
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
132
 handleAsync
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
5.93
 run
65.79% covered (warning)
65.79%
25 / 38
0.00% covered (danger)
0.00%
0 / 1
14.00
 reconcileOne
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 listPendingPayments
52.38% covered (warning)
52.38%
11 / 21
0.00% covered (danger)
0.00%
0 / 1
14.91
 tryAcquireLease
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 releaseLease
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace UppServices\stripe\async;
6
7use Psr\Log\LoggerInterface;
8use UppServices\LoggerFactory;
9use UppServices\stripe\Checkout;
10use UppServices\stripe\Reconcile;
11
12/**
13 * Place-scoped singleton reconciler for pending Stripe PAYMENT rows (LICENSE + TICKET).
14 *
15 * Dispatched after Checkout success (fire-and-forget LocalUrl). One worker per place:
16 * non-blocking lease; if already held, the async request exits. Loops until no PA left
17 * or Checkout Session lifetime elapses. Cron may call the same entrypoint later.
18 */
19class Sweep
20{
21    /** Prefer webhook before the first Stripe poll. */
22    public const GraceMs = 10_000;
23
24    /** Delay between reconcile passes. */
25    public const PollMs = 15_000;
26
27    private readonly LoggerFactory $loggerFactory;
28    private readonly Reconcile $reconcile;
29
30    /** @var callable|null Optional: fn(): \ConxHelper */
31    private $conxFactory;
32
33    /** @var callable fn(int $seconds): void */
34    private $sleeper;
35
36    /** @var callable fn(): int unix timestamp */
37    private $clock;
38
39    private int $graceMs;
40    private int $pollMs;
41    private int $maxMs;
42
43    /**
44     * @param callable|null $conxFactory Optional for tests
45     * @param callable|null $sleeper Optional for tests: fn(int $seconds): void
46     * @param callable|null $clock Optional for tests: fn(): int
47     */
48    public function __construct(
49        LoggerFactory $loggerFactory,
50        Reconcile $reconcile,
51        ?callable $conxFactory = null,
52        ?callable $sleeper = null,
53        ?callable $clock = null,
54        ?int $graceMs = null,
55        ?int $pollMs = null,
56        ?int $maxMs = null
57    ) {
58        require_once __DIR__.'/../../legacy/ddbb/conx.php';
59
60        $this->loggerFactory = $loggerFactory;
61        $this->reconcile = $reconcile;
62        $this->conxFactory = $conxFactory;
63        $this->sleeper = $sleeper ?? static function (int $seconds): void {
64            if ($seconds > 0) {
65                sleep($seconds);
66            }
67        };
68        $this->clock = $clock ?? static fn (): int => time();
69        $this->graceMs = $graceMs ?? self::GraceMs;
70        $this->pollMs = $pollMs ?? self::PollMs;
71        $this->maxMs = $maxMs ?? ((Checkout::CHECKOUT_EXPIRE_SECONDS * 1000) + 60_000);
72    }
73
74    /**
75     * Fire-and-forget LocalUrl POST to stripe/reconcile-async (parity with swinvoice/async).
76     * Failure is logged only â€” never rolls back Checkout.
77     */
78    public static function dispatchAsync(string $placeId): int
79    {
80        if ($placeId === '') {
81            return \ApplicationError::Parameters;
82        }
83
84        $post = ['place' => $placeId];
85
86        $curl = curl_init();
87        if ($curl === false) {
88            if (function_exists('addlog')) {
89                addlog(__FILE__, \LogLevel::Error, 'Could not initialize cURL for stripe sweep dispatch');
90            }
91            return \ApplicationError::Unknown;
92        }
93
94        $url = \AppConstants::LocalUrl().'stripe/reconcile-async';
95        curl_setopt($curl, CURLOPT_URL, $url);
96        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
97        curl_setopt($curl, CURLOPT_TIMEOUT, 1);
98        curl_setopt($curl, CURLOPT_POST, true);
99        curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($post));
100        curl_setopt($curl, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
101
102        curl_exec($curl);
103        $curlerr = curl_errno($curl);
104        $retval = \ApplicationError::Success;
105        if ($curlerr) {
106            if ($curlerr === CURLE_OPERATION_TIMEDOUT) {
107                if (function_exists('addlog')) {
108                    addlog(__FILE__, \LogLevel::Information, "[STRIPE-SWEEP] Internal request to '".$url."' ongoing");
109                }
110            } else {
111                if (function_exists('addlog')) {
112                    addlog(__FILE__, \LogLevel::Error, "[STRIPE-SWEEP] Internal request to '".$url."' failed: ".curl_error($curl));
113                }
114                $retval = \ApplicationError::Unknown;
115            }
116        } else {
117            $http = curl_getinfo($curl, CURLINFO_HTTP_CODE);
118            if ($http >= 400) {
119                if (function_exists('addlog')) {
120                    addlog(__FILE__, \LogLevel::Error, "[STRIPE-SWEEP] Internal request to '".$url."' failed with HTTP code: [".$http."]");
121                }
122                $retval = \ApplicationError::Unknown;
123            } else {
124                if (function_exists('addlog')) {
125                    addlog(__FILE__, \LogLevel::Information, "[STRIPE-SWEEP] Internal request to '".$url."' ongoing");
126                }
127            }
128        }
129        curl_close($curl);
130        return $retval;
131    }
132
133    /**
134     * POST /stripe/reconcile-async body handler.
135     *
136     * @return array{errorcode: int, status?: string}
137     */
138    public function handleAsync(string $rawBody): array
139    {
140        $logger = $this->loggerFactory->for(__FILE__);
141        if ($rawBody === '') {
142            $logger->error('Empty body for stripe/reconcile-async.');
143            return ['errorcode' => \ApplicationError::Parameters];
144        }
145
146        $data = json_decode($rawBody, true);
147        if (!is_array($data) || !isset($data['place']) || (string) $data['place'] === '') {
148            $logger->error('Invalid stripe/reconcile-async payload.');
149            return ['errorcode' => \ApplicationError::Parameters];
150        }
151
152        return $this->run((string) $data['place']);
153    }
154
155    /**
156     * Run the place sweep (lock + grace + poll loop). Safe to call from cron later.
157     *
158     * @return array{errorcode: int, status?: string}
159     */
160    public function run(string $placeId): array
161    {
162        $logger = $this->loggerFactory->for(__FILE__);
163        if ($placeId === '') {
164            return ['errorcode' => \ApplicationError::Parameters];
165        }
166
167        $lease = $this->tryAcquireLease($placeId);
168        if ($lease === null) {
169            $logger->info('stripe-sweep busy place='.$placeId);
170            return ['errorcode' => \ApplicationError::Success, 'status' => 'busy'];
171        }
172
173        $maxSeconds = (int) max(1, (int) ceil($this->maxMs / 1000));
174        @ini_set('max_execution_time', (string) $maxSeconds);
175        @ini_set('ignore_user_abort', '1');
176
177        $started = ($this->clock)();
178        $logger->info('stripe-sweep start place='.$placeId);
179
180        try {
181            $graceSeconds = (int) max(0, (int) ceil($this->graceMs / 1000));
182            ($this->sleeper)($graceSeconds);
183
184            $pollSeconds = (int) max(1, (int) ceil($this->pollMs / 1000));
185
186            while (true) {
187                $elapsedMs = (($this->clock)() - $started) * 1000;
188                if ($elapsedMs >= $this->maxMs) {
189                    $logger->info('stripe-sweep wall-clock exit place='.$placeId);
190                    break;
191                }
192
193                $pending = $this->listPendingPayments($placeId, $logger);
194                if ($pending === null) {
195                    return ['errorcode' => \ApplicationError::Database];
196                }
197
198                if (count($pending) === 0) {
199                    $logger->info('stripe-sweep done (no PA) place='.$placeId);
200                    break;
201                }
202
203                foreach ($pending as $payment) {
204                    $this->reconcileOne($placeId, $payment, $logger);
205                }
206
207                $still = $this->listPendingPayments($placeId, $logger);
208                if ($still === null) {
209                    return ['errorcode' => \ApplicationError::Database];
210                }
211                if (count($still) === 0) {
212                    $logger->info('stripe-sweep done after pass place='.$placeId);
213                    break;
214                }
215
216                ($this->sleeper)($pollSeconds);
217            }
218
219            return ['errorcode' => \ApplicationError::Success, 'status' => 'done'];
220        } finally {
221            $this->releaseLease($lease);
222            $logger->info('stripe-sweep end place='.$placeId);
223        }
224    }
225
226    /**
227     * @param array<string, mixed> $payment
228     */
229    private function reconcileOne(string $placeId, array $payment, LoggerInterface $logger): void
230    {
231        $conx = $this->conxFactory !== null
232            ? ($this->conxFactory)()
233            : new \ConxHelper(__FILE__);
234
235        try {
236            $conx->tenant = $placeId;
237            if ($conx->tenant === null) {
238                $logger->error('stripe-sweep tenant connect failed place='.$placeId);
239                return;
240            }
241
242            $result = $this->reconcile->reconcilePendingPayment($conx, $placeId, $payment);
243            $paymentId = (string) ($payment['objid'] ?? '');
244            $logger->info(
245                'stripe-sweep payment='.$paymentId
246                .' errorcode='.(string) ($result['errorcode'] ?? '')
247                .' status='.(string) ($result['status'] ?? '')
248            );
249        } finally {
250            $conx->release();
251        }
252    }
253
254    /**
255     * @return list<array<string, mixed>>|null null on database error
256     */
257    private function listPendingPayments(string $placeId, LoggerInterface $logger): ?array
258    {
259        $conx = $this->conxFactory !== null
260            ? ($this->conxFactory)()
261            : new \ConxHelper(__FILE__);
262
263        try {
264            $conx->tenant = $placeId;
265            if ($conx->tenant === null) {
266                $logger->error('stripe-sweep list: tenant connect failed place='.$placeId);
267                return null;
268            }
269
270            $rows = [];
271            $res = $conx->tenant->query('PAYMENT', ['status' => 'PA'], $rows);
272            if (!$res) {
273                $logger->error('stripe-sweep list PAYMENT failed place='.$placeId);
274                return null;
275            }
276
277            $out = [];
278            foreach ($rows as $row) {
279                if (!is_array($row)) {
280                    continue;
281                }
282                $type = (string) ($row['type'] ?? '');
283                if ($type === 'LICENSE' || $type === 'TICKET') {
284                    $out[] = $row;
285                }
286            }
287            return $out;
288        } finally {
289            $conx->release();
290        }
291    }
292
293    /**
294     * Non-blocking exclusive lease per place (avoids blocking flock / short Redis TTL).
295     *
296     * @return resource|null
297     */
298    private function tryAcquireLease(string $placeId)
299    {
300        $safe = preg_replace('/[^A-Za-z0-9_.-]/', '_', $placeId);
301        $path = sys_get_temp_dir().'/upp_stripe_sweep_'.$safe.'.lock';
302        $handle = fopen($path, 'c+');
303        if ($handle === false) {
304            return null;
305        }
306        if (!flock($handle, LOCK_EX | LOCK_NB)) {
307            fclose($handle);
308            return null;
309        }
310        return $handle;
311    }
312
313    /** @param resource $lease */
314    private function releaseLease($lease): void
315    {
316        flock($lease, LOCK_UN);
317        fclose($lease);
318    }
319}