clientId = $config['client_id'] ?? ''; $this->clientSecret = $config['client_secret'] ?? ''; $this->env = $config['env'] ?? 'sandbox'; $this->squareVersion = $config['square_version'] ?? '2026-05-20'; $this->scopes = $config['scopes'] ?? []; $this->forceLogin = !empty($config['oauth_force_login']); $this->caBundle = !empty($config['curl_ca_bundle']) ? $config['curl_ca_bundle'] : null; $this->connectBase = $this->env === 'production' ? 'https://connect.squareup.com' : 'https://connect.squareupsandbox.com'; if (!$this->clientId || !$this->clientSecret) { throw new RuntimeException('Missing Square client_id/client_secret in config.php'); } } /** * cURL TLS options. We always verify the peer/host (never disable it — that * would be insecure and is rejected in production). When a CA bundle path is * configured we point cURL at it, which fixes environments that lack a * system-wide curl.cainfo (e.g. stock Windows PHP). */ private function tlsOpts(): array { $opts = [ CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]; if ($this->caBundle && is_file($this->caBundle)) { $opts[CURLOPT_CAINFO] = $this->caBundle; } return $opts; } /** * Build the seller-facing authorization URL. The seller is redirected here, * logs into THEIR Square account, and approves our requested scopes. * * @param string $state Opaque anti-CSRF token we generate and later verify. * @param string $redirectUri Optional; must match the dashboard registration * if supplied (and must then also be sent to * obtainToken()). */ public function authorizeUrl(string $state, string $redirectUri = ''): string { $params = [ 'client_id' => $this->clientId, 'scope' => implode(' ', $this->scopes), 'state' => $state, ]; // session=false forces a fresh sign-in. Leave it OFF by default: the // Sandbox authorize page can't render a sign-in (you must keep a test // account's Dashboard open instead), so forcing it returns 400. Enable // via config 'oauth_force_login' => true only if you need it in production. if ($this->forceLogin) { $params['session'] = 'false'; } if ($redirectUri !== '') { $params['redirect_uri'] = $redirectUri; } return $this->connectBase . '/oauth2/authorize?' . http_build_query($params); } /** * Exchange an authorization code for an access/refresh token pair. * Called from the OAuth callback. Auth codes expire after 5 minutes. */ public function obtainToken(string $code, string $redirectUri = ''): array { $payload = [ 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret, 'code' => $code, 'grant_type' => 'authorization_code', ]; if ($redirectUri !== '') { $payload['redirect_uri'] = $redirectUri; } return $this->oauth('/oauth2/token', $payload); } /** * Trade a refresh token for a fresh access token (Phase 2 background job). * Code-flow refresh tokens do not expire, but access tokens last 30 days. */ public function refreshToken(string $refreshToken): array { return $this->oauth('/oauth2/token', [ 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret, 'refresh_token' => $refreshToken, 'grant_type' => 'refresh_token', ]); } /** * Revoke a seller's tokens (seller disconnects, or cleanup). Note the * special "Client {secret}" authorization scheme required by this endpoint. * Square rejects a request carrying BOTH access_token and merchant_id * ("provide either 'access_token' or 'merchant_id', not both"), so the * merchant id — which revokes the whole authorization — wins when given. */ public function revokeToken(string $accessToken = '', string $merchantId = ''): array { $payload = ['client_id' => $this->clientId]; if ($merchantId !== '') { $payload['merchant_id'] = $merchantId; } elseif ($accessToken !== '') { $payload['access_token'] = $accessToken; } return $this->request('POST', '/oauth2/revoke', $payload, [ 'Authorization: Client ' . $this->clientSecret, ]); } /** * OAuth token endpoint helper. These calls authenticate with the app * credentials inside the JSON body (no bearer header). */ private function oauth(string $path, array $payload): array { return $this->request('POST', $path, $payload, []); } /** * Authorized Square API call ON BEHALF OF A SELLER using that seller's * access token. This is the Square analogue of PayPalClient::api()'s * Auth-Assertion calls — the token identifies which seller's account the * request acts on, so funds route to that seller. */ public function api(string $method, string $path, string $accessToken, array $payload = null): array { return $this->request($method, $path, $payload, [ 'Authorization: Bearer ' . $accessToken, ]); } /** * Low-level cURL wrapper. Returns ['status'=>int,'body'=>array,'debug_id'=>?string] * to match the shape PayPalClient::api() returns, so calling code reads the same. * Square returns a correlation id in the response body errors, but also exposes * a Square-Version echo; we capture nothing extra beyond status + body here. */ private function request(string $method, string $path, ?array $payload, array $extraHeaders): array { $url = $this->connectBase . $path; $headers = array_merge([ 'Content-Type: application/json', 'Accept: application/json', 'Square-Version: ' . $this->squareVersion, ], $extraHeaders); $ch = curl_init($url); $opts = [ CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => strtoupper($method), ]; if ($payload !== null) { $opts[CURLOPT_POSTFIELDS] = json_encode($payload); } curl_setopt_array($ch, $opts + $this->tlsOpts()); $resp = curl_exec($ch); if ($resp === false) { throw new RuntimeException('Square API request failed: ' . curl_error($ch)); } $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $data = json_decode($resp, true); if ($data === null && $resp !== '' && $resp !== 'null') { $data = ['raw' => $resp]; } // Square reports failures as a body { "errors": [ { detail, code, ... } ] }. $debugId = $data['errors'][0]['detail'] ?? null; if (function_exists('writeLog')) { writeLog('Square API ' . strtoupper($method) . ' ' . $path . ' => HTTP ' . $code . (isset($data['errors']) ? ' errors=' . json_encode($data['errors']) : '')); } return ['status' => $code, 'body' => $data ?? [], 'debug_id' => $debugId]; } /** * Charge a card ON BEHALF OF A SELLER. The $accessToken is that seller's * OAuth token, which is what routes the money into their Square balance; any * app_fee_money in the payload is split off to the platform's account. * * $payload must include at least source_id, idempotency_key, amount_money. * Returns the same ['status','body','debug_id'] shape as api(). */ public function createPayment(string $accessToken, array $payload): array { return $this->api('POST', '/v2/payments', $accessToken, $payload); } /** * Determine whether a connected seller can actually accept card payments. * Mirrors PayPalClient::getSellerStatus(): returns a normalized readiness * summary plus a list of human-readable issues. * * Readiness = the merchant has at least one ACTIVE location whose * capabilities include CREDIT_CARD_PROCESSING. */ public function getSellerStatus(string $accessToken): array { $resp = $this->api('GET', '/v2/locations', $accessToken); $body = $resp['body'] ?? []; $locations = $body['locations'] ?? []; $activeLocationId = null; $canTakeCards = false; $merchantName = null; $country = null; $currency = null; foreach ($locations as $loc) { $merchantName = $merchantName ?? ($loc['business_name'] ?? $loc['name'] ?? null); $country = $country ?? ($loc['country'] ?? null); $currency = $currency ?? ($loc['currency'] ?? null); $isActive = ($loc['status'] ?? '') === 'ACTIVE'; $caps = $loc['capabilities'] ?? []; $hasCardProc = in_array('CREDIT_CARD_PROCESSING', $caps, true); if ($isActive && $activeLocationId === null) { $activeLocationId = $loc['id'] ?? null; } if ($isActive && $hasCardProc) { $canTakeCards = true; $activeLocationId = $loc['id'] ?? $activeLocationId; } } // An ACTIVE location is enough to render checkout. CREDIT_CARD_PROCESSING // is preferred, but some verified sellers omit it from capabilities. if (!$canTakeCards && $activeLocationId !== null) { $canTakeCards = true; } $issues = []; if ($resp['status'] < 200 || $resp['status'] >= 300) { $issues[] = 'Could not read locations (HTTP ' . $resp['status'] . ') — token may be invalid or missing MERCHANT_PROFILE_READ scope.'; } elseif (empty($locations)) { $issues[] = 'Seller has no Square locations yet.'; } else { if ($activeLocationId === null) { $issues[] = 'Seller has no ACTIVE location.'; } if (!$canTakeCards) { $issues[] = 'No location has CREDIT_CARD_PROCESSING — seller cannot accept card payments yet (account may need verification).'; } } return [ 'status' => $resp['status'], 'square_ready' => $canTakeCards, 'can_take_cards' => $canTakeCards, 'active_location_id' => $activeLocationId, 'merchant_name' => $merchantName, 'country' => $country, 'currency' => $currency, 'location_count' => count($locations), 'issues' => $issues, ]; } public function getConnectBase(): string { return $this->connectBase; } }