Files
2026-10-19 15:22:52 -07:00

302 lines
12 KiB
PHP

<?php
/**
* Thin Square API client mirroring the structure of PayPalClient.
*
* Unlike the PayPal partner model (which acts on a seller's behalf via an
* Auth-Assertion JWT built from the partner credentials), Square uses OAuth:
* each seller authorizes our application and we receive a per-seller
* access_token (30-day) + refresh_token (non-expiring for the code flow).
* Seller API calls are therefore made with that seller's bearer token, NOT the
* application secret.
*/
class SquareClient
{
private string $clientId;
private string $clientSecret;
private string $env; // sandbox|production
private string $connectBase; // https://connect.squareup(sandbox).com
private string $squareVersion;
private array $scopes;
private bool $forceLogin; // if true, send session=false to force a fresh sign-in
private ?string $caBundle; // optional CA bundle path for cURL TLS verification
public function __construct(array $config)
{
$this->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;
}
}