Files
HotLunches/square/square-checkout-widget.php
2026-10-19 15:22:52 -07:00

314 lines
13 KiB
PHP

<?php
/**
* Square "Pay with Square" Checkout Widget (Phase 3)
* ──────────────────────────────────────────────────
* Drop-in include for any school payment page. Loads the Web Payments SDK with
* the platform appId + that school's location_id, renders a card form, tokenizes
* the card in the browser (card data NEVER touches our server), then POSTs
* { sourceId, amount, school_name } to order-api.php.
*
* Usage — set $sqCheckout before including this file:
*
* $sqCheckout = [
* 'school_name' => 'Springfield Elementary', // required
* 'amount' => '25.00', // required (string, major units)
* 'currency' => 'CAD', // optional, default from onboarding/CAD
* 'order_api_url' => '/square/order-api.php', // URL to order-api.php
* 'success_url' => '/payment-success', // redirect after a successful charge (Phase 4)
* 'buyer_email' => 'parent@school.com', // optional, for receipts (Phase 4)
* ];
* require_once '/path/to/square/square-checkout-widget.php';
*
* The widget resolves the school's Square connection server-side; if the school
* is not connected / not ready, it renders a small muted notice and stops.
*/
if (!isset($sqCheckout) || !is_array($sqCheckout)) {
throw new RuntimeException('$sqCheckout array must be set before including square-checkout-widget.php');
}
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
$_sqConfig = require __DIR__ . '/config.php';
$_sqSchool = (string)($sqCheckout['school_name'] ?? '');
$_sqAmount = (string)($sqCheckout['amount'] ?? '');
$_sqSchoolNum = (int)($sqCheckout['school_num'] ?? 0);
// ── Resolve the school's Square connection server-side ────────────────────────
$_sqAppId = $_sqConfig['web_payments_app_id'] ?: $_sqConfig['client_id'];
$_sqEnv = $_sqConfig['env'] ?? 'sandbox';
$_sqStore = new MerchantStore($_sqConfig);
$_sqClient = new SquareClient($_sqConfig);
$_sqMeta = $_sqSchoolNum > 0
? $_sqStore->getMetaByNum($_sqSchoolNum)
: ($_sqSchool !== '' ? $_sqStore->getMeta($_sqSchool) : []);
$_sqMerchantId = $_sqMeta['merchant_id'] ?? null;
$_sqLocationId = ($_sqSchool !== '' || $_sqSchoolNum > 0)
? $_sqStore->locationId($_sqSchool, $_sqClient, $_sqSchoolNum > 0 ? $_sqSchoolNum : null)
: null;
$_sqReady = !isset($_sqMeta['square_ready']) || (bool)$_sqMeta['square_ready'];
$_sqRevoked = !empty($_sqMeta['authorization_revoked']);
$_sqCurrency = strtoupper((string)($sqCheckout['currency'] ?? ($_sqMeta['currency'] ?? 'CAD')));
// Helper to render a muted, non-blocking notice and bail out.
$_sqNotice = function (string $msg): void {
echo '<div class="sq-notice" style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;'
. 'max-width:500px;background:#f5f7fa;border:1px solid #e2e8f0;border-radius:8px;'
. 'padding:12px 14px;color:#555;font-size:13px;">'
. htmlspecialchars($msg) . '</div>';
};
if (!$_sqAppId || strpos($_sqAppId, 'REPLACE') === 0) {
$_sqNotice('Card payment is temporarily unavailable.');
return;
}
if (!$_sqMerchantId || $_sqRevoked) {
$_sqNotice('This school is not set up to accept card payments yet.');
return;
}
if (!$_sqLocationId) {
$_sqNotice('Card payment for this school is being set up and is not available yet.');
return;
}
if (!is_numeric($_sqAmount) || (float)$_sqAmount <= 0) {
$_sqNotice('Invalid payment amount.');
return;
}
$_sqAmountFmt = number_format((float)$_sqAmount, 2, '.', '');
$_sqApiUrl = (string)($sqCheckout['order_api_url'] ?? './order-api.php');
$_sqSuccessUrl = (string)($sqCheckout['success_url'] ?? '');
$_sqBuyerEmail = (string)($sqCheckout['buyer_email'] ?? '');
$_sqUserNum = (int)($sqCheckout['user_num'] ?? 0);
// Public embed key (preferred over school_name when this widget is loaded from
// an external site via pay-frame.php). Resolved server-side; safe to expose.
$_sqMerchantKey = (string)($sqCheckout['merchant_key'] ?? ($_sqMeta['merchant_key'] ?? ''));
// Unique id so multiple widgets can coexist on one page. Alphanumeric only.
$_sqWidgetId = 'sqwidget' . substr(md5($_sqSchool . $_sqAmountFmt . microtime()), 0, 8);
// SDK URL by environment.
$_sqSdkUrl = $_sqEnv === 'production'
? 'https://web.squarecdn.com/v1/square.js'
: 'https://sandbox.web.squarecdn.com/v1/square.js';
// Load the SDK once per page.
$_sqSdkAlreadyLoaded = $GLOBALS['_sq_sdk_loaded'] ?? false;
if (!$_sqSdkAlreadyLoaded) { $GLOBALS['_sq_sdk_loaded'] = true; }
?>
<div id="<?= $_sqWidgetId ?>" class="sq-checkout-wrap">
<div class="sq-card-header">
<span class="sq-card-title">Pay with card</span>
<span class="sq-card-amount"><?= htmlspecialchars($_sqCurrency) ?> <?= htmlspecialchars($_sqAmountFmt) ?></span>
</div>
<div id="<?= $_sqWidgetId ?>-card" class="sq-card-container"></div>
<button id="<?= $_sqWidgetId ?>-pay" class="sq-pay-btn" type="button">
Pay <?= htmlspecialchars($_sqCurrency) ?> <?= htmlspecialchars($_sqAmountFmt) ?>
</button>
<div id="<?= $_sqWidgetId ?>-status" class="sq-status" style="display:none;"></div>
<div class="sq-secure-note">
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M12 1 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-4zm0 10.99h6c-.45 3.6-2.97 6.84-6 7.93V12H6V6.3l6-3 6 3V12h-6V11.99z"/></svg>
Card details are encrypted by Square and never touch our servers.
</div>
</div>
<style>
#<?= $_sqWidgetId ?> {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 500px;
background: #fff;
border: 1.5px solid #e2e8f0;
border-radius: 12px;
padding: 20px;
}
#<?= $_sqWidgetId ?> .sq-card-header {
display: flex; justify-content: space-between; align-items: baseline;
margin-bottom: 14px;
}
#<?= $_sqWidgetId ?> .sq-card-title { font-weight: 600; color: #1a202c; font-size: 15px; }
#<?= $_sqWidgetId ?> .sq-card-amount { font-weight: 700; color: #006aff; font-size: 16px; }
#<?= $_sqWidgetId ?> .sq-card-container {
min-height: 48px; margin-bottom: 14px;
}
#<?= $_sqWidgetId ?> .sq-pay-btn {
width: 100%; padding: 14px;
background: linear-gradient(135deg, #006aff 0%, #003a8c 100%);
color: #fff; border: none; border-radius: 8px;
font-size: 16px; font-weight: 600; cursor: pointer;
}
#<?= $_sqWidgetId ?> .sq-pay-btn:hover { box-shadow: 0 8px 18px rgba(0,106,255,.28); }
#<?= $_sqWidgetId ?> .sq-pay-btn:disabled { background: #cbd5e0; cursor: not-allowed; box-shadow: none; }
#<?= $_sqWidgetId ?> .sq-status {
margin-top: 12px; padding: 10px 12px; border-radius: 8px; font-size: 13px;
}
#<?= $_sqWidgetId ?> .sq-status.err { background: #fff5f5; border: 1px solid #feb2b2; color: #c53030; }
#<?= $_sqWidgetId ?> .sq-status.ok { background: #f0fff4; border: 1px solid #9ae6b4; color: #276749; }
#<?= $_sqWidgetId ?> .sq-status.info{ background: #ebf8ff; border: 1px solid #90cdf4; color: #2c5282; }
#<?= $_sqWidgetId ?> .sq-secure-note {
display: flex; align-items: center; gap: 6px;
margin-top: 12px; color: #718096; font-size: 12px;
}
</style>
<?php if (!$_sqSdkAlreadyLoaded): ?>
<script src="<?= htmlspecialchars($_sqSdkUrl) ?>" onload="window.__sqSdkReady && window.__sqSdkReady()"></script>
<?php endif; ?>
<script>
(function () {
var CFG = {
id: <?= json_encode($_sqWidgetId) ?>,
appId: <?= json_encode($_sqAppId) ?>,
locationId:<?= json_encode($_sqLocationId) ?>,
school: <?= json_encode($_sqSchool) ?>,
schoolNum: <?= (int)$_sqSchoolNum ?>,
userNum: <?= (int)$_sqUserNum ?>,
merchantKey:<?= json_encode($_sqMerchantKey) ?>,
amount: <?= json_encode($_sqAmountFmt) ?>,
currency: <?= json_encode($_sqCurrency) ?>,
apiUrl: <?= json_encode($_sqApiUrl) ?>,
successUrl:<?= json_encode($_sqSuccessUrl) ?>,
buyerEmail:<?= json_encode($_sqBuyerEmail) ?>
};
// A simple per-page init queue so the SDK is loaded once but every widget on
// the page gets initialized when it's ready.
window.__sqInitQueue = window.__sqInitQueue || [];
window.__sqInitQueue.push(CFG);
window.__sqDrainQueue = window.__sqDrainQueue || function () {
if (!window.Square) return;
var q = window.__sqInitQueue || [];
window.__sqInitQueue = [];
q.forEach(function (cfg) { initWidget(cfg); });
};
window.__sqSdkReady = window.__sqDrainQueue;
function setStatus(cfg, cls, msg) {
var el = document.getElementById(cfg.id + '-status');
if (!el) return;
el.style.display = 'block';
el.className = 'sq-status ' + cls;
el.textContent = msg;
}
function newIdempotencyKey() {
if (window.crypto && crypto.randomUUID) return crypto.randomUUID();
return 'idem-' + Date.now() + '-' + Math.random().toString(16).slice(2);
}
async function initWidget(cfg) {
var payBtn = document.getElementById(cfg.id + '-pay');
var payments, card;
try {
payments = window.Square.payments(cfg.appId, cfg.locationId);
card = await payments.card();
await card.attach('#' + cfg.id + '-card');
} catch (e) {
// Surface the real SDK error (helpful while integrating). The most common
// cause is the appId and the location belonging to different Square apps
// or environments.
console.error('Square init error:', e, 'appId=', cfg.appId, 'locationId=', cfg.locationId);
var detail = (e && e.message) ? e.message : String(e);
setStatus(cfg, 'err', 'Could not load the card form: ' + detail);
if (payBtn) payBtn.disabled = true;
return;
}
payBtn.addEventListener('click', async function () {
payBtn.disabled = true;
setStatus(cfg, 'info', 'Processing…');
var result;
try {
result = await card.tokenize();
} catch (e) {
setStatus(cfg, 'err', 'Could not read the card. Please check the details and try again.');
payBtn.disabled = false;
return;
}
if (!result || result.status !== 'OK') {
var msg = 'Please check your card details.';
if (result && result.errors && result.errors.length) {
msg = result.errors.map(function (er) { return er.message; }).join(' ');
}
setStatus(cfg, 'err', msg);
payBtn.disabled = false;
return;
}
var verifyToken = '';
try {
if (payments.verifyBuyer) {
var verification = await payments.verifyBuyer(result.token, {
amount: cfg.amount,
currencyCode: cfg.currency,
intent: 'CHARGE',
billingContact: cfg.buyerEmail ? { email: cfg.buyerEmail } : {}
});
if (verification && verification.token) verifyToken = verification.token;
}
} catch (e) {
var cancelled = e && (/cancel/i.test(e.message || '') || e.name === 'VerifyBuyerError');
if (cancelled) {
setStatus(cfg, 'err', 'Card verification was cancelled. Please try again.');
payBtn.disabled = false;
return;
}
// SCA not required / SDK could not verify — charge without the token.
}
// result.token is the single-use sourceId. Send it to our backend.
try {
var resp = await fetch(cfg.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'create_payment',
source_id: result.token,
amount: cfg.amount,
currency: cfg.currency,
school_name: cfg.school,
school_num: cfg.schoolNum || undefined,
user_num: cfg.userNum || undefined,
merchant_key: cfg.merchantKey,
buyer_email: cfg.buyerEmail,
verification_token: verifyToken,
idempotency_key: newIdempotencyKey()
})
});
var d = await resp.json();
if (d.success && d.charged) {
// Phase 4: charge completed.
setStatus(cfg, 'ok', 'Payment successful!');
if (cfg.successUrl) {
var sep = cfg.successUrl.indexOf('?') === -1 ? '?' : '&';
window.location.href = cfg.successUrl + sep + 'payment_id=' + encodeURIComponent(d.payment_id || '');
}
} else if (d.success && d.stage === 'validated') {
// Phase 3: backend received + validated the token; charging is Phase 4.
setStatus(cfg, 'ok', 'Card accepted and validated by the server. (Charging is enabled in Phase 4.)');
} else {
setStatus(cfg, 'err', d.error || 'Payment could not be completed.');
payBtn.disabled = false;
}
} catch (e) {
setStatus(cfg, 'err', 'Network error. Please try again.');
payBtn.disabled = false;
}
});
}
// If the SDK is already on the page (a previous widget loaded it), drain now.
if (window.Square) { window.__sqDrainQueue(); }
}());
</script>