Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.02% covered (success)
82.02%
479 / 584
38.46% covered (danger)
38.46%
10 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
Connect
82.02% covered (success)
82.02%
479 / 584
38.46% covered (danger)
38.46%
10 / 26
522.86
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 connect
80.71% covered (success)
80.71%
113 / 140
0.00% covered (danger)
0.00%
0 / 1
82.13
 connectStatus
81.97% covered (success)
81.97%
50 / 61
0.00% covered (danger)
0.00%
0 / 1
31.27
 onConnect
89.47% covered (success)
89.47%
34 / 38
0.00% covered (danger)
0.00%
0 / 1
19.42
 onRefresh
77.19% covered (warning)
77.19%
44 / 57
0.00% covered (danger)
0.00%
0 / 1
27.74
 statusFromAccount
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 statusFromRequirements
77.61% covered (warning)
77.61%
52 / 67
0.00% covered (danger)
0.00%
0 / 1
45.22
 recipientStripeTransfersStatus
74.07% covered (warning)
74.07%
20 / 27
0.00% covered (danger)
0.00%
0 / 1
18.92
 accountHasActiveStripeTransfers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 accountCreateParams
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
1
 accountRecipientUpgradeParams
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 recipientTransfersConfiguration
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 accountRetrieveParams
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 publishStripeChange
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 accountHasRecipient
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
7.77
 onboardingConfigurations
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 createAccountLink
75.76% covered (warning)
75.76%
25 / 33
0.00% covered (danger)
0.00%
0 / 1
7.70
 validateAndUpdateAccount
