406 lines
19 KiB
PHP
406 lines
19 KiB
PHP
<?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']);
|
||
}
|
||
}
|