470 lines
20 KiB
PHP
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()]);
|
|
}
|