`schools` column. * The token columns (merchant id, access/refresh, expiry) are handled * directly by saveConnection / get and are not part of this map. */ private const META_COLUMNS = [ 'merchant_id' => 'square_merchant_id', 'merchant_key' => 'square_merchant_key', 'active_location_id' => 'square_location_id', 'currency' => 'square_currency', 'country' => 'square_country', 'merchant_name' => 'square_merchant_name', 'square_ready' => 'square_ready', 'status_issues' => 'square_status_issues', 'allowed_origins' => 'square_allowed_origins', 'status_checked_at' => 'square_status_checked_at', 'refreshed_at' => 'square_refreshed_at', 'refresh_failed_at' => 'square_refresh_failed_at', 'refresh_error' => 'square_refresh_error', 'authorization_revoked' => 'square_authorization_revoked', 'revoked_at' => 'square_revoked_at', 'seeded_for_testing' => 'square_seeded_for_testing', ]; public function __construct(array $config) { $this->config = $config; $this->key = $config['token_encryption_key'] ?? ''; $this->bootstrapDb(); } // ── Low-level helpers ──────────────────────────────────────────────────── /** * Make the host application's procedural db_* layer available. In production * these pages run under the hotlunches app where it is already loaded; when a * caller (e.g. a public endpoint or a bare cron) hasn't loaded it yet, we * include it from the host's include/ directory in the same order onboard.php * does, so database.php has whatever it needs to open the shared connection. * No credentials are read from this folder. */ private function bootstrapDb(): void { if (function_exists('db_query')) { return; } $incDir = null; foreach ([__DIR__ . '/../../include', __DIR__ . '/../include'] as $dir) { if (is_file($dir . '/database.php')) { $incDir = $dir; break; } } if ($incDir === null) { return; } foreach (['logging-functions.php', 'config.php', 'database.php'] as $f) { if (is_file($incDir . '/' . $f)) { require_once $incDir . '/' . $f; } } } /** Is the host db_* layer available? Reads/writes degrade safely when not. */ private function dbReady(): bool { return function_exists('db_query'); } private function log(string $msg): void { if (function_exists('writeLog')) { writeLog($msg); } } /** Decrypt a stored token blob to plaintext. */ public function decrypt(string $stored): string { return $stored === '' ? '' : Secret::open($stored, $this->key); } /** Build the SQL literal for one metadata column from its caller-facing value. */ private function metaSql(string $key, $val): string { switch ($key) { case 'square_ready': case 'authorization_revoked': case 'seeded_for_testing': return $val ? '1' : '0'; case 'status_checked_at': case 'refreshed_at': case 'refresh_failed_at': case 'revoked_at': return ($val === null || $val === '') ? 'NULL' : (string)(int)$val; case 'status_issues': case 'allowed_origins': $list = is_array($val) ? $val : (($val === null || $val === '') ? [] : [$val]); $joined = implode("\n", array_map('strval', $list)); return "'" . db_escape_string($joined) . "'"; default: return ($val === null) ? 'NULL' : "'" . db_escape_string((string)$val) . "'"; } } /** Turn a fetched `schools` row into the metadata array callers expect. */ private function rowToMeta(array $row): array { $meta = []; foreach (self::META_COLUMNS as $key => $col) { if (!array_key_exists($col, $row)) { continue; } $val = $row[$col]; switch ($key) { case 'square_ready': case 'authorization_revoked': case 'seeded_for_testing': if ($val !== null) { $meta[$key] = (bool)(int)$val; } break; case 'status_issues': case 'allowed_origins': $meta[$key] = ($val === null || $val === '') ? [] : array_values(array_filter(explode("\n", (string)$val), static fn($v) => $v !== '')); break; case 'status_checked_at': case 'refreshed_at': case 'refresh_failed_at': case 'revoked_at': if ($val !== null && $val !== '') { $meta[$key] = (int)$val; } break; default: if ($val !== null && $val !== '') { $meta[$key] = $val; } } } return $meta; } // ── Reads ──────────────────────────────────────────────────────────────── /** * Connection record for one school. Tokens in the result are still the * ENCRYPTED blobs (use decrypt() / ensureFreshToken() for usable tokens). */ public function get(string $school): ?array { if (!$this->dbReady()) { return null; } $s = db_escape_string($school); $r = db_query( "SELECT square_merchant_id, square_access_token, square_refresh_token, square_token_expires_at FROM schools WHERE school_name = '$s' AND square_merchant_id IS NOT NULL LIMIT 1" ); if (!$r || db_num_rows($r) === 0) { return null; } $row = db_fetch_assoc($r); if (empty($row['square_merchant_id'])) { return null; } return [ 'school' => $school, 'record_num' => null, 'merchant_id' => $row['square_merchant_id'], 'access_token' => $row['square_access_token'] ?? '', 'refresh_token' => $row['square_refresh_token'] ?? '', 'expires_at' => $row['square_token_expires_at'], 'source' => 'db', ]; } /** * Connection record keyed by unique schools.record_num. Prefer this over * get($name) whenever the numeric id is known — school names are not unique. */ public function getByNum(int $recordNum): ?array { if (!$this->dbReady() || $recordNum <= 0) { return null; } $n = (int)$recordNum; $r = db_query( "SELECT record_num, school_name, square_merchant_id, square_access_token, square_refresh_token, square_token_expires_at FROM schools WHERE record_num = $n AND square_merchant_id IS NOT NULL LIMIT 1" ); if (!$r || db_num_rows($r) === 0) { return null; } $row = db_fetch_assoc($r); if (empty($row['square_merchant_id'])) { return null; } return [ 'school' => (string)($row['school_name'] ?? ''), 'record_num' => (int)$row['record_num'], 'merchant_id' => $row['square_merchant_id'], 'access_token' => $row['square_access_token'] ?? '', 'refresh_token' => $row['square_refresh_token'] ?? '', 'expires_at' => $row['square_token_expires_at'], 'source' => 'db', ]; } /** * Non-secret metadata for a school (square_ready, active_location_id, * currency, merchant_name, merchant_key, status_issues, allowed_origins, …), * plus merchant_id. Tokens are never included. Safe for the payment frontend. */ public function getMeta(string $school): array { if (!$this->dbReady()) { return []; } $s = db_escape_string($school); $cols = implode(', ', array_values(self::META_COLUMNS)); $r = db_query("SELECT $cols FROM schools WHERE school_name = '$s' LIMIT 1"); if (!$r || db_num_rows($r) === 0) { return []; } return $this->rowToMeta(db_fetch_assoc($r)); } /** Same as getMeta() but keyed by unique schools.record_num. */ public function getMetaByNum(int $recordNum): array { if (!$this->dbReady() || $recordNum <= 0) { return []; } $n = (int)$recordNum; $cols = implode(', ', array_values(self::META_COLUMNS)); $r = db_query("SELECT $cols FROM schools WHERE record_num = $n LIMIT 1"); if (!$r || db_num_rows($r) === 0) { return []; } return $this->rowToMeta(db_fetch_assoc($r)); } /** * Resolve the seller's location_id for the Web Payments SDK. Uses the cached * value; if it's missing it fetches locations live with a valid token and * caches the result. Returns null if the school isn't connected or has no * usable location. Also caches currency so the frontend can display it. * * Pass $recordNum when known so a duplicate school name cannot pick the * wrong location / token. */ public function locationId(string $school, SquareClient $client, ?int $recordNum = null): ?string { $num = ($recordNum !== null && $recordNum > 0) ? (int)$recordNum : 0; $meta = $num > 0 ? $this->getMetaByNum($num) : $this->getMeta($school); if (!empty($meta['active_location_id'])) { return $meta['active_location_id']; } $token = $this->ensureFreshToken($school, $client, $num > 0 ? ['school_num' => $num] : [])['access_token'] ?? null; if (!$token) { return null; } $status = $client->getSellerStatus($token); if (!empty($status['active_location_id'])) { $patch = [ 'active_location_id' => $status['active_location_id'], 'currency' => $status['currency'], 'square_ready' => $status['square_ready'], 'status_checked_at' => time(), ]; if ($num > 0) { $this->mergeMetaByNum($num, $patch); } else { $this->mergeMeta($school, $patch); } return $status['active_location_id']; } return null; } /** * THE rule for showing the "Pay with Square" button: the school completed * OAuth (merchant id + live access token), has not revoked us, and Square * reported an ACTIVE location to take payments at. Everything that decides * whether to offer Square (button, checkout, charge) goes through here. * * When the school is connected but not yet ready (e.g. the seller had no * location at onboarding time) and a client is supplied, Square is re-checked * at most every 10 minutes so the button appears on its own once the seller * finishes setting up their Square account. */ public function isOnboarded(int $recordNum, ?SquareClient $client = null): bool { if (!$this->dbReady() || $recordNum <= 0) { return false; } $n = (int)$recordNum; $r = db_query( "SELECT square_merchant_id, square_access_token, square_location_id, square_ready, square_authorization_revoked, square_status_checked_at FROM schools WHERE record_num = $n LIMIT 1" ); if (!$r || db_num_rows($r) === 0) { return false; } $row = db_fetch_assoc($r); $connected = !empty($row['square_merchant_id']) && !empty($row['square_access_token']) && empty($row['square_authorization_revoked']); if (!$connected) { return false; } if (!empty($row['square_ready']) && !empty($row['square_location_id'])) { return true; } if ($client === null || (time() - (int)($row['square_status_checked_at'] ?? 0)) < 600) { return false; } try { $token = $this->ensureFreshToken('', $client, ['school_num' => $n])['access_token'] ?? null; if (!$token) { return false; } $status = $client->getSellerStatus($token); $this->mergeMetaByNum($n, [ 'square_ready' => $status['square_ready'] && !empty($status['active_location_id']), 'active_location_id' => $status['active_location_id'], 'merchant_name' => $status['merchant_name'], 'currency' => $status['currency'], 'country' => $status['country'], 'status_issues' => $status['issues'], 'status_checked_at' => time(), ]); return $status['square_ready'] && !empty($status['active_location_id']); } catch (Throwable $e) { $this->mergeMetaByNum($n, ['status_checked_at' => time()]); $this->log('MerchantStore::isOnboarded live check failed for record_num ' . $n . ': ' . $e->getMessage()); return false; } } /** * Connected schools for the refresh cron, keyed by unique record_num (names * are not unique, so iterating by name would skip same-named schools). * * @return array record_num => school_name */ public function connectedSchoolNums(): array { if (!$this->dbReady()) { return []; } $out = []; $r = db_query( "SELECT record_num, school_name FROM schools WHERE square_merchant_id IS NOT NULL AND square_refresh_token IS NOT NULL AND square_refresh_token <> ''" ); if ($r) { while ($row = db_fetch_assoc($r)) { $out[(int)$row['record_num']] = (string)$row['school_name']; } } return $out; } /** All connected school names (those with a refresh token to maintain). */ public function connectedSchools(): array { if (!$this->dbReady()) { return []; } $names = []; $r = db_query( "SELECT school_name FROM schools WHERE square_merchant_id IS NOT NULL AND square_refresh_token IS NOT NULL AND square_refresh_token <> ''" ); if ($r) { while ($row = db_fetch_assoc($r)) { $names[] = $row['school_name']; } } return $names; } /** * All school names that have a Square connection (a merchant_id), regardless * of whether they have a refresh token. Use this for "is this school * connected?" listings; use connectedSchools() for the refresh cron. */ public function merchantSchools(): array { if (!$this->dbReady()) { return []; } $names = []; $r = db_query("SELECT school_name FROM schools WHERE square_merchant_id IS NOT NULL"); if ($r) { while ($row = db_fetch_assoc($r)) { $names[] = $row['school_name']; } } return $names; } /** * Return the school's stable, PUBLIC embed key, generating + persisting one * on first use. This key (not the school name) is what an external website's * embed snippet references, so the name can change and remain unguessable. */ public function merchantKey(string $school): string { $meta = $this->getMeta($school); if (!empty($meta['merchant_key'])) { return (string)$meta['merchant_key']; } $key = 'sqpk_' . bin2hex(random_bytes(16)); $this->mergeMeta($school, ['merchant_key' => $key]); return $key; } /** * Resolve the school owning a public embed key (unique). Returns * ['school_name', 'record_num'] or null. */ public function findByKey(string $key): ?array { $key = trim($key); if ($key === '' || !$this->dbReady()) { return null; } $k = db_escape_string($key); $r = db_query("SELECT school_name, record_num FROM schools WHERE square_merchant_key = '$k' LIMIT 1"); if ($r && db_num_rows($r) > 0) { $row = db_fetch_assoc($r); return [ 'school_name' => (string)($row['school_name'] ?? ''), 'record_num' => (int)($row['record_num'] ?? 0), ]; } return null; } /** Resolve the school name owning a given public embed key. */ public function findSchoolByKey(string $key): ?string { $found = $this->findByKey($key); if ($found === null || $found['school_name'] === '') { return null; } return $found['school_name']; } /** * Origins (scheme://host[:port]) a school has authorized to embed its button. * Empty means "not yet restricted" — order-api.php treats that per the config * 'enforce_embed_origins' flag. */ public function allowedOrigins(string $school): array { $list = $this->getMeta($school)['allowed_origins'] ?? []; return is_array($list) ? array_values(array_filter(array_map('strval', $list))) : []; } /** Add an origin to a school's embed allowlist (idempotent). */ public function addAllowedOrigin(string $school, string $origin): void { $origin = trim($origin); if ($origin === '') { return; } $list = $this->allowedOrigins($school); if (!in_array($origin, $list, true)) { $list[] = $origin; $this->mergeMeta($school, ['allowed_origins' => $list]); } } /** * Resolve a school's numeric id from its name. The host `schools` table's * primary key is `record_num` (there is no `school_num` column on it), so all * numeric-id lookups use record_num. Returns null when the db layer is * unavailable or the school is unknown. NOTE: school names are not unique, so * this resolves to the first match — prefer the explicit record_num when known. */ public function schoolNum(string $school): ?int { if (!$this->dbReady() || $school === '') { return null; } $s = db_escape_string($school); $r = db_query("SELECT record_num FROM schools WHERE school_name = '$s' LIMIT 1"); if (!$r || db_num_rows($r) === 0) { return null; } $row = db_fetch_assoc($r); return ($row['record_num'] === null || $row['record_num'] === '') ? null : (int)$row['record_num']; } /** * Resolve a school's name from its numeric id (schools.record_num). Returns * null when the db layer is unavailable or no such school exists. Used by the * OAuth callback to drive the name-keyed reads/embed once the exact school is * known by its unique record_num. */ public function schoolNameByNum(int $recordNum): ?string { if (!$this->dbReady() || $recordNum <= 0) { return null; } $n = (int)$recordNum; $r = db_query("SELECT school_name FROM schools WHERE record_num = $n LIMIT 1"); if (!$r || db_num_rows($r) === 0) { return null; } $row = db_fetch_assoc($r); return ($row['school_name'] === null || $row['school_name'] === '') ? null : (string)$row['school_name']; } /** * Build a quoted MySQL DATETIME literal (UTC) from an arbitrary date string, * or the literal NULL when empty/unparseable. Square returns token expiries as * ISO-8601 (e.g. 2026-07-27T19:00:00Z); the `T`/`Z` form is rejected by a * DATETIME column under strict SQL mode, which would abort the INSERT/UPDATE. */ private function sqlDateTime(?string $value): string { if ($value === null || $value === '') { return 'NULL'; } $ts = strtotime($value); if ($ts === false) { return 'NULL'; } return "'" . gmdate('Y-m-d H:i:s', $ts) . "'"; } /** Find the school name owning a given Square merchant_id. */ public function findSchoolByMerchantId(string $merchantId): ?string { if (!$this->dbReady()) { return null; } $m = db_escape_string($merchantId); $r = db_query("SELECT school_name FROM schools WHERE square_merchant_id = '$m' LIMIT 1"); if ($r && db_num_rows($r) > 0) { $row = db_fetch_assoc($r); return $row['school_name']; } return null; } // ── Writes ─────────────────────────────────────────────────────────────── /** * Persist a freshly obtained connection (called from the OAuth callback). * Returns true on success. Returns false when the db layer is unavailable or * no `schools` row matches the given name (so onboarding can surface it). * Extra metadata (readiness, location id, …) is merged later via mergeMeta(). */ public function saveConnection(string $school, string $merchantId, string $accessTokenPlain, string $refreshTokenPlain, ?string $expiresAt): bool { if (!$this->dbReady()) { $this->log('MerchantStore::saveConnection — db layer unavailable, cannot persist ' . $school); return false; } $s = db_escape_string($school); // The connection attaches to an existing school row (keyed by name, as // the rest of the integration is). If there's no such row, report it. $check = db_query("SELECT record_num FROM schools WHERE school_name = '$s' LIMIT 1"); if (!$check || db_num_rows($check) === 0) { $this->log('MerchantStore::saveConnection — no schools row named "' . $school . '"'); return false; } $encAccess = Secret::seal($accessTokenPlain, $this->key); $encRefresh = $refreshTokenPlain !== '' ? Secret::seal($refreshTokenPlain, $this->key) : ''; $mid = db_escape_string($merchantId); $ea = "'" . db_escape_string($encAccess) . "'"; $er = $encRefresh !== '' ? "'" . db_escape_string($encRefresh) . "'" : 'NULL'; $exp = $this->sqlDateTime($expiresAt); // ISO-8601 → MySQL DATETIME (or NULL) $ok = db_query( "UPDATE schools SET square_merchant_id = '$mid', square_access_token = $ea, square_refresh_token = $er, square_token_expires_at = $exp, square_connected_at = NOW(), square_ready = 1, square_authorization_revoked = 0, square_revoked_at = NULL WHERE school_name = '$s'" ); $this->log('MerchantStore::saveConnection ' . $school . ' => ' . $merchantId . ' (' . ($ok !== false ? 'ok' : 'FAILED') . ')'); return $ok !== false; } /** * Same as saveConnection() but keyed by the unique numeric id (schools.record_num) * instead of the (non-unique) school name. The OAuth callback uses this so * duplicate school names can never cause the connection to be written to the * wrong row (or to several rows at once). */ public function saveConnectionByNum(int $recordNum, string $merchantId, string $accessTokenPlain, string $refreshTokenPlain, ?string $expiresAt): bool { if (!$this->dbReady()) { $this->log('MerchantStore::saveConnectionByNum — db layer unavailable, cannot persist record_num ' . $recordNum); return false; } $n = (int)$recordNum; $check = db_query("SELECT record_num FROM schools WHERE record_num = $n LIMIT 1"); if (!$check || db_num_rows($check) === 0) { $this->log('MerchantStore::saveConnectionByNum — no schools row with record_num ' . $n); return false; } $encAccess = Secret::seal($accessTokenPlain, $this->key); $encRefresh = $refreshTokenPlain !== '' ? Secret::seal($refreshTokenPlain, $this->key) : ''; $mid = db_escape_string($merchantId); $ea = "'" . db_escape_string($encAccess) . "'"; $er = $encRefresh !== '' ? "'" . db_escape_string($encRefresh) . "'" : 'NULL'; $exp = $this->sqlDateTime($expiresAt); // ISO-8601 → MySQL DATETIME (or NULL) $ok = db_query( "UPDATE schools SET square_merchant_id = '$mid', square_access_token = $ea, square_refresh_token = $er, square_token_expires_at = $exp, square_connected_at = NOW(), square_ready = 0, -- set by the readiness check that follows square_location_id = NULL, -- never keep a previous account's location square_authorization_revoked = 0, square_revoked_at = NULL WHERE record_num = $n" ); $this->log('MerchantStore::saveConnectionByNum ' . $n . ' => ' . $merchantId . ' (' . ($ok !== false ? 'ok' : 'FAILED') . ')'); return $ok !== false; } /** * Record a completed Square payment in the `square_payments` table. * * The school is stored by its numeric id (schools.record_num, written to the * square_payments.school_num column), not by name — resolved from the * caller-supplied 'school' name (or taken directly from 'school_num' if * provided). Keyed by the Square payment id so a retried idempotent charge * updates its row rather than duplicating it. Never stores card data — only * ids/amounts/status. */ public function recordPayment(array $row): void { if (!$this->dbReady()) { $this->log('MerchantStore::recordPayment — db layer unavailable; payment ' . ($row['payment_id'] ?? '?') . ' NOT recorded'); return; } $paymentId = (string)($row['payment_id'] ?? ('noid-' . bin2hex(random_bytes(6)))); $schoolNum = array_key_exists('school_num', $row) ? $row['school_num'] : $this->schoolNum((string)($row['school'] ?? '')); $merchantId = (string)($row['merchant_id'] ?? ''); $amountCents = (int)($row['amount_cents'] ?? 0); $currency = (string)($row['currency'] ?? ''); $appFeeCents = (int)($row['app_fee_cents'] ?? 0); $status = (string)($row['status'] ?? ''); $srcSuffix = (string)($row['source_id_suffix'] ?? ''); $recordedAt = (int)($row['recorded_at'] ?? time()); $pid = db_escape_string($paymentId); $sn = ($schoolNum === null || $schoolNum === '') ? 'NULL' : (string)(int)$schoolNum; $mid = db_escape_string($merchantId); $cur = db_escape_string($currency); $st = db_escape_string($status); $suf = db_escape_string($srcSuffix); $existing = db_query("SELECT record_num FROM square_payments WHERE payment_id = '$pid' LIMIT 1"); if ($existing && db_num_rows($existing) > 0) { db_query( "UPDATE square_payments SET school_num = $sn, merchant_id = '$mid', amount_cents = $amountCents, currency = '$cur', app_fee_cents = $appFeeCents, status = '$st', source_id_suffix = '$suf', recorded_at = $recordedAt WHERE payment_id = '$pid'" ); return; } db_query( "INSERT INTO square_payments (payment_id, school_num, merchant_id, amount_cents, currency, app_fee_cents, status, source_id_suffix, recorded_at) VALUES ('$pid', $sn, '$mid', $amountCents, '$cur', $appFeeCents, '$st', '$suf', $recordedAt)" ); } /** Merge non-secret metadata (readiness, location id, currency, …) into `schools`. */ public function mergeMeta(string $school, array $meta): void { if (!$this->dbReady() || empty($meta)) { return; } $sets = []; foreach ($meta as $key => $val) { if (!isset(self::META_COLUMNS[$key])) { continue; // ignore keys with no column (e.g. connected_at handled elsewhere) } $sets[] = self::META_COLUMNS[$key] . ' = ' . $this->metaSql($key, $val); } if (empty($sets)) { return; } $s = db_escape_string($school); db_query("UPDATE schools SET " . implode(', ', $sets) . " WHERE school_name = '$s'"); } /** Merge non-secret metadata keyed by the unique id (schools.record_num). */ public function mergeMetaByNum(int $recordNum, array $meta): void { if (!$this->dbReady() || empty($meta) || $recordNum <= 0) { return; } $sets = []; foreach ($meta as $key => $val) { if (!isset(self::META_COLUMNS[$key])) { continue; } $sets[] = self::META_COLUMNS[$key] . ' = ' . $this->metaSql($key, $val); } if (empty($sets)) { return; } $n = (int)$recordNum; db_query("UPDATE schools SET " . implode(', ', $sets) . " WHERE record_num = $n"); } /** Store a refreshed access token (and rotated refresh token, if Square sent one). */ private function applyRefreshResult(string $school, string $accessTokenPlain, ?string $refreshTokenPlain, ?string $expiresAt, int $recordNum = 0): void { if (!$this->dbReady()) { return; } $encAccess = Secret::seal($accessTokenPlain, $this->key); $encRefresh = ($refreshTokenPlain !== null && $refreshTokenPlain !== '') ? Secret::seal($refreshTokenPlain, $this->key) : null; $ea = "'" . db_escape_string($encAccess) . "'"; $exp = $this->sqlDateTime($expiresAt); // ISO-8601 → MySQL DATETIME (or NULL) $now = time(); $refreshSet = ''; if ($encRefresh !== null) { $refreshSet = "square_refresh_token = '" . db_escape_string($encRefresh) . "', "; } $where = $this->schoolWhere($school, $recordNum); if ($where === null) { return; } db_query( "UPDATE schools SET square_access_token = $ea, {$refreshSet}square_token_expires_at = $exp, square_refreshed_at = $now, square_refresh_failed_at = NULL, square_refresh_error = NULL WHERE $where" ); } /** Record a non-fatal refresh failure (for visibility; does not delete tokens). */ private function noteRefreshFailure(string $school, string $detail, int $recordNum = 0): void { if (!$this->dbReady()) { return; } $d = db_escape_string($detail); $now = time(); $where = $this->schoolWhere($school, $recordNum); if ($where === null) { return; } db_query("UPDATE schools SET square_refresh_failed_at = $now, square_refresh_error = '$d' WHERE $where"); } /** SQL WHERE for a school row: record_num wins when known (names are not unique). */ private function schoolWhere(string $school, int $recordNum = 0): ?string { if ($recordNum > 0) { return 'record_num = ' . (int)$recordNum; } if ($school === '') { return null; } return "school_name = '" . db_escape_string($school) . "'"; } /** * Fully remove a school's connection (used by our own "disconnect" action, * after we've revoked the token at Square). Keeps the school row, clears the * Square columns. Keyed by record_num so a same-named school is untouched. */ public function removeConnectionByNum(int $recordNum): void { if (!$this->dbReady() || $recordNum <= 0) { return; } $n = (int)$recordNum; db_query( "UPDATE schools SET square_merchant_id = NULL, square_access_token = NULL, square_refresh_token = NULL, square_token_expires_at = NULL, square_connected_at = NULL, square_merchant_key = NULL, square_location_id = NULL, square_currency = NULL, square_country = NULL, square_merchant_name = NULL, square_ready = 0, square_status_issues = NULL, square_allowed_origins = NULL, square_status_checked_at = NULL, square_refreshed_at = NULL, square_refresh_failed_at = NULL, square_refresh_error = NULL, square_authorization_revoked = 0, square_revoked_at = NULL, square_seeded_for_testing = 0 WHERE record_num = $n" ); } /** * Handle an inbound revocation (seller removed our app on Square's side, via * the oauth.authorization.revoked webhook). The tokens are already dead at * Square, so we clear them and flag the record. We keep the merchant_id for * audit / easy re-onboarding matching. * * @return ?string the school name we updated, or null if unknown merchant. */ public function markAuthorizationRevoked(string $merchantId): ?string { $school = $this->findSchoolByMerchantId($merchantId); if ($school === null) { $this->log('MerchantStore::markAuthorizationRevoked — unknown merchant_id=' . $merchantId); return null; } if (!$this->dbReady()) { return $school; } $m = db_escape_string($merchantId); $now = time(); $issue = db_escape_string('Authorization revoked — the school removed access on their Square account. They must reconnect before payments can resume.'); // Null the tokens (blocks any payment attempt) but keep merchant_id. db_query( "UPDATE schools SET square_access_token = NULL, square_refresh_token = NULL, square_token_expires_at = NULL, square_ready = 0, square_authorization_revoked = 1, square_revoked_at = $now, square_status_issues = '$issue' WHERE square_merchant_id = '$m'" ); $this->log('MerchantStore::markAuthorizationRevoked ' . $school); return $school; } // ── Pending OAuth onboarding (replaces data/pending_onboarding.json) ─────── /** * Record a new pending OAuth attempt keyed by its anti-CSRF state token. * Prunes stale attempts first (state hygiene). */ public function createPending(string $state, int $schoolNum): void { if (!$this->dbReady()) { return; } $this->prunePending(); $st = db_escape_string($state); $sn = (int)$schoolNum; $now = time(); // Keyed by school_num (unique) rather than school name, which is NOT unique // across clients — the callback must reattach to the exact school that began // onboarding. db_query( "INSERT INTO square_pending_onboarding (state, school_num, status, created_at) VALUES ('$st', $sn, 'pending', $now) ON DUPLICATE KEY UPDATE school_num = $sn, status = 'pending', created_at = $now" ); } /** * Look up a pending attempt by its state token (CSRF validation). Only a * still-unused attempt started within the last 15 minutes is returned, so a * state value can never be replayed later to swap in another Square account. * The caller must mark it used (updatePending) as soon as it accepts it. */ public function getPending(string $state): ?array { if (!$this->dbReady() || $state === '') { return null; } $st = db_escape_string($state); $cutoff = time() - 900; $r = db_query( "SELECT state, school_num, status, merchant_id, created_at, connected_at, last_callback FROM square_pending_onboarding WHERE state = '$st' AND status = 'pending' AND created_at >= $cutoff LIMIT 1" ); if (!$r || db_num_rows($r) === 0) { return null; } return db_fetch_assoc($r); } /** * Update a pending attempt's status and (optionally) merchant_id / * connected_at / last_callback. */ public function updatePending(string $state, string $status, array $extra = []): void { if (!$this->dbReady()) { return; } $st = db_escape_string($state); $sets = ["status = '" . db_escape_string($status) . "'"]; if (array_key_exists('merchant_id', $extra)) { $sets[] = "merchant_id = '" . db_escape_string((string)$extra['merchant_id']) . "'"; } if (array_key_exists('connected_at', $extra)) { $sets[] = 'connected_at = ' . (int)$extra['connected_at']; } if (array_key_exists('last_callback', $extra)) { $sets[] = 'last_callback = ' . (int)$extra['last_callback']; } db_query("UPDATE square_pending_onboarding SET " . implode(', ', $sets) . " WHERE state = '$st'"); } /** Drop pending OAuth attempts older than 1 hour. */ public function prunePending(): void { if (!$this->dbReady()) { return; } $cutoff = time() - 3600; db_query("DELETE FROM square_pending_onboarding WHERE status = 'pending' AND created_at < $cutoff"); } // ── Refresh orchestration ──────────────────────────────────────────────── /** * Return a usable (decrypted, non-expired) access token for a school, * refreshing it via the refresh token when it is within $threshold_days of * expiry. This is the single path used by both the lazy web lookup * (helper.php) and the CLI cron sweep (refresh_tokens.php). * * @param array $opts threshold_days(int, default 3), force(bool), dry_run(bool) * @return array { * school, action: not_connected|no_refresh_token|skipped|would_refresh|refreshed|failed, * access_token: ?string (plaintext, usable), expires_at: ?string, detail: ?string * } */ public function ensureFreshToken(string $school, SquareClient $client, array $opts = []): array { $thresholdDays = (int)($opts['threshold_days'] ?? 3); $force = !empty($opts['force']); $dryRun = !empty($opts['dry_run']); $recordNum = (int)($opts['school_num'] ?? 0); $rec = $recordNum > 0 ? $this->getByNum($recordNum) : $this->get($school); if ($rec && $school === '') { $school = (string)($rec['school'] ?? ''); } if ($rec && $recordNum <= 0 && !empty($rec['record_num'])) { $recordNum = (int)$rec['record_num']; } if (!$rec || empty($rec['access_token'])) { return ['school' => $school, 'action' => 'not_connected', 'access_token' => null, 'expires_at' => null]; } $access = $this->decrypt($rec['access_token']); // Stored as UTC by sqlDateTime(); read it back as UTC regardless of server timezone. $expTs = $rec['expires_at'] ? (int)strtotime((string)$rec['expires_at'] . ' UTC') : 0; if (empty($rec['refresh_token'])) { return ['school' => $school, 'action' => 'no_refresh_token', 'access_token' => $access, 'expires_at' => $rec['expires_at']]; } // Due when forced, when unknown expiry, or when within the threshold window. $due = $force || $expTs === 0 || $expTs < (time() + $thresholdDays * 86400); if (!$due) { return ['school' => $school, 'action' => 'skipped', 'access_token' => $access, 'expires_at' => $rec['expires_at']]; } if ($dryRun) { return ['school' => $school, 'action' => 'would_refresh', 'access_token' => $access, 'expires_at' => $rec['expires_at']]; } try { $refresh = $this->decrypt($rec['refresh_token']); $resp = $client->refreshToken($refresh); if ($resp['status'] >= 200 && $resp['status'] < 300 && !empty($resp['body']['access_token'])) { $newAccess = $resp['body']['access_token']; $newRefresh = $resp['body']['refresh_token'] ?? null; // usually absent for code flow $newExpiry = $resp['body']['expires_at'] ?? null; $this->applyRefreshResult($school, $newAccess, $newRefresh, $newExpiry, $recordNum); $this->log('MerchantStore: refreshed token for ' . $school . ' new expiry=' . ($newExpiry ?? '?')); return ['school' => $school, 'action' => 'refreshed', 'access_token' => $newAccess, 'expires_at' => $newExpiry]; } $detail = $resp['body']['errors'][0]['detail'] ?? ('HTTP ' . $resp['status']); $this->noteRefreshFailure($school, $detail, $recordNum); $this->log('MerchantStore: refresh FAILED for ' . $school . ' — HTTP ' . $resp['status'] . ' ' . $detail); // A revoked/invalid authorization keeps failing forever; surface it // clearly. (The webhook is the authoritative revoke path; we only flag // here, we don't delete, in case it's a transient 5xx mis-mapped.) if (in_array($resp['status'], [400, 401, 403], true)) { $issue = [ 'square_ready' => false, 'status_issues' => ['Token refresh was rejected by Square (HTTP ' . $resp['status'] . '). The school may have revoked access — they may need to reconnect.'], ]; if ($recordNum > 0) { $this->mergeMetaByNum($recordNum, $issue); } else { $this->mergeMeta($school, $issue); } } return ['school' => $school, 'action' => 'failed', 'access_token' => $access, 'expires_at' => $rec['expires_at'], 'detail' => $detail]; } catch (Throwable $e) { $this->noteRefreshFailure($school, $e->getMessage(), $recordNum); $this->log('MerchantStore: refresh threw for ' . $school . ' — ' . $e->getMessage()); return ['school' => $school, 'action' => 'failed', 'access_token' => $access, 'expires_at' => $rec['expires_at'], 'detail' => $e->getMessage()]; } } }