1110 lines
45 KiB
PHP
1110 lines
45 KiB
PHP
<?php
|
|
|
|
require_once __DIR__ . '/Secret.php';
|
|
|
|
/**
|
|
* Single source of truth for reading/writing a school's Square connection
|
|
* (merchant id + encrypted access/refresh tokens), its metadata, the pending
|
|
* onboarding attempts, and the payment records, plus the token-refresh and
|
|
* revocation logic shared by:
|
|
* - onboard.php (web: store on OAuth callback, manual disconnect)
|
|
* - helper.php (web: hand a valid token to the payments code)
|
|
* - order-api.php (web: charge + record the payment)
|
|
* - refresh_tokens.php (CLI cron: refresh tokens before they expire)
|
|
* - webhook.php (server-to-server: oauth.authorization.revoked)
|
|
*
|
|
* STORAGE: everything lives in the DATABASE — no JSON files. Persistence goes
|
|
* exclusively through the host application's procedural db_* layer (db_query /
|
|
* db_fetch_assoc / db_num_rows / db_getVal / db_escape_string / db_insert_id),
|
|
* the SAME data layer the saving/ API uses. The real credentials live in the
|
|
* host app (../include/database.php); this folder's db_config.php is no longer
|
|
* used and no mysqli connection is opened here. Tokens are encrypted at rest via
|
|
* Secret (AES-256-GCM). Every caller-supplied value is passed through
|
|
* db_escape_string before being placed in the query string.
|
|
*
|
|
* Connection + metadata live on the `schools` row (one column per field).
|
|
* Payments live in `square_payments`; pending OAuth attempts in
|
|
* `square_pending_onboarding` (see schema.sql).
|
|
*
|
|
* This class deliberately performs NO session/permission checks — it is used
|
|
* from CLI and webhook contexts that have no logged-in user. Callers that need
|
|
* an auth gate (the web pages) apply it themselves before using the store.
|
|
*/
|
|
class MerchantStore
|
|
{
|
|
private array $config;
|
|
private ?string $key;
|
|
|
|
/**
|
|
* Metadata key (as used by callers / mergeMeta) -> `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<int, string> 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()];
|
|
}
|
|
}
|
|
}
|