70.27% covered (warning)
70.27%
26 / 37
0.00% covered (danger)
0.00%
0 / 1
9.68
 callPublishStripe
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 newConx
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 createStripeClient
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 v2Core
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newAuthorizationKey
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 callAddAuthorization
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 callUseAuthorization
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 callDelAuthorization
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2
3declare(strict_types=1);
4
5namespace UppServices\stripe;
6
7use Psr\Log\LoggerInterface;
8use UppServices\LoggerFactory;
9use UppServices\SessionService;
10
11/**
12 * Stripe Connect onboarding: create/reuse account, Account Link, return/refresh callbacks.
13 */
14class Connect
15{
16    private readonly SessionService $sessionService;
17    private readonly LoggerFactory $loggerFactory;
18
19    /** @var callable|null Optional factory for tests: fn(): object with v2->core->accounts / accountLinks */
20    private $stripeClientFactory;
21
22    /** @var callable|null Optional factory for tests: fn(): \ConxHelper */
23    private $conxFactory;
24
25    /** @var callable|null Optional: fn(\ConxHelper, string $auth, ?string $email): bool */
26    private $addAuthorization;
27
28    /** @var callable|null Optional: fn(\ConxHelper, string $auth): ?array */
29    private $useAuthorization;
30
31    /** @var callable|null Optional: fn(\ConxHelper, array $item): bool */
32    private $delAuthorization;
33
34    /** @var callable|null Optional: fn(\ConxHelper, string $placeId, int|string $stripeId, LoggerInterface): void */
35    private $publishStripe;
36
37    public function __construct(
38        SessionService $sessionService,
39        LoggerFactory $loggerFactory,
40        ?callable $stripeClientFactory = null,
41        ?callable $conxFactory = null,
42        ?callable $addAuthorization = null,
43        ?callable $useAuthorization = null,
44        ?callable $delAuthorization = null,
45        ?callable $publishStripe = null,
46    ) {
47        require_once __DIR__.'/../legacy/ddbb/conx.php';
48        require_once __DIR__.'/../legacy/model/syncchanges.php';
49        require_once __DIR__.'/../../Legacy/common/authorize.php';
50
51        $this->sessionService = $sessionService;
52        $this->loggerFactory = $loggerFactory;
53        $this->stripeClientFactory = $stripeClientFactory;
54        $this->conxFactory = $conxFactory;
55        $this->addAuthorization = $addAuthorization;
56        $this->useAuthorization = $useAuthorization;
57        $this->delAuthorization = $delAuthorization;
58        $this->publishStripe = $publishStripe;
59    }
60
61    /**
62     * POST /stripe/connect â€” start Connect onboarding; returns Account Link URL.
63     *
64     * @param array<string, mixed> $query device, session, place, lang, token
65     * @return array{errorcode: int, url?: string}
66     */
67    public function connect(array $query): array
68    {
69        $logger = $this->loggerFactory->for(__FILE__);
70        $conx = $this->newConx();
71        $url = null;
72        $retval = \ApplicationError::Success;
73
74        try {
75            $device = isset($query['device']) ? (string) $query['device'] : '';
76            $session = isset($query['session']) ? (string) $query['session'] : '';
77            $placeId = isset($query['place']) ? (string) $query['place'] : '';
78            $lang = isset($query['lang']) ? (string) $query['lang'] : '';
79            $token = isset($query['token']) ? (string) $query['token'] : '';
80
81            if ($device === '' || $session === '' || $placeId === '' || $lang === '' || $token === '') {
82                $logger->error('Missing device/session/place/lang/token for Connect onboarding.');
83                $retval = \ApplicationError::Parameters;
84            }
85
86            $userid = null;
87            if (\success($retval)) {
88                $retval = $this->sessionService->checkSession($conx, $session, $device, $userid);
89            }
90
91            $userEmail = null;
92            if (\success($retval)) {
93                $users = [];
94                $res = $conx->global->query('USER', ['objid' => (string) $userid], $users);
95                if (!$res) {
96                    $retval = \ApplicationError::Database;
97                } elseif (count($users) === 0) {
98                    $logger->error('Session user with objid ['.$userid.'] not found.');
99                    $retval = \ApplicationError::Unknown;
100                } else {
101                    $userEmail = (string) ($users[0]['email'] ?? '');
102                }
103            }
104
105            if (\success($retval)) {
106                $places = [];
107                $res = $conx->global->query('PLACE', ['objid' => $placeId, 'status' => ['PA', 'AC']], $places);
108                if (!$res) {
109                    $retval = \ApplicationError::Database;
110                } elseif (count($places) === 0) {
111                    $logger->error("No active place found with objid '".$placeId."'.");
112                    $retval = \ApplicationError::NotFound;
113                }
114            }
115
116            if (\success($retval)) {
117                $conx->tenant = $placeId;
118                if ($conx->tenant === null) {
119                    $logger->error('Connection to place database failed.');
120                    $retval = \ApplicationError::Database;
121                }
122            }
123
124            $existing = null;
125            if (\success($retval)) {
126                // ddbb::query() with an empty filter builds invalid SQL ("WHERE ;").
127                // List all STRIPE rows via runsql, then skip deleted ones below.
128                $stripes = [];
129                $res = $conx->tenant->runsql(['STRIPE'], 'SELECT * FROM `STRIPE`;', $stripes);
130                if (!$res) {
131                    $logger->error('Could not list STRIPE rows for place '.$placeId.'.');
132                    $retval = \ApplicationError::Database;
133                } else {
134                    foreach ($stripes as $row) {
135                        if (($row['status'] ?? '') === 'DE') {
136                            continue;
137                        }
138                        $existing = $row;
139                        break;
140                    }
141                }
142            }
143
144            $accountId = null;
145            $stripe = null;
146            $createNew = ($existing === null);
147            if (\success($retval)) {
148                $stripe = $this->createStripeClient();
149                if (!$createNew) {
150                    $logger->info("Retrieving existing Stripe account for '".$userEmail."'..");
151                    $accountId = (string) ($existing['account'] ?? '');
152                    try {
153                        $account = $this->v2Core($stripe)->accounts->retrieve(
154                            $accountId,
155                            self::accountRetrieveParams()
156                        );
157                    } 
158                    catch (\Throwable $e) {
159                        $logger->error('Exception retrieving Stripe account: '.$e->getMessage());
160                        $retval = \ApplicationError::Generic;
161                        $account = null;
162                    }
163                    
164                    if (\success($retval) && !$account) {
165                        $logger->error('Failed obtaining Stripe account instance.');
166                        $retval = \ApplicationError::Generic;
167                    }
168
169                    // Destination charges need recipient/stripe_transfers. Upgrade in
170                    // place; never retire the existing STRIPE row on upgrade failure
171                    // (permissions / incomplete key would also break create, and would
172                    // discard a half-configured Connect account).
173                    if (\success($retval) && $account !== null && !self::accountHasRecipient($account)) {
174                        try {
175                            $this->v2Core($stripe)->accounts->update(
176                                $accountId,
177                                self::accountRecipientUpgradeParams()
178                            );
179                            $account = $this->v2Core($stripe)->accounts->retrieve(
180                                $accountId,
181                                self::accountRetrieveParams()
182                            );
183                        } 
184                        catch (\Throwable $e) {
185                            $logger->error(
186                                'Exception upgrading Stripe account recipient capability: '.$e->getMessage()
187                            );
188                            $retval = \ApplicationError::Generic;
189                        }
190                        
191                        if (\success($retval) && !self::accountHasRecipient($account)) {
192                            $logger->error(
193                                'Stripe account '.$accountId
194                                .' still lacks recipient after upgrade; keeping STRIPE row.'
195                            );
196                            $retval = \ApplicationError::Generic;
197                        }
198                    }
199                }
200
201                if (\success($retval) && $createNew) {
202                    $logger->info("Creating Stripe Connect account (Accounts v2) for '".$userEmail."'..");
203                    try {
204                        $account = $this->v2Core($stripe)->accounts->create(
205                            self::accountCreateParams((string) $userEmail)
206                        );
207                    } catch (\Throwable $e) {
208                        $logger->error('Exception creating Stripe account: '.$e->getMessage());
209                        $retval = \ApplicationError::Generic;
210                        $account = null;
211                    }
212
213                    if (\success($retval) && $account) {
214                        $accountId = (string) (is_array($account) ? ($account['id'] ?? '') : ($account->id ?? ''));
215                        if ($accountId === '') {
216                            $logger->error('Stripe account create returned empty id.');
217                            $retval = \ApplicationError::Generic;
218                        }
219                    }
220
221                    $stripeObjid = null;
222                    if (\success($retval)) {
223                        $iteminfo = [
224                            'account' => $accountId,
225                            'status' => 'PA',
226                        ];
227                        // ConxHelper::trn() requires an open batch (begin before trn).
228                        $ok = $conx->transactional(function (\ConxHelper $batch) use ($iteminfo, &$stripeObjid): bool {
229                            $tx = $batch->trn('STRIPE');
230                            if ($tx === null) {
231                                return false;
232                            }
233
234                            return (bool) $tx->insert('STRIPE', $iteminfo, $stripeObjid);
235                        });
236                        if (!$ok || $stripeObjid === null || $stripeObjid === '') {
237                            $logger->error('Could not insert STRIPE row for account '.$accountId.'.');
238                            $retval = \ApplicationError::Database;
239                        }
240                    }
241
242                    if (\success($retval) && $stripeObjid !== null && $stripeObjid !== '') {
243                        $this->callPublishStripe($conx, $placeId, $stripeObjid, $logger);
244                    }
245                }
246            }
247
248            if (\success($retval) && $stripe !== null && $accountId !== null && $accountId !== '') {
249                $linkConfigs = self::onboardingConfigurations($account ?? null);
250                $linkResult = $this->createAccountLink(
251                    $conx,
252                    $stripe,
253                    $placeId,
254                    $lang,
255                    $token,
256                    $accountId,
257                    $logger,
258                    $linkConfigs
259                );
260                if (\success($linkResult['errorcode'])) {
261                    $url = $linkResult['url'] ?? null;
262                } else {
263                    $retval = $linkResult['errorcode'];
264                }
265            }
266        } finally {
267            $conx->release();
268        }
269
270        $result = ['errorcode' => $retval];
271        if (\success($retval) && $url !== null && $url !== '') {
272            $result['url'] = $url;
273        }
274
275        return $result;
276    }
277
278    /**
279     * POST /stripe/connectstatus â€” UI poll: re-read Connect account and refresh STRIPE.status.
280     *
281     * @param array<string, mixed> $query device, session, place
282     * @return array{errorcode: int, status?: string}
283     */
284    public function connectStatus(array $query): array
285    {
286        $logger = $this->loggerFactory->for(__FILE__);
287        $conx = $this->newConx();
288        $retval = \ApplicationError::Success;
289        $status = null;
290
291        try {
292            $device = isset($query['device']) ? (string) $query['device'] : '';
293            $session = isset($query['session']) ? (string) $query['session'] : '';
294            $placeId = isset($query['place']) ? (string) $query['place'] : '';
295
296            if ($device === '' || $session === '' || $placeId === '') {
297                $logger->error('Missing device/session/place for Connect status poll.');
298                $retval = \ApplicationError::Parameters;
299            }
300
301            $userid = null;
302            if (\success($retval)) {
303                $retval = $this->sessionService->checkSession($conx, $session, $device, $userid);
304            }
305
306            if (\success($retval)) {
307                $places = [];
308                $res = $conx->global->query('PLACE', ['objid' => $placeId, 'status' => ['PA', 'AC']], $places);
309                if (!$res) {
310                    $retval = \ApplicationError::Database;
311                } elseif (count($places) === 0) {
312                    $logger->error("No active place found with objid '".$placeId."'.");
313                    $retval = \ApplicationError::NotFound;
314                }
315            }
316
317            if (\success($retval)) {
318                $conx->tenant = $placeId;
319                if ($conx->tenant === null) {
320                    $logger->error('Connection to place database failed.');
321                    $retval = \ApplicationError::Database;
322                }
323            }
324
325            $existing = null;
326            if (\success($retval)) {
327                $stripes = [];
328                $res = $conx->tenant->runsql(['STRIPE'], 'SELECT * FROM `STRIPE`;', $stripes);
329                if (!$res) {
330                    $logger->error('Could not list STRIPE rows for place '.$placeId.'.');
331                    $retval = \ApplicationError::Database;
332                } else {
333                    foreach ($stripes as $row) {
334                        if (($row['status'] ?? '') === 'DE') {
335                            continue;
336                        }
337                        $existing = $row;
338                        break;
339                    }
340                    if ($existing === null) {
341                        $logger->error('No active STRIPE row for place '.$placeId.'.');
342                        $retval = \ApplicationError::NotFound;
343                    }
344                }
345            }
346
347            if (\success($retval) && $existing !== null) {
348                $accountId = (string) ($existing['account'] ?? '');
349                $current = (string) ($existing['status'] ?? '');
350                if ($current === 'AC') {
351                    $status = 'AC';
352                } elseif ($accountId === '') {
353                    $logger->error('STRIPE row '.$existing['objid'].' has empty account id.');
354                    $retval = \ApplicationError::Invalid;
355                } else {
356                    $retval = $this->validateAndUpdateAccount($conx, $placeId, $accountId, $logger);
357                    if (\success($retval)) {
358                        $refreshed = [];
359                        $conx->tenant->query('STRIPE', ['account' => $accountId], $refreshed);
360                        $status = isset($refreshed[0]['status'])
361                            ? (string) $refreshed[0]['status']
362                            : $current;
363                    }
364                }
365            }
366        } finally {
367            $conx->release();
368        }
369
370        $result = ['errorcode' => $retval];
371        if (\success($retval) && $status !== null && $status !== '') {
372            $result['status'] = $status;
373        }
374
375        return $result;
376    }
377
378    /**
379     * GET /stripe/on-connect â€” validate account after onboarding; redirect to IonUrl landing.
380     *
381     * @param array<string, mixed> $query lang, place, token, account, auth
382     * @return array{errorcode: int, redirect: string, httpStatus: int}
383     */
384    public function onConnect(array $query): array
385    {
386        $logger = $this->loggerFactory->for(__FILE__);
387        $conx = $this->newConx();
388        $retval = \ApplicationError::Success;
389        $lang = isset($query['lang']) ? (string) $query['lang'] : 'ES';
390        $token = isset($query['token']) ? (string) $query['token'] : '';
391
392        try {
393            $placeId = isset($query['place']) ? (string) $query['place'] : '';
394            $accountId = isset($query['account']) ? (string) $query['account'] : '';
395            $auth = isset($query['auth']) ? (string) $query['auth'] : '';
396
397            if ($lang === '' || $placeId === '' || $token === '' || $accountId === '' || $auth === '') {
398                $logger->error('Missing lang/place/token/account/auth for Connect return.');
399                $retval = \ApplicationError::Parameters;
400            }
401
402            $authItem = null;
403            if (\success($retval)) {
404                $authItem = $this->callUseAuthorization($conx, $auth);
405                if ($authItem === null) {
406                    $logger->error("Authorization key '".$auth."' not found or invalid.");
407                    $retval = \ApplicationError::Unauthorized;
408                }
409            }
410
411            if (\success($retval) && $authItem !== null) {
412                $this->callDelAuthorization($conx, $authItem);
413            }
414
415            if (\success($retval)) {
416                $conx->tenant = $placeId;
417                if ($conx->tenant === null) {
418                    $logger->error('Connection to place database failed.');
419                    $retval = \ApplicationError::Database;
420                }
421            }
422
423            if (\success($retval)) {
424                $retval = $this->validateAndUpdateAccount($conx, $placeId, $accountId, $logger);
425            }
426        } finally {
427            $conx->release();
428        }
429
430        $resultFlag = \success($retval) ? 'SUCCESS' : 'FAILURE';
431        $redirect = \AppConstants::IonUrl().'on-connect'
432            .'?lang='.rawurlencode($lang)
433            .'&token='.rawurlencode($token)
434            .'&result='.$resultFlag;
435
436        $logger->info('Connect return redirect: '.$redirect);
437
438        return [
439            'errorcode' => $retval,
440            'redirect' => $redirect,
441            'httpStatus' => 302,
442        ];
443    }
444
445    /**
446     * GET /stripe/on-refresh â€” regenerate Account Link; redirect to Stripe URL.
447     *
448     * @param array<string, mixed> $query lang, place, token, account, auth
449     * @return array{errorcode: int, redirect?: string, httpStatus: int}
450     */
451    public function onRefresh(array $query): array
452    {
453        $logger = $this->loggerFactory->for(__FILE__);
454        $conx = $this->newConx();
455        $retval = \ApplicationError::Success;
456        $redirect = null;
457
458        try {
459            $lang = isset($query['lang']) ? (string) $query['lang'] : '';
460            $placeId = isset($query['place']) ? (string) $query['place'] : '';
461            $token = isset($query['token']) ? (string) $query['token'] : '';
462            $accountId = isset($query['account']) ? (string) $query['account'] : '';
463            $auth = isset($query['auth']) ? (string) $query['auth'] : '';
464
465            if ($lang === '' || $placeId === '' || $token === '' || $accountId === '' || $auth === '') {
466                $logger->error('Missing lang/place/token/account/auth for Connect refresh.');
467                $retval = \ApplicationError::Parameters;
468            }
469
470            $authItem = null;
471            if (\success($retval)) {
472                $authItem = $this->callUseAuthorization($conx, $auth);
473                if ($authItem === null) {
474                    $logger->error("Authorization key '".$auth."' not found or invalid.");
475                    $retval = \ApplicationError::Unauthorized;
476                }
477            }
478
479            if (\success($retval) && $authItem !== null) {
480                $this->callDelAuthorization($conx, $authItem);
481            }
482
483            if (\success($retval)) {
484                $stripe = $this->createStripeClient();
485                $linkConfigs = ['merchant', 'recipient'];
486                
487                try {
488                    $account = $this->v2Core($stripe)->accounts->retrieve(
489                        $accountId,
490                        self::accountRetrieveParams()
491                    );
492                    $linkConfigs = self::onboardingConfigurations($account);
493                } 
494                catch (\Throwable $e) {
495                    $logger->error(
496                        'Exception retrieving Stripe account for refresh link: '.$e->getMessage()
497                    );
498                }
499                
500                $linkResult = $this->createAccountLink(
501                    $conx,
502                    $stripe,
503                    $placeId,
504                    $lang,
505                    $token,
506                    $accountId,
507                    $logger,
508                    $linkConfigs
509                );
510
511                if (\success($linkResult['errorcode'])) {
512                    $redirect = $linkResult['url'] ?? null;
513                } 
514                else {
515                    $retval = $linkResult['errorcode'];
516                }
517            }
518        } finally {
519            $conx->release();
520        }
521
522        if (!\success($retval) || $redirect === null || $redirect === '') {
523            $redirect = \AppConstants::IonUrl().'on-connect'
524                .'?lang='.rawurlencode($lang !== '' ? $lang : 'ES')
525                .'&token='.rawurlencode($token)
526                .'&result=FAILURE';
527        }
528
529        $logger->info('Connect refresh redirect: '.$redirect);
530
531        return [
532            'errorcode' => $retval,
533            'redirect' => $redirect,
534            'httpStatus' => 302,
535        ];
536    }
537
538    /**
539     * Map Stripe Accounts v2 requirements + recipient capability to STRIPE.status.
540     *
541     * v2: entries with awaiting_action_from=user + currently_due/past_due â†’ PR;
542     *     entries awaiting Stripe â†’ PV; else candidate AC.
543     * v1 fallback: currently_due â†’ PR; pending_verification â†’ PV; else candidate AC.
544     * AC only when recipient `stripe_transfers` capability status is `active`
545     * (destination-charge readiness without a test PaymentIntent).
546     *
547     * @param object|array<string, mixed> $account
548     */
549    public static function statusFromAccount(object|array $account): string
550    {
551        $fromRequirements = self::statusFromRequirements($account);
552        if ($fromRequirements !== 'AC') {
553            return $fromRequirements;
554        }
555
556        // Requirements clear is not enough: destination charges need active transfers.
557        if (!self::accountHasActiveStripeTransfers($account)) {
558            return 'PV';
559        }
560
561        return 'AC';
562    }
563
564    /**
565     * Requirements-only mapping (no capability gate). Used by {@see statusFromAccount}.
566     *
567     * @param object|array<string, mixed> $account
568     */
569    private static function statusFromRequirements(object|array $account): string
570    {
571        $requirements = is_array($account)
572            ? ($account['requirements'] ?? null)
573            : ($account->requirements ?? null);
574
575        if ($requirements === null) {
576            return 'AC';
577        }
578
579        // Legacy Accounts v1 shape (currently_due / pending_verification arrays).
580        $currentlyDue = null;
581        $pendingVerification = null;
582        if (is_object($requirements)) {
583            if (isset($requirements->currently_due) || isset($requirements->pending_verification)) {
584                $currentlyDue = (array) ($requirements->currently_due ?? []);
585                $pendingVerification = (array) ($requirements->pending_verification ?? []);
586            }
587        } elseif (is_array($requirements)) {
588            if (array_key_exists('currently_due', $requirements)
589                || array_key_exists('pending_verification', $requirements)
590            ) {
591                $currentlyDue = (array) ($requirements['currently_due'] ?? []);
592                $pendingVerification = (array) ($requirements['pending_verification'] ?? []);
593            }
594        }
595        if ($currentlyDue !== null) {
596            if (count($currentlyDue) > 0) {
597                return 'PR';
598            }
599            if (count((array) $pendingVerification) > 0) {
600                return 'PV';
601            }
602
603            return 'AC';
604        }
605
606        // Accounts v2: requirements.entries[] + optional summary.minimum_deadline.
607        $entries = [];
608        if (is_object($requirements)) {
609            $entries = (array) ($requirements->entries ?? []);
610        } elseif (is_array($requirements)) {
611            $entries = (array) ($requirements['entries'] ?? []);
612        }
613
614        $userDue = false;
615        $stripePending = false;
616        foreach ($entries as $entry) {
617            $awaiting = is_array($entry)
618                ? (string) ($entry['awaiting_action_from'] ?? '')
619                : (string) ($entry->awaiting_action_from ?? '');
620            $deadline = is_array($entry)
621                ? ($entry['minimum_deadline'] ?? null)
622                : ($entry->minimum_deadline ?? null);
623            $deadlineStatus = '';
624            if (is_array($deadline)) {
625                $deadlineStatus = (string) ($deadline['status'] ?? '');
626            } elseif (is_object($deadline)) {
627                $deadlineStatus = (string) ($deadline->status ?? '');
628            }
629
630            if ($awaiting === 'user'
631                && ($deadlineStatus === 'currently_due' || $deadlineStatus === 'past_due')
632            ) {
633                $userDue = true;
634            }
635            if ($awaiting === 'stripe') {
636                $stripePending = true;
637            }
638        }
639
640        if (!$userDue) {
641            $summary = is_object($requirements)
642                ? ($requirements->summary ?? null)
643                : ($requirements['summary'] ?? null);
644            $summaryDeadline = null;
645            if (is_object($summary)) {
646                $summaryDeadline = $summary->minimum_deadline ?? null;
647            } elseif (is_array($summary)) {
648                $summaryDeadline = $summary['minimum_deadline'] ?? null;
649            }
650            $summaryStatus = '';
651            if (is_array($summaryDeadline)) {
652                $summaryStatus = (string) ($summaryDeadline['status'] ?? '');
653            } elseif (is_object($summaryDeadline)) {
654                $summaryStatus = (string) ($summaryDeadline->status ?? '');
655            }
656            if ($summaryStatus === 'currently_due' || $summaryStatus === 'past_due') {
657                $userDue = true;
658            }
659        }
660
661        if ($userDue) {
662            return 'PR';
663        }
664        if ($stripePending) {
665            return 'PV';
666        }
667
668        return 'AC';
669    }
670
671    /**
672     * Status of recipient stripe_balance.stripe_transfers capability, or null if absent.
673     *
674     * @param object|array<string, mixed>|null $account
675     */
676    public static function recipientStripeTransfersStatus(object|array|null $account): ?string
677    {
678        if ($account === null) {
679            return null;
680        }
681
682        $cfg = is_object($account) ? ($account->configuration ?? null) : ($account['configuration'] ?? null);
683        if ($cfg === null) {
684            return null;
685        }
686
687        $recipient = is_object($cfg) ? ($cfg->recipient ?? null) : ($cfg['recipient'] ?? null);
688        if ($recipient === null) {
689            return null;
690        }
691
692        $capabilities = is_object($recipient)
693            ? ($recipient->capabilities ?? null)
694            : ($recipient['capabilities'] ?? null);
695        if ($capabilities === null) {
696            return null;
697        }
698
699        $balance = is_object($capabilities)
700            ? ($capabilities->stripe_balance ?? null)
701            : ($capabilities['stripe_balance'] ?? null);
702        if ($balance === null) {
703            return null;
704        }
705
706        $transfers = is_object($balance)
707            ? ($balance->stripe_transfers ?? null)
708            : ($balance['stripe_transfers'] ?? null);
709        if ($transfers === null) {
710            return null;
711        }
712
713        $status = is_object($transfers)
714            ? ($transfers->status ?? null)
715            : ($transfers['status'] ?? null);
716
717        return is_string($status) && $status !== '' ? $status : null;
718    }
719
720    /**
721     * True when destination charges can target this Connect account.
722     *
723     * @param object|array<string, mixed>|null $account
724     */
725    public static function accountHasActiveStripeTransfers(object|array|null $account): bool
726    {
727        return self::recipientStripeTransfersStatus($account) === 'active';
728    }
729
730    /**
731     * Standard-equivalent Accounts v2 create payload.
732     * Merchant (card_payments) + recipient (stripe_transfers) so destination
733     * charges in guest ticketCheckout can transfer_data.destination.
734     *
735     * @return array<string, mixed>
736     */
737    public static function accountCreateParams(string $contactEmail): array
738    {
739        return [
740            'contact_email' => $contactEmail,
741            'identity' => [
742                'country' => 'ES',
743            ],
744            'configuration' => [
745                'merchant' => [
746                    'capabilities' => [
747                        'card_payments' => [
748                            'requested' => true,
749                        ],
750                    ],
751                ],
752                'recipient' => self::recipientTransfersConfiguration(),
753            ],
754            'dashboard' => 'full',
755            'defaults' => [
756                'responsibilities' => [
757                    'fees_collector' => 'stripe',
758                    'losses_collector' => 'stripe',
759                ],
760            ],
761            'include' => [
762                'requirements',
763                'configuration.merchant',
764                'configuration.recipient',
765            ],
766        ];
767    }
768
769    /**
770     * Upgrade payload for existing Accounts v2 that were created without recipient.
771     * Required for destination charges (transfer_data.destination).
772     *
773     * @return array<string, mixed>
774     */
775    public static function accountRecipientUpgradeParams(): array
776    {
777        return [
778            'configuration' => [
779                'recipient' => self::recipientTransfersConfiguration(),
780            ],
781            'include' => [
782                'requirements',
783                'configuration.merchant',
784                'configuration.recipient',
785            ],
786        ];
787    }
788
789    /**
790     * @return array{capabilities: array<string, mixed>}
791     */
792    public static function recipientTransfersConfiguration(): array
793    {
794        return [
795            'capabilities' => [
796                'stripe_balance' => [
797                    'stripe_transfers' => [
798                        'requested' => true,
799                    ],
800                ],
801            ],
802        ];
803    }
804
805    /**
806     * @return array{include: list<string>}
807     */
808    public static function accountRetrieveParams(): array
809    {
810        return [
811            'include' => [
812                'requirements',
813                'configuration.merchant',
814                'configuration.recipient',
815            ],
816        ];
817    }
818
819    /**
820     * Publish STRIPE row to _CHANGESLOG after COMMIT.
821     *
822     * @param int|string $stripeId
823     */
824    public static function publishStripeChange(
825        \ConxHelper $conx,
826        string $placeId,
827        int|string $stripeId,
828        LoggerInterface $logger
829    ): void {
830        $updates = [];
831        $change = [
832            'table' => 'STRIPE',
833            'objid' => $stripeId,
834        ];
835        $pushRet = \push_change($conx, $change, $updates);
836        if (\success($pushRet) && count($updates) > 0) {
837            \commit_changes($conx, $placeId, null, $updates);
838        } else {
839            $logger->error('Could not register STRIPE change after Connect onboarding.');
840        }
841    }
842
843    /**
844     * True when Accounts v2 payload includes recipient configuration.
845     *
846     * @param object|array<string, mixed>|null $account
847     */
848    public static function accountHasRecipient(object|array|null $account): bool
849    {
850        if ($account === null) {
851            return false;
852        }
853        $cfg = is_object($account) ? ($account->configuration ?? null) : ($account['configuration'] ?? null);
854        if ($cfg === null) {
855            return false;
856        }
857        if (is_object($cfg)) {
858            return isset($cfg->recipient) && $cfg->recipient !== null;
859        }
860
861        return isset($cfg['recipient']) && $cfg['recipient'] !== null;
862    }
863
864    /**
865     * Account Link configurations must match those applied on the account.
866     *
867     * @param object|array<string, mixed>|null $account
868     * @return list<string>
869     */
870    public static function onboardingConfigurations(object|array|null $account): array
871    {
872        $configs = ['merchant'];
873        if (self::accountHasRecipient($account)) {
874            $configs[] = 'recipient';
875        }
876
877        return $configs;
878    }
879
880    /**
881     * @param list<string> $configurations
882     * @return array{errorcode: int, url?: string}
883     */
884    private function createAccountLink(
885        \ConxHelper $conx,
886        object $stripe,
887        string $placeId,
888        string $lang,
889        string $token,
890        string $accountId,
891        LoggerInterface $logger,
892        array $configurations = ['merchant', 'recipient']
893    ): array {
894        $authorization = $this->newAuthorizationKey($conx);
895        if ($authorization === null) {
896            $logger->error('Could not generate authorization key for Connect callbacks.');
897            return ['errorcode' => \ApplicationError::Unknown];
898        }
899
900        $base = rtrim(\AppConstants::AppUrl(), '/');
901        $common = 'lang='.rawurlencode($lang)
902            .'&place='.rawurlencode($placeId)
903            .'&token='.rawurlencode($token)
904            .'&account='.rawurlencode($accountId)
905            .'&auth='.rawurlencode($authorization);
906        $refreshUrl = $base.'/stripe/on-refresh?'.$common;
907        $returnUrl = $base.'/stripe/on-connect?'.$common;
908
909        if ($configurations === []) {
910            $configurations = ['merchant', 'recipient'];
911        }
912
913        try {
914            $link = $this->v2Core($stripe)->accountLinks->create([
915                'account' => $accountId,
916                'use_case' => [
917                    'type' => 'account_onboarding',
918                    'account_onboarding' => [
919                        'configurations' => $configurations,
920                        'refresh_url' => $refreshUrl,
921                        'return_url' => $returnUrl,
922                    ],
923                ],
924            ]);
925        } catch (\Throwable $e) {
926            $logger->error('Exception creating Stripe account link: '.$e->getMessage());
927            return ['errorcode' => \ApplicationError::Generic];
928        }
929
930        $url = is_array($link) ? ($link['url'] ?? null) : ($link->url ?? null);
931        if ($url === null || $url === '') {
932            $logger->error('Failed obtaining Stripe account link URL.');
933            return ['errorcode' => \ApplicationError::Generic];
934        }
935
936        return ['errorcode' => \ApplicationError::Success, 'url' => (string) $url];
937    }
938
939    private function validateAndUpdateAccount(
940        \ConxHelper $conx,
941        string $placeId,
942        string $accountId,
943        LoggerInterface $logger
944    ): int {
945        $retval = \ApplicationError::Success;
946        $stripes = [];
947        $res = $conx->tenant->query('STRIPE', ['account' => $accountId], $stripes);
948        if (!$res) {
949            return \ApplicationError::Database;
950        }
951        if (count($stripes) === 0) {
952            $logger->error('account ['.$accountId.'] not found for place ['.$placeId.']');
953            return \ApplicationError::NotFound;
954        }
955        $row = $stripes[0];
956
957        try {
958            $account = $this->v2Core($this->createStripeClient())->accounts->retrieve(
959                $accountId,
960                self::accountRetrieveParams()
961            );
962        } catch (\Throwable $e) {
963            $logger->error('Exception retrieving Stripe account: '.$e->getMessage());
964            return \ApplicationError::Generic;
965        }
966        if (!$account) {
967            $logger->error('Failed obtaining Stripe account instance.');
968            return \ApplicationError::Generic;
969        }
970
971        $status = self::statusFromAccount($account);
972        if ((string) ($row['status'] ?? '') === $status) {
973            return $retval;
974        }
975
976        $iteminfo = [
977            'objid' => $row['objid'],
978            'status' => $status,
979        ];
980        $ok = $conx->transactional(function (\ConxHelper $batch) use ($iteminfo): bool {
981            $tx = $batch->trn('STRIPE');
982            if ($tx === null) {
983                return false;
984            }
985
986            return (bool) $tx->update('STRIPE', $iteminfo);
987        });
988        if (!$ok) {
989            $logger->error('Could not update STRIPE status for account '.$accountId.'.');
990            return \ApplicationError::Database;
991        }
992
993        $this->callPublishStripe($conx, $placeId, $row['objid'], $logger);
994
995        return $retval;
996    }
997
998    /**
999     * @param int|string $stripeId
1000     */
1001    private function callPublishStripe(
1002        \ConxHelper $conx,
1003        string $placeId,
1004        int|string $stripeId,
1005        LoggerInterface $logger
1006    ): void {
1007        if ($this->publishStripe !== null) {
1008            ($this->publishStripe)($conx, $placeId, $stripeId, $logger);
1009            return;
1010        }
1011        self::publishStripeChange($conx, $placeId, $stripeId, $logger);
1012    }
1013
1014    private function newConx(): \ConxHelper
1015    {
1016        if ($this->conxFactory !== null) {
1017            return ($this->conxFactory)();
1018        }
1019
1020        return new \ConxHelper(__FILE__);
1021    }
1022
1023    private function createStripeClient(): object
1024    {
1025        if ($this->stripeClientFactory !== null) {
1026            return ($this->stripeClientFactory)();
1027        }
1028
1029        return new \Stripe\StripeClient(\AppConstants::Stripe_APIKEY());
1030    }
1031
1032    /**
1033     * StripeClient.v2.core (Accounts / AccountLinks v2).
1034     */
1035    private function v2Core(object $stripe): object
1036    {
1037        return $stripe->v2->core;
1038    }
1039
1040    private function newAuthorizationKey(\ConxHelper $conx): ?string
1041    {
1042        $key = sprintf(
1043            '%04x%04x%04x%04x',
1044            random_int(0, 0xFFFF),
1045            random_int(0, 0xFFFF),
1046            random_int(0, 0xFFFF),
1047            random_int(0, 0xFFFF)
1048        );
1049        if ($this->callAddAuthorization($conx, $key, null)) {
1050            return $key;
1051        }
1052
1053        return null;
1054    }
1055
1056    private function callAddAuthorization(\ConxHelper $conx, string $auth, ?string $email): bool
1057    {
1058        if ($this->addAuthorization !== null) {
1059            return (bool) ($this->addAuthorization)($conx, $auth, $email);
1060        }
1061
1062        return (bool) add_authorization($conx, $auth, $email);
1063    }
1064
1065    /** @return array<string, mixed>|null */
1066    private function callUseAuthorization(\ConxHelper $conx, string $auth): ?array
1067    {
1068        if ($this->useAuthorization !== null) {
1069            $item = ($this->useAuthorization)($conx, $auth);
1070            return is_array($item) ? $item : null;
1071        }
1072
1073        return use_authorization($conx, $auth);
1074    }
1075
1076    /** @param array<string, mixed> $item */
1077    private function callDelAuthorization(\ConxHelper $conx, array $item): bool
1078    {
1079        if ($this->delAuthorization !== null) {
1080            return (bool) ($this->delAuthorization)($conx, $item);
1081        }
1082
1083        return (bool) del_authorization($conx, $item);
1084    }
1085}