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

470 lines
20 KiB
PHP

<?php
/**
* PayPal Order API
* Handles create_order and capture_order for buyer checkout.
* Called via AJAX from paypal-checkout-widget.php.
*
* POST body (JSON):
* { action, school_name, amount, currency, items, buyer_email, fraudnet_id }
* { action: "capture_order", order_id, school_name }
*/
// Include logging if available from main app
$_pp_appDir = '/m/data1/app.hotlunches.net';
ob_start();
if (file_exists('../include/logging-functions.php')) {
require_once '../include/logging-functions.php';
startLog('order-api');
}
ob_end_clean();
header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => 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()]);
}