false, 'error' => 'Method not allowed']); exit(); } // Accept both JSON body and form-encoded $input = json_decode(file_get_contents('php://input'), true); if (!is_array($input)) { $input = $_POST; } $action = trim($input['action'] ?? ''); if (!$action) { echo json_encode(['success' => false, 'error' => 'action required']); exit(); } // ── Attach to the main app session (same domain, session name 'hotlunch') ───── // Lets us resolve the logged-in parent tamper-proof when recording the payment // into the ledger. Harmless for public callers (demos) where no session exists. if (session_status() !== PHP_SESSION_ACTIVE) { @session_name('hotlunch'); @session_start(); } // ── Resolve merchant ID (no admin auth needed — reads shared data) ────────── function pp_getMerchantId(string $schoolName): ?string { // Try DB first try { $dbConfig = require __DIR__ . '/db_config.php'; $conn = new mysqli($dbConfig['host'], $dbConfig['username'], $dbConfig['password'], $dbConfig['database']); if (!$conn->connect_error) { $conn->set_charset($dbConfig['charset'] ?? 'utf8mb4'); $stmt = $conn->prepare( "SELECT paypal_merchant_id FROM schools WHERE school_name = ? AND paypal_merchant_id IS NOT NULL LIMIT 1" ); $stmt->bind_param('s', $schoolName); $stmt->execute(); $row = $stmt->get_result()->fetch_assoc(); $stmt->close(); $conn->close(); if ($row['paypal_merchant_id'] ?? null) { return $row['paypal_merchant_id']; } } } catch (Throwable $e) { // fall through to JSON } // JSON fallback $file = __DIR__ . '/data/school_merchants.json'; if (!file_exists($file)) return null; $merchants = json_decode(file_get_contents($file), true) ?: []; return $merchants[$schoolName]['merchant_id'] ?? null; } // ── Also check paypal_ready flag ───────────────────────────────────────────── function pp_isSchoolReady(string $schoolName): bool { $file = __DIR__ . '/data/school_merchants.json'; if (!file_exists($file)) return false; $merchants = json_decode(file_get_contents($file), true) ?: []; $record = $merchants[$schoolName] ?? null; if (!$record) return false; // If status was never checked, allow through (status check happens on onboarding) if (!isset($record['paypal_ready'])) return true; return (bool)$record['paypal_ready']; } // ── Record a captured payment into the app ledger ──────────────────────────── // Mirrors how user_account.php books a Payment: // • transaction_amount is NEGATIVE (payments reduce the balance owing) // • transaction_status = 'Closed' // • payment_method = 'PayPal', payment_reference = PayPal capture_id // Idempotent on capture_id so calling capture twice never double-credits. // Returns ['posted' => bool, 'reason' => string]. function pp_recordPayment(string $schoolName, string $captureId, float $amount, ?int $userNumFallback): array { if ($captureId === '' || $amount <= 0) { return ['posted' => false, 'reason' => 'missing capture_id or amount']; } $dbConfig = require __DIR__ . '/db_config.php'; $conn = new mysqli($dbConfig['host'], $dbConfig['username'], $dbConfig['password'], $dbConfig['database']); if ($conn->connect_error) { return ['posted' => false, 'reason' => 'db_connect: ' . $conn->connect_error]; } $conn->set_charset($dbConfig['charset'] ?? 'utf8mb4'); try { // Resolve school_num (record_num) from the exact school name. $stmt = $conn->prepare("SELECT record_num FROM schools WHERE school_name = ? LIMIT 1"); $stmt->bind_param('s', $schoolName); $stmt->execute(); $schoolNum = (int)($stmt->get_result()->fetch_assoc()['record_num'] ?? 0); $stmt->close(); if (!$schoolNum) { return ['posted' => false, 'reason' => 'school_not_found']; } // Prefer the tamper-proof logged-in user; fall back to the value the // page passed through. Without either we can't credit an account. $userNum = (int)($_SESSION['UserInfo']['record_num'] ?? 0); if (!$userNum) $userNum = (int)($userNumFallback ?? 0); if (!$userNum) { return ['posted' => false, 'reason' => 'no_user_num']; } // Idempotency — never post the same PayPal capture twice. $stmt = $conn->prepare( "SELECT record_num FROM user_account WHERE school_num = ? AND payment_reference = ? AND transaction_type = 'Payment' LIMIT 1" ); $stmt->bind_param('is', $schoolNum, $captureId); $stmt->execute(); $already = $stmt->get_result()->fetch_assoc(); $stmt->close(); if ($already) { return ['posted' => true, 'reason' => 'already_posted']; } // Insert the payment transaction (negative amount reduces balance owing). $now = time(); $dt = date('Y-m-d H:i:s', $now); $amt = -1 * abs($amount); $ttype = 'Payment'; $tstatus = 'Closed'; $method = 'PayPal'; $note = 'PayPal payment ' . $captureId; $orderNum = 0; $schedNum = 0; $deleted = 'N'; $sql = "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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"; $stmt = $conn->prepare($sql); // types: i i s d s s i s s s i i i i s $stmt->bind_param( 'iisdssisssiiiis', $schoolNum, $userNum, $ttype, $amt, $tstatus, $dt, $now, $method, $captureId, $note, $orderNum, $schedNum, $now, $userNum, $deleted ); $stmt->execute(); $stmt->close(); // Mark the matching pending online-payment row completed (best-effort). $stmt = $conn->prepare( "UPDATE user_online_payments SET status = 'Completed' WHERE school_num = ? AND user_num = ? AND status = 'Pending' ORDER BY record_num DESC LIMIT 1" ); $stmt->bind_param('ii', $schoolNum, $userNum); $stmt->execute(); $stmt->close(); // Keep the denormalized balance cache on `users` in sync (as user_account.php does). $stmt = $conn->prepare( "SELECT COALESCE(SUM(transaction_amount),0) AS bal FROM user_account WHERE school_num = ? AND user_num = ? AND (deleted != 'Y' OR deleted IS NULL)" ); $stmt->bind_param('ii', $schoolNum, $userNum); $stmt->execute(); $bal = (float)($stmt->get_result()->fetch_assoc()['bal'] ?? 0); $stmt->close(); $stmt = $conn->prepare( "UPDATE users SET balance_owing = ?, account_balance = ? WHERE school_num = ? AND record_num = ?" ); $stmt->bind_param('ddii', $bal, $bal, $schoolNum, $userNum); $stmt->execute(); $stmt->close(); return ['posted' => true, 'reason' => 'posted']; } catch (Throwable $e) { return ['posted' => false, 'reason' => 'exception: ' . $e->getMessage()]; } finally { $conn->close(); } } // ── Load config + client ───────────────────────────────────────────────────── $config = require __DIR__ . '/config.php'; require_once __DIR__ . '/lib/PayPalClient.php'; try { $client = new PayPalClient($config); // ════════════════════════════════════════════════════════════ // CREATE ORDER // ════════════════════════════════════════════════════════════ if ($action === 'create_order') { $schoolName = trim($input['school_name'] ?? ''); $amount = trim($input['amount'] ?? ''); $currency = strtoupper(trim($input['currency'] ?? 'USD')); $items = $input['items'] ?? []; $buyerEmail = trim($input['buyer_email'] ?? ''); $fraudnetId = trim($input['fraudnet_id'] ?? ''); if (!$schoolName || !$amount) { echo json_encode(['success' => false, 'error' => 'school_name and amount are required']); exit(); } if (!is_numeric($amount) || (float)$amount <= 0) { echo json_encode(['success' => false, 'error' => 'Invalid amount']); exit(); } if (!pp_isSchoolReady($schoolName)) { echo json_encode(['success' => false, 'error' => 'This school cannot accept PayPal payments at this time']); exit(); } $merchantId = pp_getMerchantId($schoolName); if (!$merchantId) { echo json_encode(['success' => false, 'error' => 'School is not connected to PayPal']); exit(); } $formattedAmount = number_format((float)$amount, 2, '.', ''); $purchaseUnit = [ 'reference_id' => 'order-' . time(), 'amount' => [ 'currency_code' => $currency, 'value' => $formattedAmount, ], 'payee' => ['merchant_id' => $merchantId], 'soft_descriptor' => substr($schoolName, 0, 22), ]; // Line items — required when breakdown is present if (!empty($items)) { $itemTotal = array_sum(array_map( fn($i) => (float)($i['unit_amount']['value'] ?? 0) * (int)($i['quantity'] ?? 1), $items )); $purchaseUnit['items'] = $items; $purchaseUnit['amount']['breakdown'] = [ 'item_total' => [ 'currency_code' => $currency, 'value' => number_format($itemTotal, 2, '.', ''), ], ]; } $orderPayload = [ 'intent' => 'CAPTURE', 'purchase_units' => [$purchaseUnit], ]; // Buyer email prefill + PAY_NOW experience $experienceContext = [ 'user_action' => 'PAY_NOW', 'shipping_preference' => 'NO_SHIPPING', 'brand_name' => $schoolName, ]; if ($buyerEmail) { $orderPayload['payment_source'] = [ 'paypal' => [ 'email_address' => $buyerEmail, 'experience_context' => $experienceContext, ], ]; } else { $orderPayload['payment_source'] = [ 'paypal' => ['experience_context' => $experienceContext], ]; } if (function_exists('writeLog')) { writeLog('create_order: school=' . $schoolName . ' amount=' . $formattedAmount . ' currency=' . $currency); } $resp = $client->createOrder($merchantId, $orderPayload, $fraudnetId); if ($resp['status'] < 200 || $resp['status'] >= 300) { if (function_exists('writeLog')) { writeLog('create_order failed: debug_id=' . ($resp['debug_id'] ?? 'n/a') . ' body=' . json_encode($resp['body'])); } echo json_encode(['success' => false, 'error' => 'Order creation failed', 'debug_id' => $resp['debug_id']]); exit(); } if (function_exists('writeLog')) { writeLog('create_order success: order_id=' . ($resp['body']['id'] ?? '?') . ' debug_id=' . ($resp['debug_id'] ?? 'n/a')); } echo json_encode(['success' => true, 'order_id' => $resp['body']['id']]); exit(); } // ════════════════════════════════════════════════════════════ // CAPTURE ORDER // ════════════════════════════════════════════════════════════ if ($action === 'capture_order') { $orderId = trim($input['order_id'] ?? ''); $schoolName = trim($input['school_name'] ?? ''); if (!$orderId || !$schoolName) { echo json_encode(['success' => false, 'error' => 'order_id and school_name are required']); exit(); } $merchantId = pp_getMerchantId($schoolName); if (!$merchantId) { echo json_encode(['success' => false, 'error' => 'School is not connected to PayPal']); exit(); } if (function_exists('writeLog')) { writeLog('capture_order: order_id=' . $orderId . ' school=' . $schoolName); } $resp = $client->captureOrder($merchantId, $orderId); if ($resp['status'] < 200 || $resp['status'] >= 300) { if (function_exists('writeLog')) { writeLog('capture_order failed: debug_id=' . ($resp['debug_id'] ?? 'n/a') . ' body=' . json_encode($resp['body'])); } echo json_encode(['success' => false, 'error' => 'Payment capture failed', 'debug_id' => $resp['debug_id']]); exit(); } $captures = $resp['body']['purchase_units'][0]['payments']['captures'] ?? []; $capture = $captures[0] ?? []; $captureId = $capture['id'] ?? null; $captureStatus = $capture['status'] ?? ($resp['body']['status'] ?? 'UNKNOWN'); $captureAmount = (float)($capture['amount']['value'] ?? 0); if (function_exists('writeLog')) { writeLog('capture_order success: capture_id=' . $captureId . ' status=' . $captureStatus . ' debug_id=' . ($resp['debug_id'] ?? 'n/a')); } // ── Post the payment to the app ledger (server-side, idempotent) ────── // A failure here must NOT fail the response — the money is already // captured. We surface ledger_posted so the front-end / logs can flag // any capture that didn't book, for manual reconciliation. $userNumInput = (int)($input['user_num'] ?? 0); $ledger = ['posted' => false, 'reason' => 'not_completed']; if ($captureId && strtoupper($captureStatus) === 'COMPLETED') { $ledger = pp_recordPayment($schoolName, (string)$captureId, $captureAmount, $userNumInput ?: null); if (function_exists('writeLog')) { writeLog('capture_order ledger: posted=' . ($ledger['posted'] ? 'true' : 'false') . ' reason=' . $ledger['reason'] . ' capture_id=' . $captureId); } } echo json_encode([ 'success' => true, 'capture_id' => $captureId, 'status' => $captureStatus, 'ledger_posted' => $ledger['posted'], 'ledger_reason' => $ledger['reason'], ]); exit(); } // ════════════════════════════════════════════════════════════ // REFUND // ════════════════════════════════════════════════════════════ if ($action === 'refund') { $captureId = trim($input['capture_id'] ?? ''); $schoolName = trim($input['school_name'] ?? ''); $amount = trim($input['amount'] ?? ''); // omit → full refund $currency = strtoupper(trim($input['currency'] ?? 'USD')); $note = trim($input['note'] ?? ''); $idempotencyKey = trim($input['idempotency_key'] ?? ''); // caller may supply for safe retry if (!$captureId || !$schoolName) { echo json_encode(['success' => false, 'error' => 'capture_id and school_name are required']); exit(); } $merchantId = pp_getMerchantId($schoolName); if (!$merchantId) { echo json_encode(['success' => false, 'error' => 'School is not connected to PayPal']); exit(); } // Build refund body — empty = full refund (no body sent to PayPal) $refundData = []; if ($amount !== '' && is_numeric($amount) && (float)$amount > 0) { $refundData['amount'] = [ 'value' => number_format((float)$amount, 2, '.', ''), 'currency_code' => $currency, ]; } if ($note !== '') { $refundData['note_to_payer'] = substr($note, 0, 255); // PayPal max } $refundType = ($amount !== '') ? 'PARTIAL ' . $amount . ' ' . $currency : 'FULL'; if (function_exists('writeLog')) { writeLog('refund: capture_id=' . $captureId . ' school=' . $schoolName . ' type=' . $refundType); } $resp = $client->refundCapture($merchantId, $captureId, $refundData, $idempotencyKey); if ($resp['status'] < 200 || $resp['status'] >= 300) { if (function_exists('writeLog')) { writeLog('refund failed: debug_id=' . ($resp['debug_id'] ?? 'n/a') . ' body=' . json_encode($resp['body'])); } // Surface PayPal's error details for easier debugging $ppError = $resp['body']['message'] ?? ($resp['body']['error_description'] ?? 'Refund failed'); echo json_encode([ 'success' => false, 'error' => $ppError, 'debug_id' => $resp['debug_id'], ]); exit(); } $refundId = $resp['body']['id'] ?? null; $refundStatus = $resp['body']['status'] ?? 'UNKNOWN'; $refundAmount = $resp['body']['amount']['value'] ?? null; if (function_exists('writeLog')) { writeLog('refund success: refund_id=' . $refundId . ' status=' . $refundStatus . ' amount=' . ($refundAmount ?? '?') . ' debug_id=' . ($resp['debug_id'] ?? 'n/a')); } echo json_encode([ 'success' => true, 'refund_id' => $refundId, 'status' => $refundStatus, 'amount' => $refundAmount, ]); exit(); } echo json_encode(['success' => false, 'error' => 'Unknown action: ' . htmlspecialchars($action)]); } catch (Throwable $e) { if (function_exists('writeLog')) { writeLog('order-api exception: ' . $e->getMessage()); } echo json_encode(['success' => false, 'error' => $e->getMessage()]); }