First Commit
This commit is contained in:
1 parent
5eb9dd037d
commit
2e62c0e60b
11877 files changed
+2035312
No files matched your search
@@ -0,0 +1,405 @@
|
||||
<?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']);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user