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']); } }