You can close this page and return to your school's HotLunches site.
"
. "
";
exit();
}
// The school this logged-in admin may connect/disconnect: ALWAYS the school of
// their own session ($school_record from the host app) — never a school id or
// name sent by the browser, or an admin of one school could attach their own
// Square account to another school and receive its payments. Platform
// SuperUsers working outside any school context may name the school by its
// unique record_num.
function squareAdminSchoolNum(): int {
global $school_record;
$num = (int)($school_record['record_num'] ?? 0);
if ($num > 0) return $num;
if (checkPermission(array('SuperUser')) == 1) return (int)($_POST['school_num'] ?? 0);
return 0;
}
// Handle AJAX requests
if (isset($_POST['action'])) writeLog("POST action: ".$_POST['action']);
// Admin actions only — never on a callback-shaped request, which skipped the login gate.
if (!$isSquareCallback && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
header('Content-Type: application/json');
// Guarantee a JSON body even on a FATAL error (PHP `Error`, parse/type error,
// a broken DB connection inside db_query, an OOM, etc.). Without this, such
// failures emit a blank 500 body because display_errors is off, and the
// browser's r.json() throws "Unexpected end of JSON input" with no clue why.
// We gate strictly on a fatal having occurred so a normal successful exit()
// (which also triggers this shutdown handler) does NOT append a second body.
register_shutdown_function(function () {
$err = error_get_last();
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
if (!$err || !in_array($err['type'], $fatalTypes, true)) {
return;
}
$msg = 'Server error: ' . $err['message'];
if (function_exists('writeLog')) {
writeLog('onboard POST fatal: ' . $msg);
}
if (!headers_sent()) {
header('Content-Type: application/json');
}
echo json_encode(['success' => false, 'error' => $msg]);
});
$config = require __DIR__ . '/config.php';
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
try {
$client = new SquareClient($config);
$store = new MerchantStore($config);
// ACTION: Create an OAuth authorization link and hand it to the browser.
$schoolNum = squareAdminSchoolNum();
if ($schoolNum <= 0) {
echo json_encode(['success' => false, 'error' => 'Could not determine your school. Please sign in to your school account and try again.']);
exit();
}
if ($_POST['action'] === 'connect') {
// Confirm the school_num actually exists before starting OAuth, so the
// callback is guaranteed a row to attach to.
$resolvedName = $store->schoolNameByNum($schoolNum);
if ($resolvedName === null) {
echo json_encode(['success' => false, 'error' => 'School not found.']);
exit();
}
writeLog("connect school_num: $schoolNum ($resolvedName)");
// Cryptographically-random anti-CSRF nonce, plus this school's own
// onboarding URL (signed) so the callback can send the admin back to
// their school's site. The nonce keys the pending record.
[$nonce, $state] = squareMakeState($config, squareCurrentOnboardUrl());
$authUrl = $client->authorizeUrl($state, squareRedirectUri($config));
writeLog("connect: authorize URL issued for school_num $schoolNum");
// Persist the pending attempt in the DB (prunes stale ones first).
$store->createPending($nonce, $schoolNum);
echo json_encode(['success' => true, 'redirect' => $authUrl]);
exit();
}
// ACTION: Re-check a connected seller's readiness (locations / card capability).
if ($_POST['action'] === 'check_status') {
$accessToken = $store->ensureFreshToken('', $client, ['school_num' => $schoolNum])['access_token'] ?? null;
if (!$accessToken) {
echo json_encode(['success' => false, 'error' => 'Your school is not connected to Square yet']);
exit();
}
$sellerStatus = $client->getSellerStatus($accessToken);
writeLog('Seller status [manual] school_num ' . $schoolNum . ': square_ready=' . ($sellerStatus['square_ready'] ? 'true' : 'false'));
$store->mergeMetaByNum($schoolNum, [
'square_ready' => $sellerStatus['square_ready'] && !empty($sellerStatus['active_location_id']),
'active_location_id' => $sellerStatus['active_location_id'],
'merchant_name' => $sellerStatus['merchant_name'],
'currency' => $sellerStatus['currency'],
'status_issues' => $sellerStatus['issues'],
'status_checked_at' => time(),
]);
echo json_encode([
'success' => true,
'square_ready' => $sellerStatus['square_ready'],
'active_location_id' => $sellerStatus['active_location_id'],
'currency' => $sellerStatus['currency'],
'issues' => $sellerStatus['issues'],
]);
exit();
}
// ACTION: Disconnect a seller — revoke their tokens at Square and clear ours.
if ($_POST['action'] === 'disconnect') {
$record = $store->getByNum($schoolNum);
if (!$record) {
echo json_encode(['success' => false, 'error' => 'Your school is not connected to Square']);
exit();
}
// Revoke the whole authorization at Square first (by merchant id). A
// revoke Square refused is logged; we still clear our copy so the
// button disappears, and the school can also remove the app in Square.
try {
$resp = $client->revokeToken('', (string)$record['merchant_id']);
if ($resp['status'] < 200 || $resp['status'] >= 300) {
writeLog('Revoke rejected for school_num ' . $schoolNum . ': HTTP ' . $resp['status'] . ' ' . ($resp['debug_id'] ?? ''));
}
} catch (\Throwable $e) {
writeLog('Revoke failed for school_num ' . $schoolNum . ': ' . $e->getMessage());
}
$store->removeConnectionByNum($schoolNum);
echo json_encode(['success' => true]);
exit();
}
} catch (\Throwable $e) {
// \Throwable (not just Exception) so PHP Errors — TypeError, a fatal from
// a broken db_query, etc. — are reported as JSON instead of a blank body.
if (function_exists('writeLog')) {
writeLog('onboard POST error: ' . $e->getMessage());
}
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
exit();
}
}
// Handle Square OAuth callback (top-level browser return from squareup.com).
if ($isSquareCallback) {
// Never log the query string: it carries the single-use code and state.
writeLog('Square callback received' . (isset($_GET['error']) ? ' (error=' . $_GET['error'] . ')' : ''));
// Never leave the seller on a blank page. Every normal path below ends in a
// squareCallbackPage(); if execution instead dies first (a fatal PHP Error, or
// a host db_query that exits the script on a query error), this shutdown
// guard renders a readable status page instead of an empty body.
register_shutdown_function(function () {
// A result page was rendered (headers_sent() can't tell when output is
// buffered), or real output already went out — nothing to rescue.
if (!empty($GLOBALS['sqCallbackPageSent']) || headers_sent()) {
return;
}
$err = error_get_last();
$detail = ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true))
? $err['message'] : '';
if (function_exists('writeLog')) {
writeLog('Square callback aborted before redirect' . ($detail !== '' ? (': ' . $detail) : ' (no PHP fatal captured — likely a DB query error)'));
}
$back = (string)($GLOBALS['sqReturnOnboardUrl'] ?? '');
if ($back !== '') {
while (ob_get_level() > 0) ob_end_clean();
header('Location: ' . $back . '?status=error&msg=' . rawurlencode('Square authorized the account, but saving the connection failed on our side. Please click Connect Square Account again.'), true, 302);
return;
}
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
echo ""
. "
"
. "
Connection could not be completed
"
. "
Square authorized the account, but saving the connection failed on our side. "
. "Please open Square setup on your school's HotLunches site and click Connect Square Account again.
"
. "
";
});
$config = require __DIR__ . '/config.php';
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
$store = new MerchantStore($config);
// $state = the server-side nonce; the verified school onboarding URL (if the
// signature checks out) is where the admin is sent back to.
[$state, $GLOBALS['sqReturnOnboardUrl']] = squareParseState($config, (string)($_GET['state'] ?? ''));
$code = $_GET['code'] ?? '';
$oauthError = $_GET['error'] ?? '';
// 1) Validate state against our server-side pending store (CSRF protection).
// Only an unused attempt from the last 15 minutes is accepted, and it is
// burned right away, so a state value can never be replayed later to swap
// another Square account onto the school.
$pendingRow = $store->getPending($state);
if (!$state || $pendingRow === null) {
writeLog('Callback rejected: unknown, expired or already-used state');
squareCallbackPage(false, 'Link expired', '
This authorization link has expired or was already used. Please open Square setup on your school's site and click Connect again.
');
}
$store->updatePending($state, 'processing', ['last_callback' => time()]);
// The pending record identifies the school by its unique school_num. Resolve
// the name for the name-keyed reads/embed/display below.
$schoolNum = (int)($pendingRow['school_num'] ?? 0);
$school = $schoolNum > 0 ? (string)($store->schoolNameByNum($schoolNum) ?? '') : '';
// 2) Seller declined or Square reported an error.
if ($oauthError !== '') {
$desc = $_GET['error_description'] ?? $oauthError;
writeLog('OAuth error for school_num ' . $schoolNum . ': ' . $oauthError . ' — ' . $desc);
$store->updatePending($state, 'declined', ['last_callback' => time()]);
squareCallbackPage(false, 'Not connected', '
Square authorization was not completed: ' . htmlspecialchars((string)$desc) . '
Could not complete the connection with Square: ' . htmlspecialchars((string)$detail) . '
Please open Square setup on your school's site and click Connect again.
');
}
$body = $resp['body'];
$accessToken = $body['access_token'];
$refreshToken = $body['refresh_token'] ?? '';
$merchantId = $body['merchant_id'] ?? '';
$expiresAt = $body['expires_at'] ?? null; // ISO 8601
// 4) Persist immediately (tokens encrypted at rest, in the database). The
// connection MUST be recorded even if the readiness check below fails.
// Keyed by school_num so duplicate school names can't misroute the write.
$saved = $store->saveConnectionByNum($schoolNum, $merchantId, $accessToken, $refreshToken, $expiresAt);
writeLog('Merchant saved [callback] school_num ' . $schoolNum . ' (' . $school . ') => ' . $merchantId . ' (' . ($saved ? 'ok' : 'WRITE FAILED') . ')');
if (!$saved) {
squareCallbackPage(false, 'Not saved', '
Square approved the connection, but it could not be saved for ' . htmlspecialchars($school !== '' ? $school : ('school #' . $schoolNum)) . '. Please open Square setup on your school's site and click Connect again.
');
}
// 5) Mark the pending attempt connected.
$store->updatePending($state, 'connected', [
'merchant_id' => $merchantId,
'connected_at' => time(),
'last_callback' => time(),
]);
// 6) Record whether Square says this account can take payments right now
// (an ACTIVE location). That — not merely being connected — is what
// makes the "Pay with Square" button appear. Guarded so a failure never
// discards the connection we just saved; if the check fails, the button
// re-checks Square on its own (MerchantStore::isOnboarded).
$ready = false;
try {
$sellerStatus = $client->getSellerStatus($accessToken);
$ready = $sellerStatus['square_ready'] && !empty($sellerStatus['active_location_id']);
writeLog('Seller status [callback] ' . $school . ': square_ready=' . ($ready ? 'true' : 'false'));
if (!empty($sellerStatus['issues'])) {
writeLog('Seller status issues [callback] ' . $school . ': ' . implode(' | ', $sellerStatus['issues']));
}
$store->mergeMetaByNum($schoolNum, [
'square_ready' => $ready,
'active_location_id' => $sellerStatus['active_location_id'],
'merchant_name' => $sellerStatus['merchant_name'],
'currency' => $sellerStatus['currency'],
'country' => $sellerStatus['country'],
'status_issues' => $sellerStatus['issues'],
'status_checked_at' => time(),
]);
} catch (\Throwable $e) {
writeLog('Seller status check failed [callback] ' . $school . ': ' . $e->getMessage());
// Not ready yet; a null checked-at makes the next page view re-check Square.
$store->mergeMetaByNum($schoolNum, ['square_ready' => false, 'status_checked_at' => null]);
}
$schoolHtml = '
';
}
squareCallbackPage(true, 'Connected — almost there', $schoolHtml
. '
Your Square account is connected, but Square says it can\'t take payments yet, so parents don\'t see the button. '
. 'It will appear automatically once this is fixed in your Square account.