Files
HotLunches/square/order-api.php
T
2026-10-19 15:22:52 -07:00

406 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Square Order API (buyer checkout endpoint)
* ──────────────────────────────────────────
* Public AJAX endpoint called by square-checkout-widget.php. It receives the
* single-use payment token (source_id) produced by the Web Payments SDK, looks
* up the school's seller access token, and charges the card with Square's
* Payments API so the money lands in THAT school's account (minus an optional
* app_fee_money platform cut, which lands in ours).
*
* POST body (JSON):
* { action: "create_payment", source_id, amount, currency, school_name,
* buyer_email, idempotency_key, verification_token }
*
* Response:
* success charge → { success:true, charged:true, payment_id, status }
* validate-only → { success:true, charged:false, stage:"validated", ... } (payments_enabled=false)
* handled failure → { success:false, error, code? }
*
* No admin auth — buyers are not logged-in staff. The endpoint only acts on
* shared connection data and never exposes tokens or card data.
*
* The charge logic lives in sq_handle_create_payment() so it can be unit-tested
* with a stubbed client; the HTTP dispatch at the bottom is skipped when the
* constant SQ_TESTING is defined.
*/
if (!defined('SQ_TESTING')) {
ob_start();
if (file_exists(__DIR__ . '/../include/logging-functions.php')) {
require_once __DIR__ . '/../include/logging-functions.php';
if (function_exists('startLog')) startLog('square-order-api');
}
ob_end_clean();
header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
// Same session as the host app / checkout.php so first-party amounts cannot
// be changed from the browser. Harmless for embed callers with no session.
if (session_status() !== PHP_SESSION_ACTIVE) {
@session_name('hotlunch');
@session_start();
}
// ── CORS ─────────────────────────────────────────────────────────────────
// This endpoint is called cross-origin from each school's own website (via
// embed.js → pay-frame.php). We reflect the requesting Origin so the browser
// permits the fetch; the actual per-merchant origin allowlist (when enabled)
// is enforced in sq_handle_create_payment() against the resolved school.
$sqOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($sqOrigin !== '') {
header('Access-Control-Allow-Origin: ' . $sqOrigin);
header('Vary: Origin');
}
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
header('Access-Control-Max-Age: 86400');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204);
exit();
}
}
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
function sqOut(array $payload): void { echo json_encode($payload); exit(); }
function sqLog(string $msg): void { if (function_exists('writeLog')) writeLog($msg); }
/**
* Book a completed Square charge into user_account (same shape as PayPal).
* Idempotent on payment_id. Uses the host db_* layer.
*/
function sq_recordLedgerPayment(int $schoolNum, int $userNum, string $paymentId, float $amount): array
{
if ($schoolNum <= 0 || $userNum <= 0 || $paymentId === '' || $amount <= 0) {
return ['posted' => false, 'reason' => 'missing school/user/payment_id/amount'];
}
if (!function_exists('db_query')) {
return ['posted' => false, 'reason' => 'db_layer_unavailable'];
}
$pid = function_exists('db_escape_string') ? db_escape_string($paymentId) : $paymentId;
$sn = (int)$schoolNum;
$un = (int)$userNum;
$existing = db_query(
"SELECT record_num FROM user_account
WHERE school_num = $sn AND payment_reference = '$pid' AND transaction_type = 'Payment'
LIMIT 1"
);
if ($existing && db_num_rows($existing) > 0) {
return ['posted' => true, 'reason' => 'already_posted'];
}
$now = time();
$dt = date('Y-m-d H:i:s', $now);
$amt = -1 * abs($amount);
$note = function_exists('db_escape_string')
? db_escape_string('Square payment ' . $paymentId)
: ('Square payment ' . $paymentId);
$ok = db_query(
"INSERT INTO user_account
(school_num, user_num, transaction_type, transaction_amount, transaction_status,
transaction_datetime, payment_time, payment_method, payment_reference, note,
order_num, schedule_num, entered_time, entered_by_user_num, deleted)
VALUES
($sn, $un, 'Payment', $amt, 'Closed',
'$dt', $now, 'Square', '$pid', '$note',
0, 0, $now, $un, 'N')"
);
if ($ok === false) {
return ['posted' => false, 'reason' => 'insert_failed'];
}
db_query(
"UPDATE user_online_payments SET status = 'Completed'
WHERE school_num = $sn AND user_num = $un AND status = 'Pending'
ORDER BY record_num DESC LIMIT 1"
);
$bal = 0.0;
if (function_exists('db_getVal')) {
$bal = (float)db_getVal(
"SELECT COALESCE(SUM(transaction_amount),0) AS bal FROM user_account
WHERE school_num = $sn AND user_num = $un AND (deleted != 'Y' OR deleted IS NULL)"
);
}
db_query(
"UPDATE users SET balance_owing = $bal, account_balance = $bal
WHERE school_num = $sn AND record_num = $un"
);
return ['posted' => true, 'reason' => 'posted'];
}
/**
* Validate and (when enabled) charge a payment. Returns a response array; does
* not echo or exit, so it is safe to call from tests.
*/
function sq_handle_create_payment(array $input, array $config, SquareClient $client, MerchantStore $store): array
{
$sourceId = trim($input['source_id'] ?? '');
$amount = trim((string)($input['amount'] ?? ''));
$currency = strtoupper(trim($input['currency'] ?? 'CAD'));
$schoolName = trim($input['school_name'] ?? '');
$schoolNum = (int)($input['school_num'] ?? 0);
$merchantKey = trim($input['merchant_key'] ?? '');
$idempotency = trim($input['idempotency_key'] ?? '');
$buyerEmail = trim($input['buyer_email'] ?? '');
$verifyToken = trim($input['verification_token'] ?? '');
$userNum = (int)($input['user_num'] ?? 0);
// Prefer the public embed key (external-site embeds send this); fall back to
// school_num (first-party checkout) then school name. The key is unique.
if ($merchantKey !== '') {
$found = $store->findByKey($merchantKey);
if ($found !== null) {
if ($found['school_name'] !== '') $schoolName = $found['school_name'];
if ($schoolNum <= 0 && $found['record_num'] > 0) $schoolNum = $found['record_num'];
}
}
if ($schoolNum > 0 && $schoolName === '') {
$schoolName = (string)($store->schoolNameByNum($schoolNum) ?? '');
}
// ── Validate the request shape ───────────────────────────────────────────
if ($sourceId === '' || ($schoolName === '' && $schoolNum <= 0)) {
return ['success' => false, 'error' => 'source_id and a valid merchant_key (or school) are required'];
}
if (!is_numeric($amount) || (float)$amount <= 0) {
return ['success' => false, 'error' => 'Invalid amount'];
}
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
return ['success' => false, 'error' => 'Invalid currency'];
}
// ── Validate the school can be charged ───────────────────────────────────
$meta = $schoolNum > 0 ? $store->getMetaByNum($schoolNum) : $store->getMeta($schoolName);
$merchantId = $meta['merchant_id'] ?? null;
if (!$merchantId || !empty($meta['authorization_revoked'])) {
return ['success' => false, 'error' => 'This school is not connected to Square'];
}
// ── Optional per-merchant embed-origin allowlist ─────────────────────────
// When enforce_embed_origins is on AND the school has registered origins,
// reject charges initiated from any other site (anti-impersonation). A
// same-origin/first-party call (no Origin header) is always allowed.
if (!empty($config['enforce_embed_origins'])) {
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed = $store->allowedOrigins($schoolName);
if ($origin !== '' && !empty($allowed) && !in_array($origin, $allowed, true)) {
sqLog('create_payment BLOCKED origin=' . $origin . ' school=' . $schoolName);
return ['success' => false, 'error' => 'This payment button is not authorized for this website.'];
}
}
if (!$store->isOnboarded($schoolNum > 0 ? $schoolNum : (int)($store->schoolNum($schoolName) ?? 0), $client)) {
return ['success' => false, 'error' => 'This school cannot accept card payments at this time'];
}
$locationId = $store->locationId($schoolName, $client, $schoolNum > 0 ? $schoolNum : null);
if (!$locationId) {
return ['success' => false, 'error' => 'This school has no active Square location'];
}
// Prefer the school's Square currency when the request didn't send a real one.
if ($currency === '' || $currency === 'CAD') {
$metaCur = strtoupper((string)($meta['currency'] ?? ''));
if (preg_match('/^[A-Z]{3}$/', $metaCur) && ($input['currency'] ?? '') === '') {
$currency = $metaCur;
}
}
// Square amounts are integer minor units (cents). CAD/USD = 2 decimals.
// (Zero-decimal currencies like JPY would need special handling.)
$amountCents = (int) round((float)$amount * 100);
if ($idempotency === '') $idempotency = bin2hex(random_bytes(16));
sqLog('create_payment [validated] school=' . $schoolName . ' amount_cents=' . $amountCents
. ' currency=' . $currency . ' location=' . $locationId . ' src=' . substr($sourceId, 0, 8) . '…');
// Master switch: validate-only mode when live charging is turned off.
if (empty($config['payments_enabled'])) {
return [
'success' => true,
'charged' => false,
'stage' => 'validated',
'message' => 'Payments are currently disabled on the server.',
'amount_cents' => $amountCents,
'currency' => $currency,
'location_id' => $locationId,
];
}
// ── Look up the school's access token (auto-refreshes near expiry). ──────
// This seller token is what routes the money into THAT school's account.
$accessToken = $store->ensureFreshToken($schoolName, $client, $schoolNum > 0 ? ['school_num' => $schoolNum] : [])['access_token'] ?? null;
if (!$accessToken) {
sqLog('create_payment: no usable access token for ' . $schoolName);
return ['success' => false, 'error' => 'This school is not connected to Square'];
}
// ── Build the charge payload. ────────────────────────────────────────────
$payload = [
'source_id' => $sourceId,
'idempotency_key' => substr($idempotency, 0, 45), // Square allows 1–45 chars
'amount_money' => ['amount' => $amountCents, 'currency' => $currency],
'location_id' => $locationId,
'autocomplete' => true, // capture immediately
'note' => 'Payment to ' . substr($schoolName, 0, 480),
];
if ($buyerEmail !== '' && filter_var($buyerEmail, FILTER_VALIDATE_EMAIL)) {
$payload['buyer_email_address'] = substr($buyerEmail, 0, 255);
}
if ($verifyToken !== '') {
$payload['verification_token'] = $verifyToken; // 3DS/SCA result (optional)
}
// ── Optional platform fee (app_fee_money → our account). ─────────────────
$feeCfg = $config['platform_fee'] ?? [];
$appFeeCents = 0;
if (!empty($feeCfg['enabled'])) {
$appFeeCents = (int) round($amountCents * ((float)($feeCfg['percentage'] ?? 0)) / 100)
+ (int)($feeCfg['fixed_cents'] ?? 0);
$cap = (int) floor($amountCents * 0.9); // Square caps app fee at 90% of the amount
if ($appFeeCents > $cap) {
sqLog('create_payment: app_fee capped from ' . $appFeeCents . ' to ' . $cap);
$appFeeCents = $cap;
}
if ($appFeeCents > 0) {
$payload['app_fee_money'] = ['amount' => $appFeeCents, 'currency' => $currency];
}
}
// ── Charge. ──────────────────────────────────────────────────────────────
$resp = $client->createPayment($accessToken, $payload);
$payment = $resp['body']['payment'] ?? [];
$status = $payment['status'] ?? '';
if ($resp['status'] >= 200 && $resp['status'] < 300 && in_array($status, ['COMPLETED', 'APPROVED'], true)) {
$paymentId = $payment['id'] ?? null;
$store->recordPayment([
'payment_id' => $paymentId,
'school' => $schoolName,
'school_num' => $schoolNum > 0 ? $schoolNum : null,
'merchant_id' => $merchantId,
'amount_cents' => $amountCents,
'currency' => $currency,
'app_fee_cents' => $appFeeCents,
'status' => $status,
'source_id_suffix' => substr($sourceId, -4),
]);
$ledger = ['posted' => false, 'reason' => 'skipped'];
if ($userNum > 0) {
$sn = $schoolNum > 0 ? $schoolNum : (int)($store->schoolNum($schoolName) ?? 0);
$ledger = sq_recordLedgerPayment($sn, $userNum, (string)$paymentId, $amountCents / 100);
sqLog('create_payment ledger school=' . $schoolName . ' user=' . $userNum
. ' posted=' . ($ledger['posted'] ? 'yes' : 'no') . ' reason=' . $ledger['reason']);
}
sqLog('create_payment SUCCESS school=' . $schoolName . ' payment_id=' . $paymentId
. ' status=' . $status . ' amount_cents=' . $amountCents . ' app_fee_cents=' . $appFeeCents);
return [
'success' => true,
'charged' => true,
'payment_id' => $paymentId,
'status' => $status,
'ledger' => $ledger['posted'],
];
}
// ── Failure — log the real reason, return a parent-friendly message. ─────
$err = $resp['body']['errors'][0] ?? [];
$code = $err['code'] ?? 'PAYMENT_FAILED';
$detail = $err['detail'] ?? 'Payment could not be completed.';
sqLog('create_payment FAILED school=' . $schoolName . ' http=' . $resp['status']
. ' code=' . $code . ' detail=' . $detail);
// Card/method errors carry buyer-safe detail; everything else stays generic.
$friendly = (($err['category'] ?? '') === 'PAYMENT_METHOD_ERROR')
? $detail
: 'Your payment could not be completed. Please try another card.';
// A 4xx is Square's definitive "no payment was made"; anything else (5xx,
// or an idempotent replay) keeps the same key so a retry cannot double-charge.
$rotateKey = $resp['status'] >= 400 && $resp['status'] < 500 && $code !== 'IDEMPOTENCY_KEY_REUSED';
return ['success' => false, 'error' => $friendly, 'code' => $code, 'rotate_key' => $rotateKey];
}
// ── HTTP dispatch (skipped under test) ───────────────────────────────────────
if (!defined('SQ_TESTING')) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
sqOut(['success' => false, 'error' => 'Method not allowed']);
}
// Accept JSON body or form-encoded.
$input = json_decode(file_get_contents('php://input'), true);
if (!is_array($input)) $input = $_POST;
$action = trim($input['action'] ?? '');
if ($action === '') {
sqOut(['success' => false, 'error' => 'action required']);
}
// First-party checkout: amount/school/user come from the session written by
// square_button.php — never from the browser. Embed callers have no session.
$ctx = $_SESSION['sq_checkout'] ?? null;
$ctxValid = is_array($ctx)
&& (float)($ctx['total_amount'] ?? 0) > 0
&& (int)($ctx['school_num'] ?? 0) > 0
&& (time() - (int)($ctx['created_at'] ?? 0)) < 3600;
if ($ctxValid) {
$input['amount'] = $ctx['total_amount'];
$input['currency'] = $ctx['currency'] ?? ($input['currency'] ?? 'CAD');
$input['school_num'] = (int)$ctx['school_num'];
$input['school_name'] = $ctx['school_name'] ?? ($input['school_name'] ?? '');
$input['user_num'] = (int)($ctx['user_num'] ?? 0);
$input['merchant_key'] = '';
if (!empty($ctx['idempotency_key'])) {
$input['idempotency_key'] = $ctx['idempotency_key'];
}
if (trim((string)($input['buyer_email'] ?? '')) === '' && !empty($ctx['buyer_email'])) {
$input['buyer_email'] = $ctx['buyer_email'];
}
} else {
// No first-party checkout in this session: only an external-site embed
// (identified by its public merchant_key) may charge, and the browser can
// never choose the school or book the payment to a parent's account.
if (trim((string)($input['merchant_key'] ?? '')) === '') {
sqOut(['success' => false, 'error' => 'This checkout has expired or was already paid. Please go back to your account and try again.']);
}
$input['school_num'] = 0;
$input['school_name'] = '';
$input['user_num'] = 0;
}
$config = require __DIR__ . '/config.php';
try {
$store = new MerchantStore($config);
$client = new SquareClient($config);
if ($action === 'create_payment') {
$result = sq_handle_create_payment($input, $config, $client, $store);
if ($ctxValid) {
if (!empty($result['charged'])) {
// Paid: close this checkout so Back / refresh / a second tab
// cannot charge the same balance again.
unset($_SESSION['sq_checkout']);
} elseif (!empty($result['rotate_key'])) {
// Square definitively declined (no money moved): allow a retry,
// e.g. with another card, under a fresh idempotency key.
$_SESSION['sq_checkout']['idempotency_key'] = 'hl-' . bin2hex(random_bytes(16));
}
}
unset($result['rotate_key']);
sqOut($result);
}
sqOut(['success' => false, 'error' => 'Unknown action: ' . htmlspecialchars($action)]);
} catch (Throwable $e) {
sqLog('square-order-api exception: ' . $e->getMessage());
sqOut(['success' => false, 'error' => 'Server error processing payment']);
}
}