674 lines
34 KiB
PHP
674 lines
34 KiB
PHP
<?php
|
|
ini_set('display_errors', 0);
|
|
|
|
$ValidAccess = 2010;
|
|
|
|
// require_once("../include/globals.php");
|
|
$cdir = "/m/data1/app.hotlunches.net";
|
|
$AppDir = $cdir;
|
|
|
|
ob_start();
|
|
|
|
// Square redirects the seller's browser back to the ONE Redirect URL registered
|
|
// for the app, whichever school started onboarding. The host app resolves the
|
|
// school from the hostname (and bounces hosts that aren't a school, e.g.
|
|
// app.hotlunches.net → invalid-access.html), so the callback must NOT load the
|
|
// school/session includes or it would never run. It needs no session: it is
|
|
// authenticated by the single-use `state` token we generated, and MerchantStore
|
|
// loads only the logging/config/database includes itself.
|
|
$isSquareCallback = isset($_GET['code']) || isset($_GET['error']);
|
|
$isSquareCallback = $isSquareCallback && isset($_GET['state']);
|
|
|
|
if ($isSquareCallback) {
|
|
if (file_exists(__DIR__ . "/../include/logging-functions.php")) {
|
|
require_once(__DIR__ . "/../include/logging-functions.php");
|
|
}
|
|
if (!function_exists('writeLog')) { function writeLog($m = '') { error_log('[square] ' . $m); } }
|
|
if (!function_exists('startLog')) { function startLog($n = '') {} }
|
|
} elseif (file_exists(__DIR__ . "/../include/logging-functions.php")) {
|
|
// Host-app integration: logging, the session, and the admin login gate.
|
|
require_once(__DIR__ . "/../include/logging-functions.php");
|
|
startLog("includes");
|
|
|
|
require_once(__DIR__ . "/../include/date-time.php");
|
|
require_once(__DIR__ . "/../include/config.php");
|
|
require_once(__DIR__ . "/../include/school-functions.php");
|
|
require_once(__DIR__ . "/../include/database.php");
|
|
require_once(__DIR__ . "/../include/session.php");
|
|
} elseif (file_exists(__DIR__ . "/local-dev-bootstrap.php")) {
|
|
// Local dev only (stubs the host app); never deployed.
|
|
require_once(__DIR__ . "/local-dev-bootstrap.php");
|
|
}
|
|
|
|
if (!$isSquareCallback) {
|
|
$sessionName = 'hotlunch';
|
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
session_name($sessionName);
|
|
session_start();
|
|
}
|
|
|
|
if (SessionActive() != 1 || ! UserLoggedIn()) {
|
|
header("Location: {$FormTarget}");
|
|
exit();
|
|
}
|
|
|
|
|
|
$AllowedUserTypes = array('SuperUser','System Administrator');
|
|
if (checkPermission($AllowedUserTypes) != 1) {
|
|
$error_message = getLanguageText('ERRORMSG-ACCESS-DENIED');
|
|
writeLog($error_message);
|
|
$naction = '';
|
|
header("Location: {$FormTarget}?action=$action&naction=$naction$headerArgs&form_message=" . urlencode($form_message) . "&error_message=" . urlencode($error_message));
|
|
exit();
|
|
}
|
|
}
|
|
|
|
$buffer = ob_get_contents();
|
|
if (strlen($buffer)) writeLog("buffer: $buffer");
|
|
ob_end_clean();
|
|
writeLog("Header complete");
|
|
startLog("square-integration");
|
|
|
|
|
|
// redirect_uri sent to Square. Empty (the default) sends none, and Square uses
|
|
// the Redirect URL registered in the Developer Dashboard — correct for every
|
|
// school host. Never derive it from the current host: each school has its own
|
|
// hostname, and anything that differs from the registered URL is rejected.
|
|
function squareRedirectUri(array $config): string {
|
|
return trim($config['redirect_uri'] ?? '');
|
|
}
|
|
|
|
// ── Returning the admin to their OWN school site ─────────────────────────────
|
|
// Square sends every school back to the single Redirect URL registered for the
|
|
// app, not to the school's site. So when an admin clicks Connect on
|
|
// https://<schoolurl>/square/onboard.php we carry that URL inside the OAuth
|
|
// `state` (which Square echoes back verbatim), signed with our server key so it
|
|
// cannot be swapped for another site:
|
|
// state = <32-hex nonce>.<base64url(school onboard URL)>.<HMAC>
|
|
// Only the nonce is stored server-side (square_pending_onboarding.state), so no
|
|
// schema change is needed.
|
|
|
|
function squareStateKey(array $config): string {
|
|
$secret = (string)(($config['token_encryption_key'] ?? '') ?: ($config['client_secret'] ?? ''));
|
|
return hash('sha256', 'square-oauth-state|' . $secret, true);
|
|
}
|
|
|
|
// The URL of the onboarding page the admin is on right now (on their school's host).
|
|
function squareCurrentOnboardUrl(): string {
|
|
$scheme = 'http';
|
|
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
|
$scheme = 'https';
|
|
} elseif (strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https') {
|
|
$scheme = 'https';
|
|
}
|
|
$host = (string)($_SERVER['HTTP_HOST'] ?? '');
|
|
$path = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: ($_SERVER['SCRIPT_NAME'] ?? ''));
|
|
if (!preg_match('/^[A-Za-z0-9.-]+(:\d{1,5})?$/', $host) || !preg_match('#^/[A-Za-z0-9._~/-]*$#', $path)) {
|
|
return '';
|
|
}
|
|
return $scheme . '://' . $host . $path;
|
|
}
|
|
|
|
function squareMakeState(array $config, string $onboardUrl): array {
|
|
$nonce = bin2hex(random_bytes(16));
|
|
if ($onboardUrl === '') return [$nonce, $nonce];
|
|
$payload = rtrim(strtr(base64_encode($onboardUrl), '+/', '-_'), '=');
|
|
$sig = substr(hash_hmac('sha256', $nonce . '.' . $payload, squareStateKey($config)), 0, 32);
|
|
return [$nonce, $nonce . '.' . $payload . '.' . $sig];
|
|
}
|
|
|
|
// Returns [nonce, verified school onboard URL or ''].
|
|
function squareParseState(array $config, string $state): array {
|
|
if (preg_match('/^([0-9a-f]{32})\.([A-Za-z0-9_-]{1,700})\.([0-9a-f]{32})$/', $state, $m)) {
|
|
$expected = substr(hash_hmac('sha256', $m[1] . '.' . $m[2], squareStateKey($config)), 0, 32);
|
|
if (!hash_equals($expected, $m[3])) return ['', ''];
|
|
$url = (string)base64_decode(strtr($m[2], '-_', '+/'), true);
|
|
return [$m[1], preg_match('#^https?://[A-Za-z0-9.-]+(:\d{1,5})?/[A-Za-z0-9._~/-]*$#', $url) ? $url : ''];
|
|
}
|
|
return [preg_match('/^[0-9a-f]{32}$/', $state) ? $state : '', ''];
|
|
}
|
|
|
|
// The school's home URL: the onboarding URL minus its "/square/onboard.php" tail.
|
|
function squareSchoolHomeUrl(string $onboardUrl): string {
|
|
$p = parse_url($onboardUrl);
|
|
$base = rtrim(str_replace('\\', '/', dirname(dirname($p['path'] ?? '/'))), '/');
|
|
return $p['scheme'] . '://' . $p['host'] . (isset($p['port']) ? ':' . $p['port'] : '') . $base . '/';
|
|
}
|
|
|
|
// Finish the Square callback. When we know the school's site (from the signed
|
|
// state), send the admin back there:
|
|
// connected + payments ready → the school's home URL (button is live);
|
|
// connected but Square not ready → the school's onboarding page, which explains why;
|
|
// failed → the school's onboarding page with the error.
|
|
// Otherwise (legacy/unsigned state) render a self-contained page: this host is
|
|
// usually not the school's site, so it must not link into gated pages.
|
|
function squareCallbackPage(bool $ok, string $title, string $bodyHtml, bool $ready = false): void {
|
|
$GLOBALS['sqCallbackPageSent'] = true; // tells the shutdown guard a page was rendered
|
|
$onboardUrl = (string)($GLOBALS['sqReturnOnboardUrl'] ?? '');
|
|
if ($onboardUrl !== '' && !headers_sent()) {
|
|
if ($ok && $ready) {
|
|
$to = squareSchoolHomeUrl($onboardUrl);
|
|
} elseif ($ok) {
|
|
$to = $onboardUrl;
|
|
} else {
|
|
$msg = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags(str_replace('</p>', ' ', $bodyHtml)), ENT_QUOTES)));
|
|
$to = $onboardUrl . '?status=error&msg=' . rawurlencode($title . ': ' . $msg);
|
|
}
|
|
header('Location: ' . $to, true, 302);
|
|
exit();
|
|
}
|
|
if (!headers_sent()) header('Content-Type: text/html; charset=utf-8');
|
|
$color = $ok ? '#28a745' : '#c53030';
|
|
echo "<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width, initial-scale=1'>"
|
|
. "<title>School Square Setup</title></head>"
|
|
. "<body style='margin:0;font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,sans-serif;background:linear-gradient(135deg,#006aff 0%,#003a8c 100%);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;box-sizing:border-box'>"
|
|
. "<div style='background:#fff;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.3);padding:36px;max-width:520px;width:100%;box-sizing:border-box;text-align:center;color:#444;line-height:1.6'>"
|
|
. "<div style='font-size:56px;color:$color'>" . ($ok ? '✓' : '!') . "</div>"
|
|
. "<h2 style='color:$color;margin:8px 0 14px'>" . htmlspecialchars($title) . "</h2>"
|
|
. $bodyHtml
|
|
. "<p style='margin-top:22px;font-size:14px;color:#666'>You can close this page and return to your school's HotLunches site.</p>"
|
|
. "</div></body></html>";
|
|
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 "<!doctype html><meta charset='utf-8'>"
|
|
. "<div style='font-family:system-ui,Arial,sans-serif;max-width:560px;margin:60px auto;padding:24px;border:1px solid #e2e8f0;border-radius:12px'>"
|
|
. "<h2 style='color:#c53030;margin:0 0 8px'>Connection could not be completed</h2>"
|
|
. "<p style='color:#4a5568'>Square authorized the account, but saving the connection failed on our side. "
|
|
. "Please open Square setup on your school's HotLunches site and click <b>Connect Square Account</b> again.</p>"
|
|
. "</div>";
|
|
});
|
|
|
|
$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', '<p>This authorization link has expired or was already used. Please open Square setup on your school's site and click Connect again.</p>');
|
|
}
|
|
$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', '<p>Square authorization was not completed: ' . htmlspecialchars((string)$desc) . '</p>');
|
|
}
|
|
|
|
if ($code === '' || $schoolNum <= 0) {
|
|
squareCallbackPage(false, 'Not connected', '<p>Missing authorization code. Please open Square setup on your school's site and click Connect again.</p>');
|
|
}
|
|
|
|
try {
|
|
$client = new SquareClient($config);
|
|
|
|
// 3) Exchange the authorization code for tokens (codes expire in 5 minutes).
|
|
$resp = $client->obtainToken($code, squareRedirectUri($config));
|
|
if ($resp['status'] < 200 || $resp['status'] >= 300 || empty($resp['body']['access_token'])) {
|
|
$detail = $resp['body']['errors'][0]['detail'] ?? ('HTTP ' . $resp['status']);
|
|
writeLog('Token exchange failed for ' . $school . ': ' . $detail);
|
|
$store->updatePending($state, 'token_exchange_failed', ['last_callback' => time()]);
|
|
squareCallbackPage(false, 'Not connected', '<p>Could not complete the connection with Square: ' . htmlspecialchars((string)$detail) . '</p><p>Please open Square setup on your school's site and click Connect again.</p>');
|
|
}
|
|
|
|
$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', '<p>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.</p>');
|
|
}
|
|
|
|
// 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 = '<p style="font-weight:bold;color:#006aff;font-size:18px">' . htmlspecialchars($school !== '' ? $school : ('School #' . $schoolNum)) . '</p>';
|
|
if ($ready) {
|
|
$acct = !empty($sellerStatus['merchant_name']) ? ' (' . htmlspecialchars((string)$sellerStatus['merchant_name']) . ')' : '';
|
|
squareCallbackPage(true, 'Square account connected', $schoolHtml
|
|
. '<p>The <b>Pay with Square</b> button is now live for parents. Payments go directly to this school\'s Square account' . $acct . '.</p>', true);
|
|
}
|
|
$issues = '';
|
|
foreach (($sellerStatus['issues'] ?? []) as $issue) {
|
|
$issues .= '<li>' . htmlspecialchars((string)$issue) . '</li>';
|
|
}
|
|
squareCallbackPage(true, 'Connected — almost there', $schoolHtml
|
|
. '<p>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.</p>'
|
|
. ($issues !== '' ? '<ul style="text-align:left">' . $issues . '</ul>' : ''));
|
|
|
|
} catch (\Throwable $e) {
|
|
writeLog('Square callback error [' . $school . ']: ' . $e->getMessage());
|
|
squareCallbackPage(false, 'Not connected', '<p>Something went wrong while connecting to Square. Please open Square setup on your school's site and click Connect again.</p>');
|
|
}
|
|
}
|
|
|
|
// Current Square status of THIS admin's school, for the page below.
|
|
require_once __DIR__ . '/lib/SquareClient.php';
|
|
require_once __DIR__ . '/lib/MerchantStore.php';
|
|
$pageConfig = require __DIR__ . '/config.php';
|
|
$pageStore = new MerchantStore($pageConfig);
|
|
$pageSchoolNum = (int)($school_record['record_num'] ?? 0);
|
|
$pageMeta = $pageSchoolNum > 0 ? $pageStore->getMetaByNum($pageSchoolNum) : [];
|
|
$pageConnected = !empty($pageMeta['merchant_id']) && empty($pageMeta['authorization_revoked']);
|
|
$pageLive = $pageConnected && $pageStore->isOnboarded($pageSchoolNum, new SquareClient($pageConfig));
|
|
if ($pageLive !== !empty($pageMeta['square_ready'])) {
|
|
$pageMeta = $pageStore->getMetaByNum($pageSchoolNum); // isOnboarded re-checked Square
|
|
}
|
|
$pageIssues = is_array($pageMeta['status_issues'] ?? null) ? $pageMeta['status_issues'] : [];
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>School Square Account Setup</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: linear-gradient(135deg, #006aff 0%, #003a8c 100%);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
}
|
|
.container {
|
|
background: white;
|
|
border-radius: 12px;
|
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
|
padding: 40px;
|
|
max-width: 500px;
|
|
width: 100%;
|
|
}
|
|
h1 { color: #333; margin-bottom: 10px; font-size: 28px; }
|
|
.subtitle { color: #666; margin-bottom: 30px; font-size: 14px; }
|
|
.form-group { margin-bottom: 20px; }
|
|
label { display: block; margin-bottom: 8px; color: #555; font-weight: 500; }
|
|
input[type="text"] {
|
|
width: 100%;
|
|
padding: 12px;
|
|
border: 2px solid #e0e0e0;
|
|
border-radius: 6px;
|
|
font-size: 16px;
|
|
}
|
|
input[type="text"]:focus { outline: none; border-color: #006aff; }
|
|
.btn {
|
|
width: 100%;
|
|
padding: 14px;
|
|
background: linear-gradient(135deg, #006aff 0%, #003a8c 100%);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
.btn:hover { transform: translateY(-2px); box-shadow: 0 10px 20px rgba(0, 106, 255, 0.3); }
|
|
.btn:disabled { background: #ccc; cursor: not-allowed; }
|
|
.btn.success { background: linear-gradient(135deg, #28a745 0%, #20c997 100%); }
|
|
.alert {
|
|
padding: 12px;
|
|
border-radius: 6px;
|
|
margin-bottom: 20px;
|
|
font-size: 14px;
|
|
}
|
|
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
|
.alert-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
|
.success-page { text-align: center; padding: 20px; }
|
|
.success-icon { font-size: 64px; color: #28a745; margin-bottom: 20px; }
|
|
.success-page h2 { color: #28a745; margin-bottom: 15px; font-size: 24px; }
|
|
.success-page p { color: #666; margin-bottom: 10px; line-height: 1.6; }
|
|
.school-name { font-weight: bold; color: #006aff; font-size: 18px; }
|
|
.alert-warning { background: #fff8e1; color: #7a5b00; border: 1px solid #ffe08a; }
|
|
.alert ul { margin: 6px 0 0 18px; }
|
|
.btn.secondary { background: #fff; color: #c53030; border: 2px solid #f5c6cb; margin-top: 10px; }
|
|
input[readonly] { background: #f5f7fa; color: #555; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<!-- Main Form -->
|
|
<h1>🏫 School Square Setup</h1>
|
|
<p class="subtitle">Connect your school's Square account</p>
|
|
|
|
<?php
|
|
$message = '';
|
|
$messageType = '';
|
|
if (isset($_GET['status']) && $_GET['status'] === 'error') {
|
|
$message = $_GET['msg'] ?? 'An error occurred. Please try again.';
|
|
$messageType = 'error';
|
|
}
|
|
if ($message): ?>
|
|
<div class="alert alert-<?php echo htmlspecialchars($messageType); ?>">
|
|
<?php echo htmlspecialchars($message); ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<?php if ($pageConnected && $pageLive): ?>
|
|
<div class="alert alert-success">
|
|
Connected to Square<?php echo !empty($pageMeta['merchant_name']) ? ' as <b>' . htmlspecialchars((string)$pageMeta['merchant_name']) . '</b>' : ''; ?>.
|
|
The <b>Pay with Square</b> button is live for parents.
|
|
</div>
|
|
<?php elseif ($pageConnected): ?>
|
|
<div class="alert alert-warning">
|
|
Connected to Square, but the account can't take payments yet, so parents don't see the button.
|
|
<?php if ($pageIssues): ?><ul><?php foreach ($pageIssues as $issue): ?><li><?php echo htmlspecialchars((string)$issue); ?></li><?php endforeach; ?></ul><?php endif; ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<form id="connectForm">
|
|
<?php if ($pageSchoolNum > 0): ?>
|
|
<!-- The school is always the one from the admin's own session; the
|
|
server ignores any school sent by the browser. -->
|
|
<div class="form-group">
|
|
<label for="school_name"><?php displayLanguageText('TEXT-SCHOOL-NAME'); ?></label>
|
|
<input type="text" id="school_name" value="<?php echo htmlspecialchars((string)($school_record['school_name'] ?? '')); ?>" readonly>
|
|
</div>
|
|
<?php else: ?>
|
|
<!-- No school context (platform SuperUser): name the school by its unique number. -->
|
|
<div class="form-group">
|
|
<label for="school_num">School number (record_num)</label>
|
|
<input type="text" id="school_num" name="school_num" inputmode="numeric" placeholder="e.g. 251" required>
|
|
</div>
|
|
<?php endif; ?>
|
|
<button type="submit" class="btn" id="connectBtn"><?php echo $pageConnected ? 'Reconnect Square Account' : 'Connect Square Account'; ?></button>
|
|
</form>
|
|
<?php if ($pageConnected): ?>
|
|
<button type="button" class="btn secondary" id="disconnectBtn">Disconnect Square</button>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<script>
|
|
var connectForm = document.getElementById('connectForm');
|
|
if (connectForm) connectForm.addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
|
|
const btn = document.getElementById('connectBtn');
|
|
const schoolNumEl = document.getElementById('school_num');
|
|
const schoolNum = schoolNumEl ? schoolNumEl.value.trim() : '';
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = 'Connecting...';
|
|
|
|
fetch('onboard.php', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: 'action=connect&school_num=' + encodeURIComponent(schoolNum)
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success && data.redirect) {
|
|
window.location.href = data.redirect;
|
|
} else {
|
|
alert('Error: ' + (data.error || 'Connection failed'));
|
|
btn.disabled = false;
|
|
btn.textContent = 'Connect Square Account';
|
|
}
|
|
})
|
|
.catch(err => {
|
|
alert('Error: ' + err.message);
|
|
btn.disabled = false;
|
|
btn.textContent = 'Connect Square Account';
|
|
});
|
|
});
|
|
|
|
var disconnectBtn = document.getElementById('disconnectBtn');
|
|
if (disconnectBtn) disconnectBtn.addEventListener('click', function() {
|
|
if (!confirm('Disconnect Square? Parents will no longer see the Pay with Square button.')) return;
|
|
disconnectBtn.disabled = true;
|
|
fetch('onboard.php', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: 'action=disconnect'
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success) { window.location.href = 'onboard.php'; }
|
|
else { alert('Error: ' + (data.error || 'Disconnect failed')); disconnectBtn.disabled = false; }
|
|
})
|
|
.catch(err => { alert('Error: ' + err.message); disconnectBtn.disabled = false; });
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|