302 lines
12 KiB
PHP
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;
|
|
}
|
|
}
|