First Commit

This commit is contained in:
mark committed 2026-10-19 15:22:52 -07:00
1 parent 5eb9dd037d
commit 2e62c0e60b
11877 files changed
+2035312

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
# Deny direct web access to files that hold secrets or internal data.
# These are only ever include()d by the app — never requested directly — so
# blocking URL access to them is safe and prevents source/secret disclosure
# (e.g. if PHP is ever misconfigured and serves .php as text).
<FilesMatch "^(config|db_config)\.php$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
# Block the CA bundle, any JSON, and gzipped backups from being fetched.
<FilesMatch "\.(json|gz|pem)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
File diff suppressed because it is too large. Load diff
+330
View File
@@ -0,0 +1,330 @@
<?php
/**
* School PayPal diagnostic — TEMPORARY, delete after checking.
* ────────────────────────────────────────────────────────────
* Open it from the school's own link (same subdomain the school uses):
* https://<school-link>/pp-integration/check-gateway.php?key=hl-gw-2026
* The school is resolved from the link exactly like the onboarding system
* does (data/tenants.json mapping, then humanized subdomain). You can also
* force a school explicitly:
* .../check-gateway.php?key=hl-gw-2026&school=Central+Valley+Elementary
*
* For the resolved school it reports, guard by guard, everything the
* "Pay with PayPal" button checks:
* 1. Is the checkout page deployed where the button looks for it?
* 2. Onboarding status — not started / pending / connected / ready
* (including near-miss name mismatches, since the merchant lookup
* is keyed by the EXACT school name)
* 3. Gateway setting in the database — Express Checkout or not
* 4. Final verdict: will the button render?
*/
if (($_GET['key'] ?? '') !== 'hl-gw-2026') {
http_response_code(403);
exit('Forbidden');
}
header('Content-Type: text/html; charset=utf-8');
if (!function_exists('h')) { function h(?string $s): string { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); } }
if (!function_exists('norm')) { function norm(string $s): string { return strtolower(trim(preg_replace('/\s+/', ' ', $s))); } }
// ── Resolve the school: ?school= first, else from the link (subdomain) ────────
require_once __DIR__ . '/lib/Tenant.php';
$resolvedBy = null;
$school = trim((string)($_GET['school'] ?? ''));
if ($school !== '') {
$resolvedBy = 'school parameter in the URL';
} else {
$sub = TenantResolver::currentSubdomain();
if ($sub !== null) {
// Same mapping the onboarding system uses: data/tenants.json, then
// a humanized subdomain (school-one → School One) as fallback.
$tenants = [];
$tenantsFile = __DIR__ . '/data/tenants.json';
if (is_file($tenantsFile)) {
$tenants = json_decode((string)file_get_contents($tenantsFile), true) ?: [];
}
if (!empty($tenants[$sub]) && is_string($tenants[$sub])) {
$school = $tenants[$sub];
$resolvedBy = "link subdomain \"$sub\" via tenants.json";
} else {
$school = ucwords(str_replace(['-', '_'], ' ', $sub));
$resolvedBy = "link subdomain \"$sub\" (humanized — no tenants.json entry)";
}
}
}
// ── Load onboarding data ──────────────────────────────────────────────────────
$merchants = [];
$mf = __DIR__ . '/data/school_merchants.json';
if (is_file($mf)) $merchants = json_decode((string)file_get_contents($mf), true) ?: [];
$pending = [];
$pf = __DIR__ . '/data/pending_onboarding.json';
if (is_file($pf)) $pending = json_decode((string)file_get_contents($pf), true) ?: [];
// ── Onboarding status for the resolved school ─────────────────────────────────
$onb = [
'status' => 'not_started', // not_started | pending | connected_not_ready | connected_ready
'exact' => null, // merchant record on exact name match
'nearMatches' => [], // similar names found under a DIFFERENT exact spelling
'pendings' => [], // pending onboarding attempts for this school
];
if ($school !== '') {
$rec = $merchants[$school] ?? null;
if ($rec && !empty($rec['merchant_id'])) {
$onb['exact'] = $rec;
$ready = !isset($rec['paypal_ready']) || (bool)$rec['paypal_ready'];
$onb['status'] = $ready ? 'connected_ready' : 'connected_not_ready';
}
// Near-miss detection: same name after normalisation, different exact key.
foreach ($merchants as $name => $m) {
if ($name !== $school && norm($name) === norm($school)) {
$onb['nearMatches'][] = $name;
}
}
// Pending attempts (exact or normalised match on the stored school name).
foreach ($pending as $trackingId => $p) {
$pName = (string)($p['school'] ?? '');
if ($pName === $school || norm($pName) === norm($school)) {
$onb['pendings'][] = [
'tracking_id' => $trackingId,
'school' => $pName,
'status' => (string)($p['status'] ?? '?'),
'created_at' => (int)($p['created_at'] ?? 0),
];
}
}
if ($onb['status'] === 'not_started' && $onb['pendings']) {
$onb['status'] = 'pending';
}
}
// ── Checkout page deployment check (guard #1 of the button) ───────────────────
// Mirrors the button's auto-detection: it probes these folders under the
// document root and uses the first one containing checkout.php.
$docroot = rtrim((string)($_SERVER['DOCUMENT_ROOT'] ?? ''), '/');
$candidates = ['/pp-integration', '/sm'];
$detectedBase = null;
foreach ($candidates as $c) {
if (is_file($docroot . $c . '/checkout.php')) { $detectedBase = $c; break; }
}
$checkoutHere = is_file(__DIR__ . '/checkout.php'); // where this script actually lives
$pathOk = $detectedBase !== null;
$actualFolder = str_replace('\\', '/', dirname((string)($_SERVER['SCRIPT_NAME'] ?? '')));
// ── Gateway setting from the database ─────────────────────────────────────────
$dbError = null; $gatewayCols = []; $gwRaw = ''; $gwEffective = null; $foundInDb = false; $rawRow = [];
try {
$cfg = require __DIR__ . '/db_config.php';
mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli($cfg['host'], $cfg['username'], $cfg['password'], $cfg['database']);
if ($db->connect_error) {
$dbError = 'Could not connect: ' . $db->connect_error;
} else {
$db->set_charset($cfg['charset'] ?? 'utf8mb4');
$res = $db->query('SHOW COLUMNS FROM schools');
$allCols = [];
while ($c = $res->fetch_assoc()) {
$allCols[] = $c['Field'];
$f = strtolower($c['Field']);
if (strpos($f, 'paypal') !== false || strpos($f, 'gateway') !== false || strpos($f, 'express') !== false) {
$gatewayCols[] = $c['Field'];
}
}
if ($school !== '' && $gatewayCols) {
$colList = implode('`,`', array_map(fn($c) => str_replace('`', '', $c), $gatewayCols));
$stmt = $db->prepare("SELECT `$colList` FROM schools WHERE school_name = ? LIMIT 1");
$stmt->bind_param('s', $school);
$stmt->execute();
$rawRow = $stmt->get_result()->fetch_assoc() ?: [];
$stmt->close();
$foundInDb = (bool)$rawRow;
// Mirror user/paypal_ppcp_button.php exactly.
foreach (['paypal_checkout', 'paypal_gateway', 'paypal_checkout_type'] as $bc) {
if (isset($rawRow[$bc]) && trim((string)$rawRow[$bc]) !== '') { $gwRaw = trim((string)$rawRow[$bc]); break; }
}
$gwEffective = $gwRaw === '' ? 'Express Checkout' : $gwRaw;
}
$db->close();
}
} catch (Throwable $e) {
$dbError = 'Error: ' . $e->getMessage();
}
$isExpress = ($gwEffective === null) ? null : ($gwEffective === 'Express Checkout');
$connected = in_array($onb['status'], ['connected_ready', 'connected_not_ready'], true);
$ready = $onb['status'] === 'connected_ready';
// Final verdict — every guard the button applies (balance > 0 can't be known here).
$buttonShows = $pathOk && $connected && $ready && ($isExpress !== false);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PayPal check<?= $school !== '' ? ' — ' . h($school) : '' ?></title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background:#f7f8fa; color:#1a202c; margin:0; padding:32px 20px; }
.wrap { max-width: 780px; margin: 0 auto; }
h1 { font-size:20px; margin:0 0 4px; }
.sub { font-size:13px; color:#718096; margin-bottom:22px; }
.card { background:#fff; border:1px solid #e2e8f0; border-radius:10px; padding:18px 20px; margin-bottom:16px; }
.card h2 { font-size:14px; margin:0 0 12px; }
table { border-collapse:collapse; width:100%; font-size:13.5px; }
th, td { text-align:left; padding:8px 10px; border-bottom:1px solid #f0f2f5; vertical-align:top; }
th { font-size:11px; text-transform:uppercase; letter-spacing:.5px; color:#718096; }
.yes { color:#276749; font-weight:600; }
.no { color:#c53030; font-weight:600; }
.mid { color:#b7791f; font-weight:600; }
.muted { color:#a0aec0; }
code { background:#f5f7fa; border:1px solid #e2e8f0; border-radius:4px; padding:1px 6px; font-size:12.5px; }
.warn { background:#fffaf0; border:1px solid #feebc8; color:#9c4221; border-radius:8px; padding:12px 14px; font-size:13px; margin:10px 0; line-height:1.55; }
.ok { background:#f0fff4; border:1px solid #c6f6d5; color:#276749; border-radius:8px; padding:12px 14px; font-size:13px; margin:10px 0; line-height:1.55; }
.bad { background:#fff5f5; border:1px solid #feb2b2; color:#c53030; border-radius:8px; padding:12px 14px; font-size:13px; margin:10px 0; line-height:1.55; }
.verdict { font-size:16px; font-weight:700; }
</style>
</head>
<body>
<div class="wrap">
<h1>School PayPal check</h1>
<p class="sub">Mirrors every rule the "Pay with PayPal" button applies. Delete this file when done.</p>
<?php if ($school === ''): ?>
<div class="bad">Couldn't work out which school this is — the link has no subdomain and no
<code>?school=</code> was given. Open this page from the school's own link, or add
<code>&amp;school=Exact School Name</code> to the URL.</div>
<div class="card">
<h2>Schools currently connected (school_merchants.json)</h2>
<?php if ($merchants): ?>
<table>
<tr><th>School</th><th>Merchant ID</th><th>Ready</th></tr>
<?php foreach ($merchants as $n => $m): ?>
<tr><td><?= h($n) ?></td><td><code><?= h($m['merchant_id'] ?? '—') ?></code></td>
<td><?= (!isset($m['paypal_ready']) || $m['paypal_ready']) ? '<span class="yes">yes</span>' : '<span class="no">no</span>' ?></td></tr>
<?php endforeach; ?>
</table>
<?php else: ?><p class="muted">None yet.</p><?php endif; ?>
</div>
<?php else: ?>
<div class="card">
<h2>School</h2>
<table>
<tr><td>Checking</td><td><strong><?= h($school) ?></strong></td></tr>
<tr><td>Resolved from</td><td><?= h($resolvedBy) ?></td></tr>
<tr><td>In schools database</td>
<td><?= $dbError ? '<span class="mid">unknown (DB unavailable)</span>' : ($foundInDb ? '<span class="yes">yes</span>' : '<span class="no">NO — name doesn\'t match any school_name in the DB</span>') ?></td></tr>
</table>
</div>
<div class="card">
<h2>1 &middot; Onboarding status</h2>
<?php if ($onb['status'] === 'connected_ready'): ?>
<div class="ok"><strong>Connected &amp; ready.</strong> Merchant ID
<code><?= h($onb['exact']['merchant_id']) ?></code><?php if (!empty($onb['exact']['connected_at'])): ?>,
connected <?= h(date('M j, Y', (int)$onb['exact']['connected_at'])) ?><?php endif; ?>.
PayPal reports this school can receive payments.</div>
<?php elseif ($onb['status'] === 'connected_not_ready'): ?>
<div class="warn"><strong>Connected, but NOT ready.</strong> The school linked its PayPal account
(merchant ID <code><?= h($onb['exact']['merchant_id']) ?></code>) but PayPal says it can't receive
payments yet — usually an unconfirmed email or incomplete business info in the school's PayPal
account. The button stays hidden until PayPal marks it ready.</div>
<?php elseif ($onb['status'] === 'pending'): ?>
<div class="warn"><strong>Onboarding started but never completed.</strong> The school opened the
onboarding flow but PayPal hasn't confirmed the connection. Ask the school to finish the PayPal
sign-up flow, or restart onboarding from the admin page.</div>
<?php else: ?>
<div class="bad"><strong>Not onboarded.</strong> No record of this school in
<code>school_merchants.json</code> and no pending attempt. This is why the button doesn't show —
the school needs to complete PayPal onboarding first.</div>
<?php endif; ?>
<?php if ($onb['nearMatches']): ?>
<div class="warn"><strong>Name mismatch detected!</strong> A connected school exists under a
slightly different spelling:
<?php foreach ($onb['nearMatches'] as $nm) echo '<code>' . h($nm) . '</code> '; ?><br>
The button looks the school up by its <strong>exact</strong> name
(<code><?= h($school) ?></code>), so this near-match is invisible to it. The stored name and the
app's <code>school_name</code> must match character-for-character.</div>
<?php endif; ?>
<?php if ($onb['pendings']): ?>
<table>
<tr><th>Pending attempt</th><th>Stored name</th><th>Status</th><th>Started</th></tr>
<?php foreach ($onb['pendings'] as $p): ?>
<tr><td><code><?= h($p['tracking_id']) ?></code></td><td><?= h($p['school']) ?></td>
<td><?= h($p['status']) ?></td>
<td><?= $p['created_at'] ? h(date('M j, Y H:i', $p['created_at'])) : '—' ?></td></tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</div>
<div class="card">
<h2>2 &middot; Checkout page deployment</h2>
<table>
<tr><td>This script's folder</td><td><code><?= h($actualFolder) ?></code></td></tr>
<tr><td>checkout.php next to this script</td>
<td><?= $checkoutHere ? '<span class="yes">yes</span>' : '<span class="no">MISSING</span>' ?></td></tr>
<tr><td>Folder the button auto-detects (probes <?php foreach ($candidates as $c) echo '<code>' . h($c) . '</code> '; ?>)</td>
<td><?= $pathOk ? '<span class="yes"><code>' . h($detectedBase) . '</code></span>' : '<span class="no">NONE FOUND</span>' ?></td></tr>
</table>
<?php if ($checkoutHere && !$pathOk): ?>
<div class="bad">The integration is deployed (this script is running), but <strong>not in any folder
the button probes</strong> under this site's document root. Either move it to
<code>/pp-integration</code>, or define <code>PPP_WEB_BASE</code> as
<code><?= h($actualFolder) ?></code> before the button include, or add
<code><?= h($actualFolder) ?></code> to the probe list in paypal_ppcp_button.php.</div>
<?php elseif ($pathOk && $checkoutHere && $detectedBase !== $actualFolder): ?>
<div class="warn">Note: this script runs from <code><?= h($actualFolder) ?></code> but the button will
use <code><?= h($detectedBase) ?></code> — there appear to be two copies of the integration
deployed. Make sure <code><?= h($detectedBase) ?></code> is the up-to-date one (it wins).</div>
<?php endif; ?>
</div>
<div class="card">
<h2>3 &middot; Gateway setting</h2>
<?php if ($dbError): ?>
<div class="warn"><strong>Database unavailable:</strong> <?= h($dbError) ?> — gateway can't be read.
(The button would treat an unreadable/unset value as Express Checkout.)</div>
<?php else: ?>
<table>
<tr><td>PayPal/gateway columns on <code>schools</code></td>
<td><?php echo $gatewayCols ? implode(' ', array_map(fn($c) => '<code>' . h($c) . '</code>', $gatewayCols)) : '<span class="muted">none found</span>'; ?></td></tr>
<tr><td>Stored gateway value</td>
<td><?= $gwRaw !== '' ? '<code>' . h($gwRaw) . '</code>' : '<span class="muted">unset → treated as Express Checkout</span>' ?></td></tr>
<tr><td>Express Checkout?</td>
<td><?= $isExpress === null ? '<span class="mid">unknown</span>' : ($isExpress ? '<span class="yes">yes</span>' : '<span class="no">no — this school keeps its existing REST API button</span>') ?></td></tr>
</table>
<?php if ($rawRow): ?>
<p style="font-size:12px;color:#a0aec0;margin:10px 0 0;">Raw values:
<?php foreach ($rawRow as $c => $v) echo '<code>' . h($c) . '=' . h($v === null ? 'NULL' : (string)$v) . '</code> '; ?></p>
<?php endif; ?>
<?php endif; ?>
</div>
<div class="card">
<h2>Verdict</h2>
<p class="verdict"><?= $buttonShows
? '<span class="yes">✔ The “Pay with PayPal” button WILL show for this school</span>'
: '<span class="no">✘ The button will NOT show for this school</span>' ?></p>
<p style="font-size:12.5px;color:#718096;margin-top:8px;">
(Assuming the parent has a balance to pay — the button also hides when the amount owing is
<?= h('≤') ?> $0, which this page can't check.)</p>
</div>
<?php endif; ?>
</div>
</body>
</html>
+329
View File
@@ -0,0 +1,329 @@
<?php
/**
* PayPal Checkout — order details page
* ------------------------------------
* Opened by the "Pay with PayPal" button on the parent payment page
* (user/paypal_ppcp_button.php). Shows the order summary — account balance
* or deposit amount, the school's transaction fee, and the total — next to
* every PayPal-supported payment method (PayPal, Venmo, Pay Later,
* Apple Pay, Google Pay), all rendered by paypal-checkout-widget.php.
*
* The order context arrives via the shared 'hotlunch' session (written by
* the button include), never via the URL, so amounts can't be tampered
* with. The context expires after an hour; an expired or missing context
* shows a friendly "go back" page instead of a broken checkout.
*
* Money flow: order-api.php creates the order with
* payee.merchant_id = <school's onboarded merchant id>, so the capture
* settles directly in the school's PayPal account, and then books the
* payment into the user_account ledger (idempotent on capture_id).
*/
// -- Attach to the main app session (same domain, session name 'hotlunch') -----
if (session_status() !== PHP_SESSION_ACTIVE) {
@session_name('hotlunch');
@session_start();
}
$ctx = $_SESSION['ppp_checkout'] ?? null;
$ctxValid = is_array($ctx)
&& (float)($ctx['total_amount'] ?? 0) > 0
&& trim((string)($ctx['school_name'] ?? '')) !== ''
&& (time() - (int)($ctx['created_at'] ?? 0)) < 3600;
// -- Confirm the school is still connected & ready before rendering payment ----
$schoolReady = false;
if ($ctxValid) {
$merchantsFile = __DIR__ . '/data/school_merchants.json';
if (is_file($merchantsFile)) {
$merchants = json_decode((string)file_get_contents($merchantsFile), true) ?: [];
$record = $merchants[$ctx['school_name']] ?? null;
if ($record && !empty($record['merchant_id'])) {
$schoolReady = !isset($record['paypal_ready']) || (bool)$record['paypal_ready'];
}
}
}
if ($ctxValid) {
$schoolName = (string)$ctx['school_name'];
$symbol = (string)($ctx['currency_symbol'] ?? '$');
$baseAmount = (string)$ctx['base_amount'];
$feeAmount = (string)($ctx['fee_amount'] ?? '0.00');
$total = (string)$ctx['total_amount'];
$baseLabel = (string)($ctx['base_label'] ?? 'Account balance');
$feeLabel = (string)($ctx['fee_label'] ?? 'Transaction fee');
$returnUrl = (string)($ctx['return_url'] ?? '/');
$buyerEmail = (string)($ctx['buyer_email'] ?? '');
$hasFee = (float)$feeAmount > 0;
$currency = strtoupper((string)($ctx['currency'] ?? 'USD'));
// -- Contract for paypal-checkout-widget.php -------------------------------
$items = [[
'name' => $baseLabel,
'quantity' => '1',
'unit_amount' => ['currency_code' => $currency, 'value' => $baseAmount],
]];
if ($hasFee) {
$items[] = [
'name' => $feeLabel,
'quantity' => '1',
'unit_amount' => ['currency_code' => $currency, 'value' => $feeAmount],
];
}
$ppCheckout = [
'school_name' => $schoolName,
'amount' => $total,
'currency' => $currency,
'items' => $items,
'buyer_email' => $buyerEmail,
'page_type' => 'checkout',
'order_api_url' => './order-api.php',
'success_url' => $returnUrl,
'cancel_url' => '',
'user_num' => (int)($ctx['user_num'] ?? 0),
];
} else {
$schoolName = '';
$returnUrl = is_array($ctx) ? (string)($ctx['return_url'] ?? '/') : '/';
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $ctxValid ? 'Checkout — ' . htmlspecialchars($schoolName) : 'Checkout' ?></title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f7f8fa;
color: #1a202c;
min-height: 100vh;
}
a { color: #003087; }
/* -- Top bar -- */
.topbar {
background: #fff;
border-bottom: 1px solid #e2e8f0;
padding: 14px 24px;
display: flex;
align-items: center;
gap: 10px;
}
.topbar-school { font-weight: 700; font-size: 16px; color: #003087; }
.topbar-secure {
margin-left: auto;
font-size: 12px;
color: #718096;
display: flex;
align-items: center;
gap: 5px;
}
/* -- Layout -- */
.page {
max-width: 920px;
margin: 36px auto 48px;
padding: 0 20px;
}
.page-head { margin-bottom: 26px; }
.page-head h1 { font-size: 24px; font-weight: 700; margin-bottom: 6px; }
.page-head p { font-size: 14px; color: #718096; }
.cols {
display: grid;
grid-template-columns: 380px 1fr;
gap: 28px;
align-items: start;
}
@media (max-width: 760px) { .cols { grid-template-columns: 1fr; } }
/* -- Cards -- */
.card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 24px;
}
.card h2 {
font-size: 15px;
font-weight: 700;
margin-bottom: 4px;
}
.card .card-sub {
font-size: 13px;
color: #718096;
margin-bottom: 18px;
line-height: 1.5;
}
/* -- Order summary -- */
.sum-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 9px 0;
font-size: 14px;
color: #4a5568;
border-bottom: 1px solid #f0f2f5;
}
.sum-row .amt { font-variant-numeric: tabular-nums; white-space: nowrap; }
.sum-total {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 14px 0 2px;
font-size: 16px;
font-weight: 700;
}
.sum-total .amt { font-size: 22px; font-variant-numeric: tabular-nums; }
.sum-note {
margin-top: 16px;
padding: 12px 14px;
background: #f5f7fa;
border-radius: 8px;
font-size: 12.5px;
color: #4a5568;
line-height: 1.55;
}
.sum-account {
margin-top: 14px;
font-size: 12.5px;
color: #718096;
line-height: 1.5;
}
/* -- After-widget link back -- */
.back-link {
display: inline-block;
margin-top: 2px;
font-size: 13.5px;
}
/* -- Expired / not-ready notice -- */
.notice {
max-width: 480px;
margin: 70px auto;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 34px 30px;
text-align: center;
}
.notice h1 { font-size: 19px; margin-bottom: 10px; }
.notice p { font-size: 14px; color: #718096; line-height: 1.6; margin-bottom: 22px; }
.notice a.btn-back {
display: inline-block;
background: #003087;
color: #fff;
text-decoration: none;
font-size: 14px;
font-weight: 600;
padding: 11px 26px;
border-radius: 8px;
}
.notice a.btn-back:hover { background: #00256b; }
.foot {
text-align: center;
font-size: 12px;
color: #a0aec0;
margin-top: 34px;
line-height: 1.6;
}
</style>
</head>
<body>
<?php if (!$ctxValid): ?>
<div class="notice">
<h1>This checkout link has expired</h1>
<p>For your security, checkout sessions only stay open for a short while.
Head back to your account page and click <strong>Pay with PayPal</strong> again —
it only takes a second.</p>
<a class="btn-back" href="<?= htmlspecialchars($returnUrl) ?>">Back to my account</a>
</div>
<?php elseif (!$schoolReady): ?>
<div class="notice">
<h1>Online payment isn't available right now</h1>
<p><?= htmlspecialchars($schoolName) ?> can't accept PayPal payments at the moment.
Please go back and choose another payment method, or contact the school office.</p>
<a class="btn-back" href="<?= htmlspecialchars($returnUrl) ?>">Back to my account</a>
</div>
<?php else: ?>
<nav class="topbar">
<span class="topbar-school"><?= htmlspecialchars($schoolName) ?></span>
<span class="topbar-secure">&#128274; Secure checkout</span>
</nav>
<div class="page">
<div class="page-head">
<h1>Review &amp; pay</h1>
<p>You're paying <?= htmlspecialchars($schoolName) ?> through PayPal's secure checkout.</p>
</div>
<div class="cols">
<!-- Left: order summary -->
<div class="card">
<h2>Order summary</h2>
<p class="card-sub">Payment to <?= htmlspecialchars($schoolName) ?></p>
<div class="sum-row">
<span><?= htmlspecialchars($baseLabel) ?></span>
<span class="amt"><?= htmlspecialchars($symbol . number_format((float)$baseAmount, 2)) ?></span>
</div>
<?php if ($hasFee): ?>
<div class="sum-row">
<span><?= htmlspecialchars($feeLabel) ?></span>
<span class="amt"><?= htmlspecialchars($symbol . number_format((float)$feeAmount, 2)) ?></span>
</div>
<?php endif; ?>
<div class="sum-total">
<span>Total</span>
<span class="amt"><?= htmlspecialchars($symbol . number_format((float)$total, 2)) ?></span>
</div>
<div class="sum-note">
Your payment goes directly to the school's PayPal account and is credited
to your family account as soon as PayPal confirms it usually right away.
</div>
<?php if ($buyerEmail !== ''): ?>
<p class="sum-account">Paying as <?= htmlspecialchars($buyerEmail) ?></p>
<?php endif; ?>
</div>
<!-- Right: payment methods -->
<div class="card">
<h2>Payment method</h2>
<p class="card-sub">All payments are processed by PayPal &mdash; your card details never touch our servers.</p>
<?php require __DIR__ . '/paypal-checkout-widget.php'; ?>
<a class="back-link" href="<?= htmlspecialchars($returnUrl) ?>">&larr; Go back and choose a different payment method</a>
</div>
</div>
<p class="foot">
Questions about a charge? Contact the school office at <?= htmlspecialchars($schoolName) ?>.<br>
A PayPal receipt will be emailed to you after payment.
</p>
</div>
<?php endif; ?>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<?php
// Basic configuration for PayPal integration.
// Fill in your Sandbox (or Live) credentials from developer.paypal.com
// Compute a runtime base URL if no explicit override is provided (supports multi-tenant subdomains).
$envBaseUrl = getenv('PAYP_BASE_URL');
if ($envBaseUrl && is_string($envBaseUrl)) {
$computedBaseUrl = rtrim($envBaseUrl, '/');
} else {
$scheme = 'http';
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
$scheme = 'https';
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
$scheme = $_SERVER['HTTP_X_FORWARDED_PROTO'];
}
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
// e.g., /payp from /payp/index.php
$scriptDir = isset($_SERVER['SCRIPT_NAME']) ? str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'])) : '';
$scriptDir = rtrim($scriptDir, '/');
$computedBaseUrl = $scheme . '://' . $host . ($scriptDir ?: '');
}
return [
// 'sandbox' or 'live'
'env' => 'sandbox',
// PayPal REST App credentials for your Partner (platform) app
'client_id' => 'ASQZdduObsa_SOb-ls0n4OfUK4tLqGTpI4AMJH_QgX1f6IjwBnomcrMceCncY4y4_xlnLS4rM3jC7eS8',
'client_secret' => 'EHHn_pdtB7PjWhn4GKEWcAgK1z_hcgVt3ruGsdkiXEKGCofY0wYHFCYuSi2kSPrnajOmOtzr76zucDBL',
// Your Partner's PayPal Merchant ID (aka Payer ID). Required to look up merchant info by tracking_id
// Find this in Business Profile > Account IDs in your PayPal dashboard (sandbox or live as appropriate)
'partner_merchant_id' => 'WVCTUHZHST2FJ',
// Base URL used by browser redirects (auto-detected; override via PAYP_BASE_URL env if needed)
'base_url' => $computedBaseUrl,
// Storage path (must be writable by Apache/PHP)
'storage_path' => __DIR__ . DIRECTORY_SEPARATOR . 'data',
// CA bundle for cURL TLS verification. Some environments (notably Windows
// PHP builds) ship without a configured curl.cainfo, which makes every
// HTTPS call to PayPal fail with "unable to get local issuer certificate".
// We bundle a Mozilla CA file next to this config and use it when present;
// on servers with a system bundle already configured this is simply ignored.
'curl_ca_bundle' => is_file(__DIR__ . '/cacert.pem') ? __DIR__ . '/cacert.pem' : null,
// Pay Later promotional messaging ("Pay in 4 payments of $X") in the
// checkout widget and paypal-paylater-message.php. Set to false if the
// SDK's messaging component throws unhandled PAGE_TYPE exceptions —
// buttons and wallet payments are unaffected by turning this off.
'paylater_messaging' => false,
// Webhook ID from PayPal Developer Dashboard → Your App → Webhooks.
// Register webhook.php as the endpoint URL, then paste the generated ID here.
// Events to subscribe: MERCHANT.PARTNER-CONSENT.REVOKED
// CUSTOMER.MERCHANT-INTEGRATION.PRODUCT-SUBSCRIPTION-UPDATED
'webhook_id' => '8X0726325N7595153',
];
+730
View File
@@ -0,0 +1,730 @@
<?php
session_name('pp_admin');
session_start();
$ADMIN_USER = 'admin';
$ADMIN_PASS = 'admin';
$dataDir = __DIR__ . '/data';
$error = '';
// ── Diagnostic endpoint ──────────────────────────────────────────────────
// Visit dashboard.php?debug=1 to see which data path the dashboard READS from.
// Compare its realpath against onboard.php?debug=1 — if they differ, the
// onboarding writes and the dashboard reads are two different files.
if (isset($_GET['debug'])) {
header('Content-Type: text/plain');
$merchantsFile = $dataDir . '/school_merchants.json';
echo "=== dashboard.php diagnostics ===\n";
echo "__DIR__ : " . __DIR__ . "\n";
echo "realpath(__DIR__) : " . realpath(__DIR__) . "\n";
echo "dataDir realpath : " . (realpath($dataDir) ?: '(does not exist)') . "\n";
echo "merchants file : $merchantsFile\n";
echo "merchants realpath : " . (realpath($merchantsFile) ?: '(MISSING)') . "\n";
echo "\n--- school_merchants.json (" . (file_exists($merchantsFile) ? filesize($merchantsFile) . ' bytes' : 'MISSING') . ") ---\n";
echo file_exists($merchantsFile) ? file_get_contents($merchantsFile) : '(none)';
exit();
}
// ── Logout ─────────────────────────────────────────────────────────────────
if (isset($_GET['logout'])) {
$_SESSION = [];
session_destroy();
header('Location: dashboard.php');
exit();
}
// ── Login POST ──────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['_login'])) {
if (($_POST['username'] ?? '') === $ADMIN_USER && ($_POST['password'] ?? '') === $ADMIN_PASS) {
$_SESSION['admin'] = true;
header('Location: dashboard.php');
exit();
}
$error = 'Invalid username or password.';
}
// ── AJAX actions (must be logged in) ───────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && ($_SESSION['admin'] ?? false)) {
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
require_once __DIR__ . '/lib/PayPalClient.php';
try {
$client = new PayPalClient($config);
$merchantsFile = $dataDir . '/school_merchants.json';
$merchants = file_exists($merchantsFile) ? (json_decode(file_get_contents($merchantsFile), true) ?: []) : [];
if ($_POST['action'] === 'check_status') {
$school = trim($_POST['school_name'] ?? '');
$merchantId = $merchants[$school]['merchant_id'] ?? null;
if (!$merchantId) {
echo json_encode(['success' => false, 'error' => 'School not found or not connected']);
exit();
}
$s = $client->getSellerStatus($config['partner_merchant_id'], $merchantId);
$merchants[$school] = array_merge($merchants[$school], [
'paypal_ready' => $s['paypal_ready'],
'email_confirmed' => $s['email_confirmed'],
'payments_receivable' => $s['payments_receivable'],
'oauth_ok' => $s['oauth_ok'],
'seller_email' => $s['seller_email'],
'status_issues' => $s['issues'],
'status_checked_at' => time(),
]);
file_put_contents($merchantsFile, json_encode($merchants, JSON_PRETTY_PRINT));
echo json_encode(array_merge(['success' => true], $s));
exit();
}
if ($_POST['action'] === 'check_all') {
$refreshed = 0;
foreach ($merchants as $school => &$data) {
$mid = $data['merchant_id'] ?? null;
if (!$mid) continue;
$s = $client->getSellerStatus($config['partner_merchant_id'], $mid);
$data = array_merge($data, [
'paypal_ready' => $s['paypal_ready'],
'email_confirmed' => $s['email_confirmed'],
'payments_receivable' => $s['payments_receivable'],
'oauth_ok' => $s['oauth_ok'],
'seller_email' => $s['seller_email'],
'status_issues' => $s['issues'],
'status_checked_at' => time(),
]);
$refreshed++;
}
unset($data);
file_put_contents($merchantsFile, json_encode($merchants, JSON_PRETTY_PRINT));
echo json_encode(['success' => true, 'refreshed' => $refreshed]);
exit();
}
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit();
}
// ── Load data (only needed for authenticated view) ─────────────────────────
$merchants = [];
$pending = [];
$total = 0;
$ready = 0;
$withIssues = 0;
$pendingCount = 0;
if ($_SESSION['admin'] ?? false) {
$merchantsFile = $dataDir . '/school_merchants.json';
if (file_exists($merchantsFile)) {
$merchants = json_decode(file_get_contents($merchantsFile), true) ?: [];
}
$pendingFile = $dataDir . '/pending_onboarding.json';
if (file_exists($pendingFile)) {
$pending = json_decode(file_get_contents($pendingFile), true) ?: [];
}
$total = count($merchants);
foreach ($merchants as $m) {
if ($m['paypal_ready'] ?? false) $ready++;
if (!empty($m['status_issues'])) $withIssues++;
}
foreach ($pending as $p) {
if (in_array($p['status'] ?? '', ['pending', 'waiting_for_paypal', 'incomplete_onboarding'])) {
$pendingCount++;
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PayPal Admin Dashboard</title>
<style>
/* ── Reset & base ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--pp-blue: #003087;
--pp-sky: #009cde;
--pp-gold: #ffc439;
--success: #28a745;
--warning: #fd7e14;
--danger: #dc3545;
--bg: #f0f4f8;
--card: #ffffff;
--border: #e2e8f0;
--text: #1a202c;
--muted: #718096;
--radius: 10px;
--shadow: 0 2px 12px rgba(0,0,0,.08);
--shadow-lg: 0 8px 32px rgba(0,0,0,.14);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* ── Login page ── */
.login-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--pp-blue) 0%, var(--pp-sky) 100%);
padding: 20px;
}
.login-card {
background: var(--card);
border-radius: 16px;
box-shadow: var(--shadow-lg);
padding: 48px 40px;
width: 100%;
max-width: 400px;
}
.login-logo {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 32px;
}
.login-logo svg { width: 36px; height: 36px; }
.login-logo span { font-size: 22px; font-weight: 700; color: var(--pp-blue); }
.login-card h2 { font-size: 20px; color: var(--text); margin-bottom: 6px; }
.login-card p { font-size: 14px; color: var(--muted); margin-bottom: 28px; }
.field { margin-bottom: 18px; }
.field label { display: block; font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 6px; }
.field input {
width: 100%; padding: 11px 14px;
border: 1.5px solid var(--border); border-radius: 8px;
font-size: 15px; transition: border-color .2s;
}
.field input:focus { outline: none; border-color: var(--pp-sky); }
.btn-primary {
width: 100%; padding: 13px;
background: var(--pp-blue); color: #fff;
border: none; border-radius: 8px;
font-size: 15px; font-weight: 600; cursor: pointer;
transition: background .2s, transform .1s;
}
.btn-primary:hover { background: #002070; transform: translateY(-1px); }
.alert-err {
background: #fff5f5; border: 1px solid #feb2b2;
color: #c53030; border-radius: 8px;
padding: 11px 14px; font-size: 13px; margin-bottom: 20px;
}
/* ── Dashboard layout ── */
.topbar {
background: var(--pp-blue);
padding: 0 28px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 8px rgba(0,0,0,.18);
}
.topbar-brand {
display: flex; align-items: center; gap: 10px;
color: #fff; font-size: 17px; font-weight: 700;
}
.topbar-brand svg { width: 28px; height: 28px; }
.topbar-right { display: flex; align-items: center; gap: 14px; }
.topbar-env {
background: rgba(255,255,255,.15);
color: #fff; font-size: 11px; font-weight: 700;
padding: 3px 10px; border-radius: 20px; letter-spacing: .5px; text-transform: uppercase;
}
.topbar-user {
color: rgba(255,255,255,.85); font-size: 13px;
}
.btn-logout {
background: rgba(255,255,255,.12); color: #fff;
border: 1px solid rgba(255,255,255,.3); border-radius: 7px;
padding: 6px 14px; font-size: 13px; cursor: pointer;
text-decoration: none; transition: background .2s;
}
.btn-logout:hover { background: rgba(255,255,255,.22); }
.main { padding: 28px; max-width: 1300px; margin: 0 auto; }
/* ── Stat cards ── */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px,1fr)); gap: 18px; margin-bottom: 28px; }
.stat-card {
background: var(--card); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 22px 24px;
display: flex; align-items: center; gap: 16px;
}
.stat-icon {
width: 46px; height: 46px; border-radius: 12px;
display: flex; align-items: center; justify-content: center;
font-size: 20px; flex-shrink: 0;
}
.stat-icon.blue { background: #ebf4ff; }
.stat-icon.green { background: #f0fff4; }
.stat-icon.red { background: #fff5f5; }
.stat-icon.orange { background: #fffaf0; }
.stat-label { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .4px; }
.stat-value { font-size: 28px; font-weight: 700; color: var(--text); line-height: 1.1; margin-top: 2px; }
/* ── Section header ── */
.section-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px; flex-wrap: wrap; gap: 10px;
}
.section-title { font-size: 17px; font-weight: 700; color: var(--text); }
.section-sub { font-size: 13px; color: var(--muted); margin-top: 2px; }
.btn-action {
padding: 9px 18px; border-radius: 8px; font-size: 13px; font-weight: 600;
border: none; cursor: pointer; transition: all .2s; white-space: nowrap;
}
.btn-action.blue { background: var(--pp-sky); color: #fff; }
.btn-action.blue:hover { background: #0089c7; }
.btn-action.green { background: var(--success); color: #fff; }
.btn-action.green:hover { background: #218838; }
.btn-action.ghost { background: var(--card); color: var(--pp-blue); border: 1.5px solid var(--pp-sky); }
.btn-action.ghost:hover { background: #ebf8ff; }
.btn-action:disabled { opacity: .55; cursor: not-allowed; }
/* ── Table ── */
.table-wrap {
background: var(--card); border-radius: var(--radius);
box-shadow: var(--shadow); overflow: hidden;
}
.table-scroll { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
thead tr { background: #f7fafc; }
thead th {
padding: 13px 16px; text-align: left;
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .5px; color: var(--muted);
border-bottom: 1px solid var(--border); white-space: nowrap;
}
tbody tr { border-bottom: 1px solid var(--border); transition: background .15s; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: #f7fafc; }
td { padding: 14px 16px; vertical-align: middle; }
.school-name { font-weight: 600; color: var(--text); }
.merchant-id { font-size: 11px; color: var(--muted); font-family: monospace; margin-top: 2px; }
.email-cell { color: var(--pp-blue); font-weight: 500; }
.email-none { color: var(--muted); font-style: italic; }
/* badges */
.badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: 20px;
font-size: 12px; font-weight: 600; white-space: nowrap;
}
.badge-green { background: #f0fff4; color: #276749; }
.badge-red { background: #fff5f5; color: #9b2c2c; }
.badge-orange { background: #fffaf0; color: #9c4221; }
/* flag icons */
.flag { display: inline-block; width: 20px; height: 20px; border-radius: 50%; text-align: center; line-height: 20px; font-size: 11px; font-weight: 700; }
.flag-ok { background: #c6f6d5; color: #276749; }
.flag-no { background: #fed7d7; color: #9b2c2c; }
.flag-unk { background: #e2e8f0; color: #718096; }
.flags-row { display: flex; gap: 6px; align-items: center; }
.date-cell { color: var(--muted); font-size: 12px; white-space: nowrap; }
/* issues tooltip area */
.issues-list {
margin-top: 6px; font-size: 12px; color: #c53030;
list-style: none; padding: 0;
}
.issues-list li::before { content: '⚠ '; }
/* ── Pending section ── */
.pending-wrap {
background: var(--card); border-radius: var(--radius);
box-shadow: var(--shadow); margin-top: 24px;
}
.pending-header {
padding: 16px 20px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 8px;
font-size: 15px; font-weight: 700;
}
.pending-row {
padding: 14px 20px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px;
}
.pending-row:last-child { border-bottom: none; }
.pending-school { font-weight: 600; }
.pending-meta { font-size: 12px; color: var(--muted); margin-top: 2px; }
/* ── Empty state ── */
.empty-state {
text-align: center; padding: 60px 20px; color: var(--muted);
}
.empty-state .empty-icon { font-size: 48px; margin-bottom: 12px; }
.empty-state p { font-size: 15px; }
/* ── Toast ── */
#toast {
position: fixed; bottom: 24px; right: 24px;
background: #1a202c; color: #fff;
padding: 12px 20px; border-radius: 9px;
font-size: 14px; box-shadow: var(--shadow-lg);
display: none; z-index: 9999;
animation: fadeIn .2s ease;
}
#toast.show { display: block; }
#toast.success { background: #276749; }
#toast.error { background: #9b2c2c; }
@keyframes fadeIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
@media (max-width: 640px) {
.main { padding: 16px; }
.topbar { padding: 0 16px; }
.login-card { padding: 32px 24px; }
}
</style>
</head>
<body>
<?php if (!($_SESSION['admin'] ?? false)): ?>
<!-- ════════════════════ LOGIN PAGE ════════════════════ -->
<div class="login-wrap">
<div class="login-card">
<div class="login-logo">
<!-- PayPal P icon -->
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.5 21H4.5L7 3H14C17.5 3 19.5 5 19 8C18.5 11.5 15.5 13 12.5 13H9.5L8.5 18H11L10.5 21H7.5Z" fill="#003087"/>
<path d="M9 17H6L8.5 3H15.5C19 3 21 5 20.5 8.5C20 12 17 13.5 14 13.5H11L10 18H12.5L12 21H9Z" fill="#009cde" opacity=".7"/>
</svg>
<span>PayPal Admin</span>
</div>
<h2>Welcome back</h2>
<p>Sign in to manage your PayPal merchant connections.</p>
<?php if ($error): ?>
<div class="alert-err"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="POST">
<input type="hidden" name="_login" value="1">
<div class="field">
<label for="u">Username</label>
<input type="text" id="u" name="username" autocomplete="username" placeholder="admin" required>
</div>
<div class="field">
<label for="p">Password</label>
<input type="password" id="p" name="password" autocomplete="current-password" placeholder="••••••" required>
</div>
<button type="submit" class="btn-primary">Sign in</button>
</form>
</div>
</div>
<?php else: ?>
<!-- ════════════════════ DASHBOARD ════════════════════ -->
<div id="toast"></div>
<!-- Top bar -->
<nav class="topbar">
<div class="topbar-brand">
<svg viewBox="0 0 24 24" fill="none">
<path d="M7.5 21H4.5L7 3H14C17.5 3 19.5 5 19 8C18.5 11.5 15.5 13 12.5 13H9.5L8.5 18H11L10.5 21H7.5Z" fill="#fff"/>
<path d="M9 17H6L8.5 3H15.5C19 3 21 5 20.5 8.5C20 12 17 13.5 14 13.5H11L10 18H12.5L12 21H9Z" fill="#ffc439" opacity=".85"/>
</svg>
<span>PayPal Admin Dashboard</span>
</div>
<div class="topbar-right">
<?php
$config = require __DIR__ . '/config.php';
$envLabel = ($config['env'] ?? 'sandbox') === 'live' ? 'Live' : 'Sandbox';
?>
<span class="topbar-env"><?= htmlspecialchars($envLabel) ?></span>
<span class="topbar-user">admin</span>
<a href="?logout=1" class="btn-logout">Sign out</a>
</div>
</nav>
<main class="main">
<!-- Stat cards -->
<div class="stats">
<div class="stat-card">
<div class="stat-icon blue">🏫</div>
<div>
<div class="stat-label">Total Schools</div>
<div class="stat-value"><?= $total ?></div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon green">✅</div>
<div>
<div class="stat-label">PayPal Ready</div>
<div class="stat-value"><?= $ready ?></div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon red">⚠️</div>
<div>
<div class="stat-label">With Issues</div>
<div class="stat-value"><?= $withIssues ?></div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon orange">⏳</div>
<div>
<div class="stat-label">Pending</div>
<div class="stat-value"><?= $pendingCount ?></div>
</div>
</div>
</div>
<!-- Connected merchants table -->
<div class="section-header">
<div>
<div class="section-title">Connected Schools</div>
<div class="section-sub">Schools that have completed PayPal onboarding</div>
</div>
<button class="btn-action blue" id="checkAllBtn" <?= $total === 0 ? 'disabled' : '' ?>>
🔄 Refresh All Statuses
</button>
</div>
<div class="table-wrap">
<?php if (empty($merchants)): ?>
<div class="empty-state">
<div class="empty-icon">🏫</div>
<p>No schools connected yet. Use the onboarding page to connect a school.</p>
</div>
<?php else: ?>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>School</th>
<th>PayPal Email</th>
<th>Status</th>
<th title="Email Confirmed / Payments Receivable / OAuth OK">Flags</th>
<th>Connected</th>
<th>Last Checked</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="merchantsTbody">
<?php foreach ($merchants as $schoolName => $m):
$ready = $m['paypal_ready'] ?? null;
$emailConf = $m['email_confirmed'] ?? null;
$paymentsRecv = $m['payments_receivable'] ?? null;
$oauthOk = $m['oauth_ok'] ?? null;
$sellerEmail = $m['seller_email'] ?? null;
$issues = $m['status_issues'] ?? [];
$connectedAt = isset($m['connected_at']) ? date('M j, Y', $m['connected_at']) : '—';
$checkedAt = isset($m['status_checked_at']) ? date('M j, Y g:i a', $m['status_checked_at']) : 'Never';
$statusBadge = $ready === null
? '<span class="badge badge-orange">⚪ Unknown</span>'
: ($ready
? '<span class="badge badge-green">✔ Ready</span>'
: '<span class="badge badge-red">✖ Issues</span>');
$flag = fn($v) => $v === null
? '<span class="flag flag-unk" title="Not checked">?</span>'
: ($v ? '<span class="flag flag-ok" title="OK">✓</span>' : '<span class="flag flag-no" title="Failing">✗</span>');
?>
<tr id="row-<?= htmlspecialchars(base64_encode($schoolName)) ?>">
<td>
<div class="school-name"><?= htmlspecialchars($schoolName) ?></div>
<div class="merchant-id"><?= htmlspecialchars($m['merchant_id'] ?? '—') ?></div>
</td>
<td>
<?php if ($sellerEmail): ?>
<span class="email-cell"><?= htmlspecialchars($sellerEmail) ?></span>
<?php else: ?>
<span class="email-none">Not checked yet</span>
<?php endif; ?>
</td>
<td>
<?= $statusBadge ?>
<?php if (!empty($issues)): ?>
<ul class="issues-list">
<?php foreach ($issues as $iss): ?>
<li><?= htmlspecialchars($iss) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</td>
<td>
<div class="flags-row" title="Email Confirmed / Payments Receivable / OAuth OK">
<?= $flag($emailConf) ?>
<?= $flag($paymentsRecv) ?>
<?= $flag($oauthOk) ?>
</div>
<div style="font-size:10px;color:var(--muted);margin-top:4px;">Email / Pay / OAuth</div>
</td>
<td class="date-cell"><?= htmlspecialchars($connectedAt) ?></td>
<td class="date-cell" id="checked-<?= htmlspecialchars(base64_encode($schoolName)) ?>"><?= htmlspecialchars($checkedAt) ?></td>
<td>
<button class="btn-action ghost refresh-btn"
data-school="<?= htmlspecialchars($schoolName) ?>">
Refresh
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<!-- Pending schools -->
<?php
$pendingRows = array_filter($pending, fn($p) => in_array($p['status'] ?? '', ['pending','waiting_for_paypal','incomplete_onboarding']));
?>
<?php if (!empty($pendingRows)): ?>
<div class="pending-wrap" style="margin-top:24px;">
<div class="pending-header">
⏳ Pending Onboarding
<span style="font-size:12px;color:var(--muted);font-weight:400;margin-left:4px;">(<?= count($pendingRows) ?> school<?= count($pendingRows) > 1 ? 's' : '' ?>)</span>
</div>
<?php foreach ($pendingRows as $tid => $p): ?>
<div class="pending-row">
<div>
<div class="pending-school"><?= htmlspecialchars($p['school'] ?? $tid) ?></div>
<div class="pending-meta">
Status: <strong><?= htmlspecialchars($p['status']) ?></strong>
· Started: <?= isset($p['created_at']) ? date('M j, Y', $p['created_at']) : '—' ?>
</div>
</div>
<span class="badge badge-orange">⏳ <?= htmlspecialchars(ucfirst(str_replace('_', ' ', $p['status']))) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</main>
<?php endif; ?>
<script>
function showToast(msg, type = 'success') {
const t = document.getElementById('toast');
if (!t) return;
t.textContent = msg;
t.className = 'show ' + type;
setTimeout(() => { t.className = ''; }, 3500);
}
function flagHtml(val, title) {
if (val === null || val === undefined) return `<span class="flag flag-unk" title="${title} — not checked">?</span>`;
return val
? `<span class="flag flag-ok" title="${title} — OK">✓</span>`
: `<span class="flag flag-no" title="${title} — Failing">✗</span>`;
}
function statusBadge(ready) {
if (ready === null || ready === undefined) return '<span class="badge badge-orange">⚪ Unknown</span>';
return ready
? '<span class="badge badge-green">✔ Ready</span>'
: '<span class="badge badge-red">✖ Issues</span>';
}
function updateRow(schoolName, data) {
const key = btoa(unescape(encodeURIComponent(schoolName)));
const row = document.getElementById('row-' + key);
if (!row) return;
const cells = row.querySelectorAll('td');
// email
cells[1].innerHTML = data.seller_email
? `<span class="email-cell">${escHtml(data.seller_email)}</span>`
: `<span class="email-none">Not available</span>`;
// status + issues
let statusHtml = statusBadge(data.paypal_ready);
if (data.issues && data.issues.length) {
statusHtml += '<ul class="issues-list">' + data.issues.map(i => `<li>${escHtml(i)}</li>`).join('') + '</ul>';
}
cells[2].innerHTML = statusHtml;
// flags
cells[3].innerHTML =
`<div class="flags-row" title="Email Confirmed / Payments Receivable / OAuth OK">
${flagHtml(data.email_confirmed, 'Email Confirmed')}
${flagHtml(data.payments_receivable, 'Payments Receivable')}
${flagHtml(data.oauth_ok, 'OAuth OK')}
</div>
<div style="font-size:10px;color:var(--muted);margin-top:4px;">Email / Pay / OAuth</div>`;
// last checked
const checkedCell = document.getElementById('checked-' + key);
if (checkedCell) checkedCell.textContent = new Date().toLocaleString('en-US', {month:'short',day:'numeric',year:'numeric',hour:'numeric',minute:'2-digit'});
}
function escHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Per-row refresh buttons
document.querySelectorAll('.refresh-btn').forEach(btn => {
btn.addEventListener('click', function () {
const school = this.dataset.school;
this.disabled = true;
this.textContent = '…';
fetch('dashboard.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'action=check_status&school_name=' + encodeURIComponent(school)
})
.then(r => r.json())
.then(data => {
if (data.success) {
updateRow(school, data);
showToast('Status refreshed for ' + school, 'success');
} else {
showToast('Error: ' + (data.error || 'Unknown error'), 'error');
}
})
.catch(err => showToast('Request failed: ' + err.message, 'error'))
.finally(() => { this.disabled = false; this.textContent = 'Refresh'; });
});
});
// Refresh all
const checkAllBtn = document.getElementById('checkAllBtn');
if (checkAllBtn) {
checkAllBtn.addEventListener('click', function () {
this.disabled = true;
this.textContent = '⏳ Refreshing…';
fetch('dashboard.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'action=check_all'
})
.then(r => r.json())
.then(data => {
if (data.success) {
showToast('Refreshed ' + data.refreshed + ' school(s). Reloading…', 'success');
setTimeout(() => location.reload(), 1200);
} else {
showToast('Error: ' + (data.error || 'Unknown error'), 'error');
this.disabled = false;
this.textContent = '🔄 Refresh All Statuses';
}
})
.catch(err => {
showToast('Request failed: ' + err.message, 'error');
this.disabled = false;
this.textContent = '🔄 Refresh All Statuses';
});
});
}
</script>
</body>
</html>
@@ -0,0 +1,98 @@
{
"school_9cba7ae0d6": {
"school": "Central Valley Elementary",
"created_at": 1762292589,
"partner_referral_id": null,
"status": "pending"
},
"school_81af9380a7": {
"school": "Central Valley Elementary",
"created_at": 1762292895,
"partner_referral_id": null,
"status": "pending"
},
"school_a079a61572": {
"school": "Test school",
"created_at": 1780068671,
"partner_referral_id": null,
"status": "connected",
"merchant_id": "MHHP4TXF2ZYRQ",
"connected_at": 1780068726,
"paypal_ready": false
},
"school_7586eea336": {
"school": "School Testing",
"created_at": 1780078021,
"partner_referral_id": null,
"status": "connected",
"merchant_id": "Q2MS2VKUKGJNG",
"connected_at": 1780078132,
"paypal_ready": false
},
"school_a2504f5507": {
"school": "Central Valley Elementary",
"created_at": 1780078420,
"partner_referral_id": null,
"status": "pending"
},
"school_304203c846": {
"school": "Central Valley Elementary",
"created_at": 1781285161,
"partner_referral_id": null,
"status": "connected",
"merchant_id": "6PNH76RBWZRZU",
"connected_at": 1781285234,
"paypal_ready": true
},
"school_4659fb9395": {
"school": "Central Valley Elementary",
"created_at": 1781285638,
"partner_referral_id": null,
"status": "connected",
"merchant_id": "6PNH76RBWZRZU",
"connected_at": 1781285677,
"paypal_ready": true
},
"school_c731607525": {
"school": "Central Valley Elementary",
"created_at": 1785454214,
"partner_referral_id": null,
"status": "connected",
"merchant_id": "MHHP4TXF2ZYRQ",
"connected_at": 1785454319,
"paypal_ready": true
},
"school_717640756d": {
"school": "Dev testing school",
"created_at": 1785454635,
"partner_referral_id": null,
"status": "connected",
"merchant_id": "MHHP4TXF2ZYRQ",
"connected_at": 1785454662,
"paypal_ready": true
},
"school_f476f6a2ab": {
"school": "Central Valley Elementary",
"created_at": 1785454793,
"partner_referral_id": null,
"status": "connected",
"merchant_id": "NEG5HMUKCTQNL",
"connected_at": 1785454822,
"paypal_ready": true
},
"school_936c91a499": {
"school": "Testing School",
"created_at": 1785454841,
"partner_referral_id": null,
"status": "connected",
"merchant_id": "NEG5HMUKCTQNL",
"connected_at": 1785454876,
"paypal_ready": true
},
"school_48929a4c12": {
"school": "Central Valley Elementary",
"created_at": 1790673612,
"partner_referral_id": null,
"status": "pending"
}
}
+62
View File
@@ -0,0 +1,62 @@
{
"Central Valley Elementary": {
"merchant_id": "NEG5HMUKCTQNL",
"tracking_id": "school_f476f6a2ab",
"connected_at": 1785454822,
"paypal_ready": true,
"email_confirmed": true,
"payments_receivable": true,
"oauth_ok": true,
"seller_email": null,
"status_issues": [],
"status_checked_at": 1785454823
},
"Test school": {
"merchant_id": "MHHP4TXF2ZYRQ",
"tracking_id": "school_a079a61572",
"connected_at": 1780068726,
"paypal_ready": true,
"email_confirmed": true,
"payments_receivable": true,
"oauth_ok": true,
"seller_email": null,
"status_issues": [],
"status_checked_at": 1780078666
},
"School Testing": {
"merchant_id": "Q2MS2VKUKGJNG",
"tracking_id": "school_7586eea336",
"connected_at": 1780078132,
"paypal_ready": true,
"email_confirmed": true,
"payments_receivable": true,
"oauth_ok": true,
"seller_email": null,
"status_issues": [],
"status_checked_at": 1780078668
},
"Dev testing school": {
"merchant_id": "MHHP4TXF2ZYRQ",
"tracking_id": "school_717640756d",
"connected_at": 1785454662,
"paypal_ready": true,
"email_confirmed": true,
"payments_receivable": true,
"oauth_ok": true,
"seller_email": null,
"status_issues": [],
"status_checked_at": 1785454663
},
"Testing School": {
"merchant_id": "NEG5HMUKCTQNL",
"tracking_id": "school_936c91a499",
"connected_at": 1785454876,
"paypal_ready": true,
"email_confirmed": true,
"payments_receivable": true,
"oauth_ok": true,
"seller_email": null,
"status_issues": [],
"status_checked_at": 1785454877
}
}
Binary file not shown.
@@ -0,0 +1,14 @@
{
"school_9cba7ae0d6": {
"school": "Central Valley Elementary",
"created_at": 1762292589,
"partner_referral_id": null,
"status": "pending"
},
"school_81af9380a7": {
"school": "Central Valley Elementary",
"created_at": 1762292895,
"partner_referral_id": null,
"status": "pending"
}
}
@@ -0,0 +1,7 @@
{
"Central Valley Elementary": {
"merchant_id": "7A68CU68VGK8J",
"tracking_id": "school_45ecf71cad",
"connected_at": 1765317686
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
// Database configuration
// Update these with your MySQL credentials
return [
'host' => 'localhost',
'database' => 'hotlunch', // UPDATE THIS
'username' => 'ezssoftwaredb', // UPDATE THIS
'password' => 'EZsoftware7785422700@@', // UPDATE THIS
'charset' => 'utf8mb4'
];
+268
View File
@@ -0,0 +1,268 @@
<?php
/**
* Demo: Cart / Checkout Page
* Shows how to embed paypal-checkout-widget.php on a multi-item cart page.
*
* In your real app, replace $school, $cartItems, and $buyerEmail
* with values from your session and database.
*/
require_once __DIR__ . '/demo-helper.php';
// ── In your real app these come from session / DB ─────────────────────────────
// Auto-resolve to a PayPal-connected school so the buttons actually render.
// (Pass ?school=Name to force a specific connected school.)
$school = pp_demo_resolve_school($_GET['school'] ?? null);
$schoolReady = $school !== null;
$schoolLabel = $school ?? 'Demo School';
$buyerEmail = ''; // e.g. $_SESSION['user']['email']
$cartItems = [
['name' => 'Monthly Lunch Plan — October', 'qty' => 1, 'price' => 95.00, 'emoji' => '🍱'],
['name' => 'Morning Snack Pack × 20 days', 'qty' => 1, 'price' => 28.00, 'emoji' => '🥐'],
['name' => 'Juice Box Bundle × 20 days', 'qty' => 1, 'price' => 14.00, 'emoji' => '🧃'],
];
$currency = 'USD';
// ── End of real-app variables ─────────────────────────────────────────────────
// Calculate total + build PayPal line items
$total = 0.0;
$ppItems = [];
foreach ($cartItems as $item) {
$lineTotal = $item['price'] * $item['qty'];
$total += $lineTotal;
$ppItems[] = [
'name' => $item['name'],
'quantity' => (string)$item['qty'],
'unit_amount' => ['currency_code' => $currency, 'value' => number_format($item['price'], 2, '.', '')],
];
}
$ppCheckout = [
'school_name' => $schoolLabel,
'amount' => number_format($total, 2, '.', ''),
'currency' => $currency,
'items' => $ppItems,
'buyer_email' => $buyerEmail,
'page_type' => 'cart', // tells PayPal this is a cart page
'order_api_url' => './order-api.php',
'success_url' => './demo-success.php?school=' . urlencode($schoolLabel),
'cancel_url' => './demo-cart.php',
];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your Cart — <?= htmlspecialchars($schoolLabel) ?></title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f7f8fa;
color: #1a202c;
}
/* ── Top bar ── */
.topbar {
background: #fff;
border-bottom: 1px solid #e2e8f0;
padding: 14px 24px;
display: flex;
align-items: center;
gap: 12px;
}
.topbar-logo { font-size: 22px; }
.topbar-name { font-weight: 700; font-size: 17px; color: #003087; }
.topbar-school { font-size: 13px; color: #718096; margin-left: auto; }
.topbar-back {
font-size: 13px; color: #003087; text-decoration: none;
padding: 6px 14px; border: 1px solid #bee3f8; border-radius: 6px;
background: #ebf8ff;
}
/* ── Layout ── */
.page { max-width: 960px; margin: 32px auto; padding: 0 20px; display: grid; grid-template-columns: 1fr 400px; gap: 28px; align-items: start; }
@media (max-width: 700px) { .page { grid-template-columns: 1fr; } }
/* ── Cart items ── */
.cart-card {
background: #fff;
border-radius: 12px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
.cart-header {
padding: 18px 24px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.cart-header h2 { font-size: 17px; font-weight: 700; }
.cart-count { font-size: 13px; color: #718096; }
.cart-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
}
.cart-item:last-child { border-bottom: none; }
.cart-item-emoji { font-size: 32px; flex-shrink: 0; }
.cart-item-name { font-size: 15px; font-weight: 600; margin-bottom: 3px; }
.cart-item-sub { font-size: 13px; color: #718096; }
.cart-item-price { margin-left: auto; font-size: 16px; font-weight: 700; white-space: nowrap; }
/* ── Order summary panel ── */
.summary-panel {
background: #fff;
border-radius: 12px;
border: 1px solid #e2e8f0;
padding: 24px;
position: sticky;
top: 24px;
}
.summary-title { font-size: 16px; font-weight: 700; margin-bottom: 18px; }
.summary-row {
display: flex;
justify-content: space-between;
font-size: 14px;
color: #4a5568;
padding: 6px 0;
}
.summary-row.total {
border-top: 2px solid #e2e8f0;
margin-top: 8px;
padding-top: 14px;
font-size: 18px;
font-weight: 700;
color: #1a202c;
}
.summary-divider { margin: 20px 0; }
/* ── Card payment fallback ── */
.card-section h4 { font-size: 14px; font-weight: 600; color: #4a5568; margin-bottom: 14px; }
.card-field {
border: 1.5px solid #e2e8f0; border-radius: 8px;
padding: 11px 14px; font-size: 15px; width: 100%;
margin-bottom: 12px; color: #a0aec0;
}
.card-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.btn-card {
width: 100%; padding: 13px;
background: #2d3748; color: #fff;
border: none; border-radius: 8px;
font-size: 15px; font-weight: 600; cursor: pointer; margin-top: 4px;
}
.btn-card:hover { background: #1a202c; }
.secure-note {
text-align: center; font-size: 12px; color: #a0aec0; margin-top: 14px;
display: flex; align-items: center; justify-content: center; gap: 4px;
}
</style>
</head>
<body>
<nav class="topbar">
<span class="topbar-logo">🍽️</span>
<span class="topbar-name">HotLunches</span>
<a href="demo-product.php?school=<?= urlencode($schoolLabel) ?>" class="topbar-back">← Continue shopping</a>
<span class="topbar-school"><?= htmlspecialchars($schoolLabel) ?></span>
</nav>
<div class="page">
<!-- Left: cart items -->
<div>
<div class="cart-card">
<div class="cart-header">
<h2>Your Order</h2>
<span class="cart-count"><?= count($cartItems) ?> item<?= count($cartItems) > 1 ? 's' : '' ?></span>
</div>
<?php foreach ($cartItems as $item): ?>
<div class="cart-item">
<div class="cart-item-emoji"><?= $item['emoji'] ?></div>
<div>
<div class="cart-item-name"><?= htmlspecialchars($item['name']) ?></div>
<div class="cart-item-sub">Qty: <?= $item['qty'] ?></div>
</div>
<div class="cart-item-price">$<?= number_format($item['price'], 2) ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- Right: checkout panel -->
<div>
<div class="summary-panel">
<div class="summary-title">Order Summary</div>
<?php foreach ($cartItems as $item): ?>
<div class="summary-row">
<span><?= htmlspecialchars($item['name']) ?></span>
<span>$<?= number_format($item['price'], 2) ?></span>
</div>
<?php endforeach; ?>
<div class="summary-row total">
<span>Total</span>
<span>$<?= number_format($total, 2) ?></span>
</div>
<?php
/* Pay Later message below cart total — shows "Pay later with PayPal" (placement: cart) */
$ppMessage = [
'school_name' => $schoolLabel,
'amount' => number_format($total, 2, '.', ''),
'currency' => $currency,
'placement' => 'cart',
'page_type' => 'cart',
];
require_once __DIR__ . '/paypal-paylater-message.php';
?>
<div class="summary-divider"></div>
<?php if (!$schoolReady): ?>
<div style="background:#fffaf0;border:1px solid #feebc8;color:#9c4221;border-radius:10px;padding:14px 16px;font-size:13px;line-height:1.6;margin-bottom:16px;">
<strong>No PayPal-connected school found.</strong><br>
The checkout buttons appear once a school has completed PayPal onboarding.
Connect one in <a href="index.php" style="color:#9c4221;font-weight:600;">School Merchant Setup</a>,
or open this page as <code>?school=&lt;connected school name&gt;</code>.
</div>
<?php endif; ?>
<?php
/*
* ══════════════════════════════════════════════════════
* PAYPAL WIDGET — rendered FIRST, above card payment.
* page_type='cart' is passed so PayPal can apply
* cart-specific optimisations (e.g. express checkout).
* ══════════════════════════════════════════════════════
*/
require_once __DIR__ . '/paypal-checkout-widget.php';
?>
<!-- Card payment — shown below the widget divider -->
<div class="card-section">
<h4>Pay with card</h4>
<input class="card-field" type="text" placeholder="Card number" disabled>
<div class="card-row">
<input class="card-field" type="text" placeholder="MM / YY" disabled>
<input class="card-field" type="text" placeholder="CVV" disabled>
</div>
<button class="btn-card" disabled>Pay $<?= number_format($total, 2) ?></button>
<p class="secure-note">🔒 Secured by PayPal</p>
</div>
</div>
</div>
</div>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<?php
/**
* Demo helper
* ───────────
* Resolves which *connected* school the demo checkout pages should run as.
*
* The PayPal widget (paypal-checkout-widget.php) only renders buttons for a
* school that has a saved merchant_id and is PayPal-ready. The demo pages used
* to hardcode "Demo School", which is never connected — so the buttons silently
* never appeared. These helpers let the demos auto-pick a school that can
* actually accept PayPal, so the page is testable out of the box.
*/
/** All onboarded schools keyed by school name. */
function pp_demo_schools(): array
{
$file = __DIR__ . '/data/school_merchants.json';
if (!file_exists($file)) {
return [];
}
return json_decode(file_get_contents($file), true) ?: [];
}
/**
* Decide which school the demo should check out as.
*
* Priority:
* 1. An explicit ?school= value, if that school is actually connected.
* 2. Otherwise the first connected school that is PayPal-ready.
* 3. null when no school can accept PayPal yet.
*/
function pp_demo_resolve_school(?string $requested = null): ?string
{
$merchants = pp_demo_schools();
// Honour an explicit request only if that school is connected.
if ($requested !== null && $requested !== '' && isset($merchants[$requested])) {
return $requested;
}
// Auto-pick the first school that can actually receive payments.
foreach ($merchants as $name => $rec) {
if (empty($rec['merchant_id'])) {
continue;
}
// paypal_ready may be absent (status never checked) — treat absence as ready.
if (array_key_exists('paypal_ready', $rec) && !$rec['paypal_ready']) {
continue;
}
if (!empty($rec['consent_revoked'])) {
continue;
}
return $name;
}
return null;
}
+255
View File
@@ -0,0 +1,255 @@
<?php
/**
* Demo: Product Detail Page
* Shows how to embed paypal-checkout-widget.php on a product/menu item page.
*
* In your real app, replace the hardcoded $school, $product, and $buyer values
* with data from your session and database.
*/
require_once __DIR__ . '/demo-helper.php';
// ── In your real app these come from session / DB ─────────────────────────────
// Auto-resolve to a PayPal-connected school so the buttons actually render.
// (Pass ?school=Name to force a specific connected school.)
$school = pp_demo_resolve_school($_GET['school'] ?? null);
$schoolReady = $school !== null;
$schoolLabel = $school ?? 'Demo School';
$buyerEmail = ''; // e.g. $_SESSION['user']['email']
$product = [
'name' => 'Monthly Lunch Plan — October',
'description' => '20 school days · hot lunch + drink',
'price' => '1.00',
'currency' => 'USD',
'image' => '🍱',
];
// ── End of real-app variables ─────────────────────────────────────────────────
// Build the $ppCheckout array the widget expects
$ppCheckout = [
'school_name' => $schoolLabel,
'amount' => $product['price'],
'currency' => $product['currency'],
'items' => [[
'name' => $product['name'],
'quantity' => '1',
'unit_amount' => ['currency_code' => $product['currency'], 'value' => $product['price']],
]],
'buyer_email' => $buyerEmail,
'page_type' => 'product-details', // tells PayPal which page the buyer is on
'order_api_url' => './order-api.php',
'success_url' => './demo-success.php?school=' . urlencode($schoolLabel),
'cancel_url' => '',
];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($product['name']) ?> — <?= htmlspecialchars($schoolLabel) ?></title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f7f8fa;
color: #1a202c;
}
/* ── Top bar ── */
.topbar {
background: #fff;
border-bottom: 1px solid #e2e8f0;
padding: 14px 24px;
display: flex;
align-items: center;
gap: 12px;
}
.topbar-logo { font-size: 22px; }
.topbar-name { font-weight: 700; font-size: 17px; color: #003087; }
.topbar-school { font-size: 13px; color: #718096; margin-left: auto; }
/* ── Layout ── */
.page { max-width: 960px; margin: 32px auto; padding: 0 20px; display: grid; grid-template-columns: 1fr 420px; gap: 32px; }
@media (max-width: 700px) { .page { grid-template-columns: 1fr; } }
/* ── Product card ── */
.product-card {
background: #fff;
border-radius: 12px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
.product-hero {
background: linear-gradient(135deg, #ebf4ff, #e6fffa);
padding: 40px;
text-align: center;
font-size: 80px;
}
.product-body { padding: 24px; }
.product-badge {
display: inline-block;
background: #ebf4ff;
color: #003087;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .5px;
padding: 3px 10px;
border-radius: 20px;
margin-bottom: 10px;
}
.product-name { font-size: 22px; font-weight: 700; margin-bottom: 8px; }
.product-desc { font-size: 14px; color: #718096; line-height: 1.6; margin-bottom: 20px; }
.product-features { list-style: none; margin-bottom: 24px; }
.product-features li {
font-size: 14px;
color: #4a5568;
padding: 6px 0;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
gap: 8px;
}
.product-features li::before { content: '✓'; color: #38a169; font-weight: 700; }
/* ── Checkout panel ── */
.checkout-panel {
background: #fff;
border-radius: 12px;
border: 1px solid #e2e8f0;
padding: 24px;
height: fit-content;
position: sticky;
top: 24px;
}
.checkout-price { font-size: 32px; font-weight: 700; color: #1a202c; margin-bottom: 4px; }
.checkout-price-sub { font-size: 13px; color: #718096; margin-bottom: 24px; }
.checkout-divider { border: none; border-top: 1px solid #e2e8f0; margin: 20px 0; }
/* ── Card payment fallback (below widget divider) ── */
.card-section h4 { font-size: 14px; font-weight: 600; color: #4a5568; margin-bottom: 14px; }
.card-field {
border: 1.5px solid #e2e8f0;
border-radius: 8px;
padding: 11px 14px;
font-size: 15px;
width: 100%;
margin-bottom: 12px;
color: #a0aec0;
}
.card-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.btn-card {
width: 100%;
padding: 13px;
background: #2d3748;
color: #fff;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
margin-top: 4px;
}
.btn-card:hover { background: #1a202c; }
.secure-note {
text-align: center;
font-size: 12px;
color: #a0aec0;
margin-top: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
</style>
</head>
<body>
<nav class="topbar">
<span class="topbar-logo">🍽️</span>
<span class="topbar-name">HotLunches</span>
<span class="topbar-school"><?= htmlspecialchars($schoolLabel) ?></span>
</nav>
<div class="page">
<!-- Left: product info -->
<div>
<div class="product-card">
<div class="product-hero"><?= $product['image'] ?></div>
<div class="product-body">
<span class="product-badge">Lunch Plan</span>
<h1 class="product-name"><?= htmlspecialchars($product['name']) ?></h1>
<p class="product-desc"><?= htmlspecialchars($product['description']) ?></p>
<ul class="product-features">
<li>Hot meal prepared fresh daily</li>
<li>Allergen information included</li>
<li>Weekly menu sent every Friday</li>
<li>Full refund if plan cancelled before start date</li>
</ul>
</div>
</div>
</div>
<!-- Right: checkout panel -->
<div>
<div class="checkout-panel">
<div class="checkout-price">$<?= htmlspecialchars($product['price']) ?></div>
<div class="checkout-price-sub">per student · October 2025</div>
<?php
/* Pay Later message near price — shows "Pay in 4 payments of $X" (placement: product) */
$ppMessage = [
'school_name' => $schoolLabel,
'amount' => $product['price'],
'currency' => $product['currency'],
'placement' => 'product',
'page_type' => 'product-details',
];
require_once __DIR__ . '/paypal-paylater-message.php';
?>
<?php if (!$schoolReady): ?>
<div style="background:#fffaf0;border:1px solid #feebc8;color:#9c4221;border-radius:10px;padding:14px 16px;font-size:13px;line-height:1.6;margin-bottom:16px;">
<strong>No PayPal-connected school found.</strong><br>
The checkout buttons appear once a school has completed PayPal onboarding.
Connect one in <a href="index.php" style="color:#9c4221;font-weight:600;">School Merchant Setup</a>,
or open this page as <code>?school=&lt;connected school name&gt;</code>.
</div>
<?php endif; ?>
<?php
/*
* ══════════════════════════════════════════════════════
* PAYPAL WIDGET — rendered FIRST, above card payment.
* The widget outputs:
* 1. FraudNet (hidden)
* 2. "We accept PayPal and Venmo" banner
* 3. PayPal + Venmo + Pay Later buttons
* 4. "── or pay another way ──" divider
* Card section below appears under that divider.
* ══════════════════════════════════════════════════════
*/
require_once __DIR__ . '/paypal-checkout-widget.php';
?>
<!-- Card payment — shown below the widget divider -->
<div class="card-section">
<h4>Pay with card</h4>
<input class="card-field" type="text" placeholder="Card number" disabled>
<div class="card-row">
<input class="card-field" type="text" placeholder="MM / YY" disabled>
<input class="card-field" type="text" placeholder="CVV" disabled>
</div>
<button class="btn-card" disabled>Pay $<?= htmlspecialchars($product['price']) ?></button>
<p class="secure-note">🔒 Secured by PayPal</p>
</div>
</div>
</div>
</div>
</body>
</html>
+165
View File
@@ -0,0 +1,165 @@
<?php
/**
* Demo: Payment Success / Thank-You Page
* ──────────────────────────────────────
* Where the PayPal widget sends the buyer after a successful capture.
* paypal-checkout-widget.php redirects here as:
* demo-success.php?capture_id=<PayPal capture id>
*
* In your real app you would look the capture up in your DB and show the real
* order. Here we just confirm the capture and echo the PayPal capture ID so a
* tester (e.g. PayPal support) can match it against the PayPal dashboard.
*/
$captureId = trim($_GET['capture_id'] ?? '');
$school = trim($_GET['school'] ?? '');
$valid = $captureId !== '';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payment <?= $valid ? 'Confirmed' : 'Status' ?> — HotLunches</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f7f8fa;
color: #1a202c;
min-height: 100vh;
}
/* ── Top bar ── */
.topbar {
background: #fff;
border-bottom: 1px solid #e2e8f0;
padding: 14px 24px;
display: flex;
align-items: center;
gap: 12px;
}
.topbar-logo { font-size: 22px; }
.topbar-name { font-weight: 700; font-size: 17px; color: #003087; }
/* ── Card ── */
.wrap { max-width: 560px; margin: 48px auto; padding: 0 20px; }
.card {
background: #fff;
border-radius: 12px;
border: 1px solid #e2e8f0;
padding: 40px 36px;
text-align: center;
}
.badge {
width: 72px; height: 72px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
margin: 0 auto 22px;
font-size: 38px;
}
.badge.ok { background: #f0fff4; color: #38a169; border: 2px solid #c6f6d5; }
.badge.warn { background: #fffaf0; color: #dd6b20; border: 2px solid #feebc8; }
h1 { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
.sub { font-size: 15px; color: #718096; line-height: 1.6; margin-bottom: 26px; }
.detail {
background: #f7fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 16px 18px;
text-align: left;
margin-bottom: 26px;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 6px 0;
font-size: 14px;
}
.detail-row + .detail-row { border-top: 1px solid #edf2f7; }
.detail-label { color: #718096; }
.detail-value {
font-weight: 600;
color: #1a202c;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
word-break: break-all;
text-align: right;
}
.btn {
display: inline-block;
padding: 12px 22px;
background: #003087;
color: #fff;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
}
.btn:hover { background: #00256b; }
.secure-note {
text-align: center; font-size: 12px; color: #a0aec0; margin-top: 20px;
}
</style>
</head>
<body>
<nav class="topbar">
<span class="topbar-logo">🍽️</span>
<span class="topbar-name">HotLunches</span>
</nav>
<div class="wrap">
<div class="card">
<?php if ($valid): ?>
<div class="badge ok">✓</div>
<h1>Payment confirmed</h1>
<p class="sub">
Thank you! Your payment has been received and your order is confirmed.
A receipt has been sent to your email by PayPal.
</p>
<div class="detail">
<div class="detail-row">
<span class="detail-label">Status</span>
<span class="detail-value">COMPLETED</span>
</div>
<div class="detail-row">
<span class="detail-label">PayPal capture ID</span>
<span class="detail-value"><?= htmlspecialchars($captureId) ?></span>
</div>
<?php if ($school !== ''): ?>
<div class="detail-row">
<span class="detail-label">Paid to</span>
<span class="detail-value"><?= htmlspecialchars($school) ?></span>
</div>
<?php endif; ?>
</div>
<?php else: ?>
<div class="badge warn">!</div>
<h1>Payment status unavailable</h1>
<p class="sub">
We didn't receive a payment confirmation. If you were charged, no
capture reference was passed back to this page. Please contact support
before trying again so you aren't billed twice.
</p>
<?php endif; ?>
<a class="btn" href="demo-product.php<?= $school !== '' ? '?school=' . urlencode($school) : '' ?>">
← Back to store
</a>
<p class="secure-note">🔒 Payments processed securely by PayPal</p>
</div>
</div>
</body>
</html>
+119
View File
@@ -0,0 +1,119 @@
<?php
// ─────────────────────────────────────────────────────────────────────────
// One-off permission fixer for the PayPal integration's data/ directory.
// Upload to /pp-integration/ and open in a browser:
// https://dev.hotlunches.net/pp-integration/fix_perms.php
// It makes data/ writable so onboarding can save school_merchants.json etc.
// DELETE THIS FILE once data/ is writable.
// ─────────────────────────────────────────────────────────────────────────
header('Content-Type: text/plain');
$base = __DIR__;
$dataDir = $base . '/data';
$dataFiles = ['school_merchants.json', 'pending_onboarding.json'];
function describe($p) {
if (!file_exists($p)) return '(missing)';
clearstatcache(true, $p);
$perms = substr(sprintf('%o', fileperms($p)), -4);
$owner = fileowner($p);
$group = filegroup($p);
if (function_exists('posix_getpwuid')) { $o = posix_getpwuid($owner); if ($o) $owner = $o['name'] . " ($owner)"; }
if (function_exists('posix_getgrgid')) { $g = posix_getgrgid($group); if ($g) $group = $g['name'] . " ($group)"; }
return "perms=$perms owner=$owner group=$group writable=" . (is_writable($p) ? 'YES' : 'NO');
}
function testWrite($dir) {
clearstatcache();
$probe = $dir . '/_write_probe.txt';
$ok = @file_put_contents($probe, 'ok ' . date('c'));
if ($ok !== false) @unlink($probe);
return $ok !== false;
}
echo "=== data/ permission fixer ===\n";
echo "Script dir : $base\n";
echo "Data dir : $dataDir\n\n";
echo "PHP runs as:\n";
echo " getmyuid() : " . getmyuid() . "\n";
echo " getmygid() : " . getmygid() . "\n";
if (function_exists('posix_geteuid')) {
echo " effective uid : " . posix_geteuid() . "\n";
if (function_exists('posix_getpwuid')) {
$pw = posix_getpwuid(posix_geteuid());
echo " effective user : " . ($pw['name'] ?? '?') . "\n";
}
}
echo " get_current_user(): " . get_current_user() . "\n\n";
echo "Before:\n";
echo " parent : " . describe($base) . "\n";
echo " data : " . describe($dataDir) . "\n\n";
if (testWrite($dataDir)) {
echo "data/ is ALREADY writable. Nothing to do. You can delete fix_perms.php.\n";
exit;
}
// ── Attempt 1: chmod the directory (works if PHP owns it) ──────────────────
echo "--- Attempt 1: chmod 0777 data/ ---\n";
$r = @chmod($dataDir, 0777);
echo " chmod: " . ($r ? 'OK' : 'FAILED') . "\n";
foreach ($dataFiles as $f) {
$fp = $dataDir . '/' . $f;
if (file_exists($fp)) { @chmod($fp, 0666); }
}
echo " data now: " . describe($dataDir) . "\n";
if (testWrite($dataDir)) {
echo "\nSUCCESS: data/ is now writable. Re-run onboarding, then DELETE fix_perms.php.\n";
exit;
}
// ── Attempt 2: recreate data/ so it is owned by the PHP user ───────────────
// Only possible if PHP can write the parent directory.
echo "\n--- Attempt 2: recreate data/ as PHP-owned ---\n";
if (is_writable($base)) {
// preserve existing JSON in memory
$preserved = [];
foreach ($dataFiles as $f) {
$fp = $dataDir . '/' . $f;
if (file_exists($fp)) $preserved[$f] = file_get_contents($fp);
}
$backup = $base . '/data_old_' . date('Ymd_His');
if (@rename($dataDir, $backup)) {
echo " moved old data/ -> " . basename($backup) . " (kept as backup)\n";
if (@mkdir($dataDir, 0777, true)) {
echo " created fresh data/ (owned by PHP user)\n";
foreach ($preserved as $f => $c) @file_put_contents($dataDir . '/' . $f, $c);
if (testWrite($dataDir)) {
echo " data now: " . describe($dataDir) . "\n";
echo "\nSUCCESS via recreate. Old data kept in " . basename($backup) . ".\n";
echo "Re-run onboarding, then DELETE fix_perms.php (and the backup folder if you like).\n";
exit;
}
echo " still not writable; restoring original...\n";
// best-effort restore
@array_map('unlink', glob($dataDir . '/*'));
@rmdir($dataDir);
}
@rename($backup, $dataDir);
echo " restored original data/\n";
} else {
echo " rename failed (parent not writable enough by PHP)\n";
}
} else {
echo " parent dir is not writable by PHP — cannot recreate automatically.\n";
}
// ── Could not fix from PHP ─────────────────────────────────────────────────
echo "\n=========================================================\n";
echo "Could not fix automatically (PHP does not own the folder).\n";
echo "Use your hosting control panel's File Manager (or an FTP client):\n";
echo " 1. Go to the 'pp-integration' folder.\n";
echo " 2. Right-click the 'data' folder -> Change Permissions / CHMOD.\n";
echo " 3. Set it to 777 (read/write/execute for all).\n";
echo " 4. Also set the two .json files inside to 666.\n";
echo "Then reload onboard.php?debug=1 to confirm 'is_writable: yes'.\n";
echo "=========================================================\n";
+107
View File
@@ -0,0 +1,107 @@
<?php
$ValidAccess = 2010;
require_once("../include/globals.php");
if(session_status() !== PHP_SESSION_ACTIVE) session_start();
if (SessionActive() != 1 || ! UserLoggedIn()) {
header("Location: {$FormTarget}");
exit();
}
$AllowedUserTypes = array('SuperUser','System Administrator');
if (checkPermission($AllowedUserTypes) != 1) {
$error_message = getLanguageText('ERRORMSG-ACCESS-DENIED');
$naction = '';
header("Location: {$FormTarget}?action=$action&naction=$naction$headerArgs&form_message=" . urlencode($form_message) . "&error_message=" . urlencode($error_message));
exit();
}
/**
* Simple helper to get merchant IDs for schools from database
*/
function getDbConnection() {
$dbConfig = require __DIR__ . '/db_config.php';
$conn = new mysqli(
$dbConfig['host'],
$dbConfig['username'],
$dbConfig['password'],
$dbConfig['database']
);
if ($conn->connect_error) {
return null;
}
$conn->set_charset($dbConfig['charset']);
return $conn;
}
function getMerchantId(string $schoolName): ?string
{
$db = getDbConnection();
if (!$db) {
// Fallback to JSON file
$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;
}
$stmt = $db->prepare("SELECT paypal_merchant_id FROM schools WHERE school_name = ? AND paypal_merchant_id IS NOT NULL");
$stmt->bind_param('s', $schoolName);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();
$db->close();
return $row['paypal_merchant_id'] ?? null;
}
function getAllMerchants(): array
{
$db = getDbConnection();
if (!$db) {
// Fallback to JSON file
$file = __DIR__ . '/data/school_merchants.json';
if (!file_exists($file)) return [];
return json_decode(file_get_contents($file), true) ?: [];
}
$result = $db->query("SELECT school_name, paypal_merchant_id, paypal_connected_at FROM schools WHERE paypal_merchant_id IS NOT NULL");
$merchants = [];
while ($row = $result->fetch_assoc()) {
$merchants[$row['school_name']] = [
'merchant_id' => $row['paypal_merchant_id'],
'connected_at' => strtotime($row['paypal_connected_at'])
];
}
$db->close();
return $merchants;
}
// Return JSON when accessed directly
writeLog(basename(__FILE__));
writeLog(basename($_SERVER['SCRIPT_FILENAME']));
if (basename(__FILE__) === basename($_SERVER['SCRIPT_FILENAME'])) {
header('Content-Type: application/json');
$school = $_GET['school'] ?? '';
writeLog($school);
if ($school) {
echo json_encode([
'school' => $school,
'merchant_id' => getMerchantId($school)
]);
} else {
echo json_encode(['merchants' => getAllMerchants()]);
}
}
+799
View File
@@ -0,0 +1,799 @@
<?php
ini_set('display_errors', 0);
$ValidAccess = 2010;
// require_once("../include/globals.php");
$cdir = "/m/data1/app.hotlunches.net";
$AppDir = $cdir;
ob_start();
require_once("../include/logging-functions.php");
startLog("includes");
require_once("../include/date-time.php");
require_once("../include/config.php");
require_once("../include/school-functions.php");
require_once("../include/database.php");
require_once("../include/session.php");
$sessionName = 'hotlunch';
$sessionTimeout = 60 * 30;
$cookiePath = '/';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_name($sessionName);
session_start();
}
// ── Diagnostic endpoint ──────────────────────────────────────────────────
// Visit index.php?debug=1 in a browser to see exactly where data is stored,
// whether it is writable, and what is currently saved. No login required.
if (isset($_GET['debug'])) {
header('Content-Type: text/plain');
$dataDir = __DIR__ . '/data';
$merchantsFile = $dataDir . '/school_merchants.json';
$pendingFile = $dataDir . '/pending_onboarding.json';
echo "=== index.php diagnostics ===\n";
echo "__DIR__ : " . __DIR__ . "\n";
echo "realpath(__DIR__) : " . realpath(__DIR__) . "\n";
echo "SCRIPT_FILENAME : " . ($_SERVER['SCRIPT_FILENAME'] ?? 'n/a') . "\n";
echo "dataDir : $dataDir\n";
echo "dataDir realpath : " . (realpath($dataDir) ?: '(does not exist)') . "\n";
echo "dataDir is_dir : " . (is_dir($dataDir) ? 'yes' : 'NO') . "\n";
echo "dataDir is_writable : " . (is_writable($dataDir) ? 'yes' : 'NO') . "\n";
$probe = $dataDir . '/_write_probe.txt';
$ok = @file_put_contents($probe, 'probe ' . date('c'));
echo "test write : " . ($ok === false ? 'FAILED' : "ok ($ok bytes)") . "\n";
if ($ok !== false) @unlink($probe);
echo "\n--- school_merchants.json (" . (file_exists($merchantsFile) ? filesize($merchantsFile) . ' bytes' : 'MISSING') . ") ---\n";
echo file_exists($merchantsFile) ? file_get_contents($merchantsFile) : '(none)';
echo "\n\n--- pending_onboarding.json (" . (file_exists($pendingFile) ? filesize($pendingFile) . ' bytes' : 'MISSING') . ") ---\n";
echo file_exists($pendingFile) ? file_get_contents($pendingFile) : '(none)';
exit();
}
// PayPal redirects the merchant's browser back to this URL when onboarding
// finishes. That is an external, top-level return from paypal.com — the app
// session is often no longer active by then, so the auth gate below must NOT
// apply to it or the callback never runs and the merchant is never saved.
// The callback is authenticated instead by the tracking_id we generated.
$isPayPalCallback = isset($_GET['callback']) && $_GET['callback'] == '1';
if (!$isPayPalCallback) {
if (SessionActive() != 1 || ! UserLoggedIn()) {
header("Location: {$FormTarget}");
exit();
}
$AllowedUserTypes = array('SuperUser','System Administrator');
if (checkPermission($AllowedUserTypes) != 1) {
$error_message = getLanguageText('ERRORMSG-ACCESS-DENIED');
writeLog($error_message);
$naction = '';
header("Location: {$FormTarget}?action=$action&naction=$naction$headerArgs&form_message=" . urlencode($form_message) . "&error_message=" . urlencode($error_message));
exit();
}
}
$buffer = ob_get_contents();
if (strlen($buffer)) writeLog("buffer: $buffer");
ob_end_clean();
writeLog("Header complete");
startLog("pp-integration");
// Database connection helper
function getDbConnection() {
writeLog("getDbConnection: ".__DIR__ . '/config.php');
$dbConfig = require __DIR__ . '/db_config.php';
$conn = new mysqli(
$dbConfig['host'],
$dbConfig['username'],
$dbConfig['password'],
$dbConfig['database']
);
if ($conn->connect_error) {
throw new Exception('Database connection failed: ' . $conn->connect_error);
}
$conn->set_charset($dbConfig['charset']);
return $conn;
}
// Handle AJAX requests
if (isset($_POST['action'])) writeLog("POST action: ".$_POST['action']);
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
header('Content-Type: application/json');
writeLog(__DIR__ . '/config.php');
$config = require __DIR__ . '/config.php';
require __DIR__ . '/lib/PayPalClient.php';
$dataDir = __DIR__ . '/data';
writeLog("dataDir: $dataDir");
if (!is_dir($dataDir)) {
mkdir($dataDir, 0755, true);
}
try {
writeLog("new PayPalClient");
$client = new PayPalClient($config);
writeLog("new PayPalClient - complete");
// ACTION: Create connection and redirect to PayPal
writeLog($_POST['action']);
if ($_POST['action'] === 'connect') {
$schoolName = trim($_POST['school_name'] ?? '');
if ($schoolName === '') {
echo json_encode(['success' => false, 'error' => 'School name required']);
exit();
}
writeLog("schoolName: $schoolName");
$trackingId = 'school_' . substr(md5($schoolName . time()), 0, 10);
writeLog("trackingId: $trackingId");
// base_url already resolves to the directory this script is served from
// (auto-detected from SCRIPT_NAME). Don't append a hardcoded subdir like
// '/sm' or the return URL points to a path that doesn't exist (404).
$baseUrl = rtrim($config['base_url'], '/');
writeLog("baseUrl: $baseUrl");
$returnUrl = $baseUrl . '/index.php?callback=1&school=' . urlencode($schoolName) . '&track=' . urlencode($trackingId);
writeLog("returnUrl: $returnUrl");
$payload = [
'tracking_id' => $trackingId,
'operations' => [[
'operation' => 'API_INTEGRATION',
'api_integration_preference' => [
'rest_api_integration' => [
'integration_method' => 'PAYPAL',
'integration_type' => 'THIRD_PARTY',
'third_party_details' => ['features' => ['PAYMENT', 'REFUND', 'VAULT', 'BILLING_AGREEMENT']]
]
]
]],
'products' => ['PPCP', 'PAYMENT_METHODS', 'ADVANCED_VAULTING'],
'capabilities' => ['APPLE_PAY', 'GOOGLE_PAY', 'PAYPAL_WALLET_VAULTING_ADVANCED'],
'legal_consents' => [['type' => 'SHARE_DATA_CONSENT', 'granted' => true]],
'partner_config_override' => [
'return_url' => $returnUrl,
'return_url_description' => 'Return to School Setup',
'show_add_credit_card' => false
]
];
writeLog("api call");
$resp = $client->api('POST', '/v2/customer/partner-referrals', $payload);
writeLog('Partner referral status: ' . $resp['status']);
writeLog('Partner referral body: ' . json_encode($resp['body']));
writeLog("api call - complete");
writeLog("resp status: ".$resp['status']);
if ($resp['status'] < 200 || $resp['status'] >= 300) {
echo json_encode(['success' => false, 'error' => 'Partner referral failed']);
exit();
}
$body = $resp['body'];
writeLog("body partner_referral_id: ".$body['partner_referral_id']);
$partnerReferralId = $body['partner_referral_id'] ?? $body['referral_id'] ?? null;
$actionUrl = null;
foreach ($body['links'] ?? [] as $link) {
if (($link['rel'] ?? '') === 'action_url') {
$actionUrl = $link['href'];
break;
}
}
writeLog("actionUrl: $actionUrl");
if (!$actionUrl) {
writeLog("No onboarding URL from PayPal");
echo json_encode(['success' => false, 'error' => 'No onboarding URL from PayPal']);
exit();
}
$pendingFile = $dataDir . '/pending_onboarding.json';
writeLog("pendingFile: $pendingFile");
$pending = file_exists($pendingFile) ? json_decode(file_get_contents($pendingFile), true) ?: [] : [];
$pending[$trackingId] = [
'school' => $schoolName,
'created_at' => time(),
'partner_referral_id' => $partnerReferralId,
'status' => 'pending'
];
file_put_contents($pendingFile, json_encode($pending, JSON_PRETTY_PRINT));
writeLog("json_encode call");
echo json_encode(['success' => true, 'redirect' => $actionUrl]);
exit();
writeLog("still going...");
}
// ACTION: Sync pending merchants
if ($_POST['action'] === 'sync') {
$pendingFile = $dataDir . '/pending_onboarding.json';
if (!file_exists($pendingFile)) {
echo json_encode(['success' => false, 'error' => 'No pending schools']);
exit();
}
$pending = json_decode(file_get_contents($pendingFile), true) ?: [];
$synced = 0;
$db = getDbConnection();
foreach ($pending as $trackingId => $data) {
if (!in_array($data['status'] ?? '', ['pending', 'waiting_for_paypal', 'incomplete_onboarding'])) {
continue;
}
$school = $data['school'] ?? 'Unknown';
$partnerReferralId = $data['partner_referral_id'] ?? null;
$merchantId = null;
// Try 3 approaches
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id'])
. '/merchant-integrations?tracking_id=' . urlencode($trackingId);
$resp = $client->api('GET', $path);
if ($resp['status'] >= 200 && $resp['status'] < 300) {
$foundId = $resp['body']['merchant_id'] ?? null;
if ($foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
}
if (!$merchantId) {
foreach ($resp['body']['merchant_integrations'] ?? [] as $integration) {
$foundId = $integration['merchant_id'] ?? null;
if ($foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break;
}
}
}
}
if (!$merchantId && $partnerReferralId) {
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id'])
. '/merchant-integrations?onboarded=true';
$resp = $client->api('GET', $path);
if ($resp['status'] >= 200 && $resp['status'] < 300) {
foreach ($resp['body']['merchant_integrations'] ?? [] as $integration) {
$refId = $integration['partner_referral_id'] ?? $integration['referral_id'] ?? null;
$foundId = $integration['merchant_id'] ?? null;
if ($refId && strcasecmp($refId, $partnerReferralId) === 0
&& $foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break;
}
}
}
}
if (!$merchantId) {
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id'])
. '/merchant-integrations?page_size=200';
$resp = $client->api('GET', $path);
if ($resp['status'] >= 200 && $resp['status'] < 300) {
foreach ($resp['body']['merchant_integrations'] ?? [] as $integration) {
$tid = $integration['tracking_id'] ?? null;
$refId = $integration['partner_referral_id'] ?? $integration['referral_id'] ?? null;
$foundId = $integration['merchant_id'] ?? null;
if ((($tid && strcasecmp($tid, $trackingId) === 0)
|| ($partnerReferralId && $refId && strcasecmp($refId, $partnerReferralId) === 0))
&& $foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break;
}
}
}
}
if ($merchantId) {
// Save to database. Match by school_name — $school_num is not
// defined in this sync scope; the callback path keys on school_name
// too, so this keeps both write paths consistent.
$stmt = $db->prepare("UPDATE schools SET paypal_merchant_id = ?, paypal_connected_at = NOW(), paypal_tracking_id = ? WHERE school_name = ?");
$stmt->bind_param('sss', $merchantId, $trackingId, $school);
$stmt->execute();
$stmt->close();
// Check seller status
$sellerStatus = $client->getSellerStatus($config['partner_merchant_id'], $merchantId);
writeLog('Seller status [sync] ' . $school . ': paypal_ready=' . ($sellerStatus['paypal_ready'] ? 'true' : 'false') . ' debug_id=' . ($sellerStatus['debug_id'] ?? 'n/a'));
if (!empty($sellerStatus['issues'])) {
writeLog('Seller status issues [sync] ' . $school . ': ' . implode(' | ', $sellerStatus['issues']));
}
// Also keep in JSON as backup
$merchantsFile = $dataDir . '/school_merchants.json';
$merchants = file_exists($merchantsFile) ? json_decode(file_get_contents($merchantsFile), true) ?: [] : [];
$merchants[$school] = [
'merchant_id' => $merchantId,
'tracking_id' => $trackingId,
'connected_at' => time(),
'paypal_ready' => $sellerStatus['paypal_ready'],
'email_confirmed' => $sellerStatus['email_confirmed'],
'payments_receivable' => $sellerStatus['payments_receivable'],
'oauth_ok' => $sellerStatus['oauth_ok'],
'seller_email' => $sellerStatus['seller_email'],
'status_issues' => $sellerStatus['issues'],
'status_checked_at' => time(),
];
file_put_contents($merchantsFile, json_encode($merchants, JSON_PRETTY_PRINT));
$pending[$trackingId]['merchant_id'] = $merchantId;
$pending[$trackingId]['status'] = 'connected';
$pending[$trackingId]['connected_at'] = time();
$pending[$trackingId]['paypal_ready'] = $sellerStatus['paypal_ready'];
$synced++;
}
}
$db->close();
file_put_contents($pendingFile, json_encode($pending, JSON_PRETTY_PRINT));
echo json_encode([
'success' => true,
'synced' => $synced,
'message' => $synced > 0 ? "Connected $synced school(s)" : 'No merchant IDs found yet'
]);
exit();
}
// ACTION: Re-check seller status for a specific school
if ($_POST['action'] === 'check_status') {
$schoolName = trim($_POST['school_name'] ?? '');
if ($schoolName === '') {
echo json_encode(['success' => false, 'error' => 'School name required']);
exit();
}
$merchantsFile = $dataDir . '/school_merchants.json';
if (!file_exists($merchantsFile)) {
echo json_encode(['success' => false, 'error' => 'No merchant data found']);
exit();
}
$merchants = json_decode(file_get_contents($merchantsFile), true) ?: [];
$merchantId = $merchants[$schoolName]['merchant_id'] ?? null;
if (!$merchantId) {
echo json_encode(['success' => false, 'error' => 'School not found or not yet connected']);
exit();
}
$sellerStatus = $client->getSellerStatus($config['partner_merchant_id'], $merchantId);
writeLog('Seller status [manual] ' . $schoolName . ': paypal_ready=' . ($sellerStatus['paypal_ready'] ? 'true' : 'false') . ' debug_id=' . ($sellerStatus['debug_id'] ?? 'n/a'));
if (!empty($sellerStatus['issues'])) {
writeLog('Seller status issues [manual] ' . $schoolName . ': ' . implode(' | ', $sellerStatus['issues']));
}
$merchants[$schoolName]['paypal_ready'] = $sellerStatus['paypal_ready'];
$merchants[$schoolName]['email_confirmed'] = $sellerStatus['email_confirmed'];
$merchants[$schoolName]['payments_receivable'] = $sellerStatus['payments_receivable'];
$merchants[$schoolName]['oauth_ok'] = $sellerStatus['oauth_ok'];
$merchants[$schoolName]['seller_email'] = $sellerStatus['seller_email'];
$merchants[$schoolName]['status_issues'] = $sellerStatus['issues'];
$merchants[$schoolName]['status_checked_at'] = time();
file_put_contents($merchantsFile, json_encode($merchants, JSON_PRETTY_PRINT));
echo json_encode([
'success' => true,
'paypal_ready' => $sellerStatus['paypal_ready'],
'email_confirmed' => $sellerStatus['email_confirmed'],
'payments_receivable' => $sellerStatus['payments_receivable'],
'oauth_ok' => $sellerStatus['oauth_ok'],
'seller_email' => $sellerStatus['seller_email'],
'issues' => $sellerStatus['issues'],
]);
exit();
}
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
exit();
}
}
// Handle PayPal callback
if (isset($_GET['callback']) && $_GET['callback'] == '1') {
writeLog('PayPal callback received: ' . ($_SERVER['QUERY_STRING'] ?? ''));
$config = require __DIR__ . '/config.php';
require __DIR__ . '/lib/PayPalClient.php';
$school = $_GET['school'] ?? '';
$trackingId = $_GET['track'] ?? '';
// NOTE: PayPal's "merchantId" return param is OUR tracking_id, not the PayPal
// merchant ID. The real PayPal merchant ID is "merchantIdInPayPal".
$merchantIdFromUrl = $_GET['merchantIdInPayPal'] ?? $_GET['merchant_id'] ?? null;
writeLog("callback parsed: school=$school track=$trackingId merchantIdInPayPal=" . ($merchantIdFromUrl ?? 'null'));
$dataDir = __DIR__ . '/data';
if (!is_dir($dataDir)) {
@mkdir($dataDir, 0755, true);
writeLog("created dataDir: $dataDir");
}
if (!is_writable($dataDir)) {
writeLog("WARNING: dataDir not writable: $dataDir");
}
if ($school && $trackingId) {
try {
$client = new PayPalClient($config);
$merchantId = $merchantIdFromUrl;
$pendingFile = $dataDir . '/pending_onboarding.json';
$partnerReferralId = null;
if (file_exists($pendingFile)) {
$pending = json_decode(file_get_contents($pendingFile), true) ?: [];
$partnerReferralId = $pending[$trackingId]['partner_referral_id'] ?? null;
}
if (!$merchantId) {
for ($i = 0; $i < 15; $i++) {
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id'])
. '/merchant-integrations?tracking_id=' . urlencode($trackingId);
$resp = $client->api('GET', $path);
if ($resp['status'] >= 200 && $resp['status'] < 300) {
$foundId = $resp['body']['merchant_id'] ?? null;
if ($foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break;
}
foreach ($resp['body']['merchant_integrations'] ?? [] as $integration) {
$foundId = $integration['merchant_id'] ?? null;
if ($foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break 2;
}
}
}
if (!$merchantId && $partnerReferralId) {
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id'])
. '/merchant-integrations?onboarded=true';
$resp = $client->api('GET', $path);
if ($resp['status'] >= 200 && $resp['status'] < 300) {
foreach ($resp['body']['merchant_integrations'] ?? [] as $integration) {
$refId = $integration['partner_referral_id'] ?? $integration['referral_id'] ?? null;
$foundId = $integration['merchant_id'] ?? null;
if ($refId && strcasecmp($refId, $partnerReferralId) === 0
&& $foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break 2;
}
}
}
}
if (!$merchantId) {
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id'])
. '/merchant-integrations?page_size=200';
$resp = $client->api('GET', $path);
if ($resp['status'] >= 200 && $resp['status'] < 300) {
foreach ($resp['body']['merchant_integrations'] ?? [] as $integration) {
$tid = $integration['tracking_id'] ?? null;
$refId = $integration['partner_referral_id'] ?? $integration['referral_id'] ?? null;
$foundId = $integration['merchant_id'] ?? null;
if ((($tid && strcasecmp($tid, $trackingId) === 0)
|| ($partnerReferralId && $refId && strcasecmp($refId, $partnerReferralId) === 0))
&& $foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break 2;
}
}
}
}
if ($i < 14) sleep(1);
}
}
if ($merchantId) {
// Persist the merchant ID immediately. We already have it from the
// callback URL, so the connection MUST be recorded (and the dashboard
// updated) even if the follow-up seller-status check fails or PayPal
// hasn't finished propagating the new integration yet.
$merchantsFile = $dataDir . '/school_merchants.json';
$merchants = file_exists($merchantsFile) ? json_decode(file_get_contents($merchantsFile), true) ?: [] : [];
$merchants[$school] = array_merge($merchants[$school] ?? [], [
'merchant_id' => $merchantId,
'tracking_id' => $trackingId,
'connected_at' => time(),
]);
$written = file_put_contents($merchantsFile, json_encode($merchants, JSON_PRETTY_PRINT));
writeLog('Merchant saved [callback] ' . $school . ' => ' . $merchantId
. ' (' . ($written === false ? 'WRITE FAILED to ' . $merchantsFile : $written . ' bytes to ' . $merchantsFile) . ')');
// If we can't persist, do NOT show a fake success page — surface the
// real problem (almost always: the data/ directory is not writable
// by the web-server user) so it can be fixed.
if ($written === false) {
$writable = is_writable($dataDir) ? 'writable' : 'NOT writable';
header('Location: index.php?status=error&msg=' . urlencode(
'Could not save merchant data. data/ directory (' . $dataDir . ') is ' . $writable
. '. Fix filesystem permissions so PHP can write to it.'));
exit();
}
if (file_exists($pendingFile)) {
$pending = json_decode(file_get_contents($pendingFile), true) ?: [];
if (isset($pending[$trackingId])) {
$pending[$trackingId]['merchant_id'] = $merchantId;
$pending[$trackingId]['status'] = 'connected';
$pending[$trackingId]['connected_at'] = time();
file_put_contents($pendingFile, json_encode($pending, JSON_PRETTY_PRINT));
}
}
// Save to database (non-fatal — a DB failure must not lose the JSON record)
try {
$db = getDbConnection();
$stmt = $db->prepare("UPDATE schools SET paypal_merchant_id = ?, paypal_connected_at = NOW(), paypal_tracking_id = ? WHERE school_name = ?");
$stmt->bind_param('sss', $merchantId, $trackingId, $school);
$stmt->execute();
writeLog('DB update [callback] ' . $school . ': affected_rows=' . $stmt->affected_rows);
$stmt->close();
$db->close();
} catch (Exception $e) {
writeLog('DB Error [callback]: ' . $e->getMessage());
}
// Enrich with seller status — guarded so a status-check failure
// never discards the connection we just saved above.
try {
$sellerStatus = $client->getSellerStatus($config['partner_merchant_id'], $merchantId);
writeLog('Seller status [callback] ' . $school . ': paypal_ready=' . ($sellerStatus['paypal_ready'] ? 'true' : 'false') . ' debug_id=' . ($sellerStatus['debug_id'] ?? 'n/a'));
if (!empty($sellerStatus['issues'])) {
writeLog('Seller status issues [callback] ' . $school . ': ' . implode(' | ', $sellerStatus['issues']));
}
$merchants[$school] = array_merge($merchants[$school], [
'paypal_ready' => $sellerStatus['paypal_ready'],
'email_confirmed' => $sellerStatus['email_confirmed'],
'payments_receivable' => $sellerStatus['payments_receivable'],
'oauth_ok' => $sellerStatus['oauth_ok'],
'seller_email' => $sellerStatus['seller_email'],
'status_issues' => $sellerStatus['issues'],
'status_checked_at' => time(),
]);
file_put_contents($merchantsFile, json_encode($merchants, JSON_PRETTY_PRINT));
if (file_exists($pendingFile)) {
$pending = json_decode(file_get_contents($pendingFile), true) ?: [];
if (isset($pending[$trackingId])) {
$pending[$trackingId]['paypal_ready'] = $sellerStatus['paypal_ready'];
file_put_contents($pendingFile, json_encode($pending, JSON_PRETTY_PRINT));
}
}
} catch (Exception $e) {
writeLog('Seller status check failed [callback] ' . $school . ': ' . $e->getMessage());
}
header('Location: index.php?success=1&school=' . urlencode($school));
exit();
} else {
if (file_exists($pendingFile)) {
$pending = json_decode(file_get_contents($pendingFile), true) ?: [];
if (isset($pending[$trackingId])) {
$pending[$trackingId]['status'] = 'incomplete_onboarding';
$pending[$trackingId]['last_callback'] = time();
file_put_contents($pendingFile, json_encode($pending, JSON_PRETTY_PRINT));
}
}
header('Location: index.php?status=error&msg=' . urlencode('Please complete PayPal onboarding or sync later'));
exit();
}
} catch (Exception $e) {
header('Location: index.php?status=error&msg=' . urlencode($e->getMessage()));
exit();
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>School Merchant ID Setup</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
max-width: 500px;
width: 100%;
}
h1 { color: #333; margin-bottom: 10px; font-size: 28px; }
.subtitle { color: #666; margin-bottom: 30px; font-size: 14px; }
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 8px; color: #555; font-weight: 500; }
input[type="text"] {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 16px;
}
input[type="text"]:focus { outline: none; border-color: #667eea; }
.btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
}
.btn:hover { transform: translateY(-2px); box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3); }
.btn:disabled { background: #ccc; cursor: not-allowed; }
.btn.success { background: linear-gradient(135deg, #28a745 0%, #20c997 100%); }
.alert {
padding: 12px;
border-radius: 6px;
margin-bottom: 20px;
font-size: 14px;
}
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.alert-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.success-page {
text-align: center;
padding: 20px;
}
.success-icon {
font-size: 64px;
color: #28a745;
margin-bottom: 20px;
}
.success-page h2 {
color: #28a745;
margin-bottom: 15px;
font-size: 24px;
}
.success-page p {
color: #666;
margin-bottom: 10px;
line-height: 1.6;
}
.school-name {
font-weight: bold;
color: #667eea;
font-size: 18px;
}
.sync-section {
margin-top: 30px;
padding-top: 30px;
border-top: 2px solid #f0f0f0;
}
</style>
</head>
<body>
<div class="container">
<?php if (isset($_GET['success']) && $_GET['success'] == '1'): ?>
<!-- Success Page -->
<div class="success-page">
<div class="success-icon">✓</div>
<h2>Welcome!</h2>
<p>You have successfully connected your PayPal account.</p>
<p class="school-name"><?php echo htmlspecialchars($_GET['school'] ?? 'Your school'); ?></p>
<p style="margin-top: 20px; font-size: 13px;">Your merchant ID has been saved to the database.</p>
<button onclick="window.location.href='index.php'" class="btn success" style="margin-top: 30px;">
← Back to Home
</button>
</div>
<?php else: ?>
<!-- Main Form -->
<h1>🏫 School Merchant Setup</h1>
<p class="subtitle">Connect your school's PayPal account</p>
<?php
$message = '';
$messageType = '';
if (isset($_GET['status'])) {
if ($_GET['status'] === 'error') {
$message = $_GET['msg'] ?? 'An error occurred. Please try again.';
$messageType = 'error';
}
}
if ($message): ?>
<div class="alert alert-<?php echo htmlspecialchars($messageType); ?>">
<?php echo htmlspecialchars($message); ?>
</div>
<?php endif; ?>
<form id="connectForm">
<div class="form-group">
<label for="school_name"><?php displayLanguageText('TEXT-SCHOOL-NAME'); ?></label>
<input type="text" id="school_name" name="school_name" placeholder="Enter school name" value="<?php echo($school_record['school_name']) ?>" required>
</div>
<button type="submit" class="btn" id="connectBtn">Connect PayPal Account</button>
</form>
<?php endif; ?>
</div>
<script>
// Connect form handler (the form is absent on the success page)
var connectForm = document.getElementById('connectForm');
if (connectForm) connectForm.addEventListener('submit', function(e) {
e.preventDefault();
const btn = document.getElementById('connectBtn');
const schoolName = document.getElementById('school_name').value.trim();
if (!schoolName) {
alert('Please enter a school name');
return;
}
btn.disabled = true;
btn.textContent = 'Connecting...';
fetch('index.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'action=connect&school_name=' + encodeURIComponent(schoolName)
})
.then(r => r.json())
.then(data => {
if (data.success && data.redirect) {
window.location.href = data.redirect;
} else {
alert('Error: ' + (data.error || 'Connection failed'));
btn.disabled = false;
btn.textContent = 'Connect PayPal Account';
}
})
.catch(err => {
alert('Error: ' + err.message);
btn.disabled = false;
btn.textContent = 'Connect PayPal Account';
});
});
</script>
</body>
</html>
+261
View File
@@ -0,0 +1,261 @@
<?php
class PayPalClient
{
private string $clientId;
private string $clientSecret;
private string $env; // sandbox|live
private string $baseApi;
private ?string $caBundle; // optional CA bundle path for cURL TLS verification
public function __construct(array $config)
{
$this->clientId = $config['client_id'] ?? '';
$this->clientSecret = $config['client_secret'] ?? '';
$this->env = $config['env'] ?? 'sandbox';
$this->caBundle = !empty($config['curl_ca_bundle']) ? $config['curl_ca_bundle'] : null;
$this->baseApi = $this->env === 'live'
? 'https://api-m.paypal.com'
: 'https://api-m.sandbox.paypal.com';
if (!$this->clientId || !$this->clientSecret) {
throw new RuntimeException('Missing PayPal client_id/client_secret in config.php');
}
}
/**
* cURL TLS options. We always verify the peer/host (never disable it — that
* would be insecure and is rejected in production). When a CA bundle path is
* configured we point cURL at it, which fixes environments that lack a
* system-wide curl.cainfo (e.g. stock Windows PHP).
*/
private function tlsOpts(): array
{
$opts = [
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
];
if ($this->caBundle && is_file($this->caBundle)) {
$opts[CURLOPT_CAINFO] = $this->caBundle;
}
return $opts;
}
public function getAccessToken(): string
{
$url = $this->baseApi . '/v1/oauth2/token';
$headers = [
'Accept: application/json',
'Accept-Language: en_US',
];
$body = 'grant_type=client_credentials';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERPWD => $this->clientId . ':' . $this->clientSecret,
] + $this->tlsOpts());
$resp = curl_exec($ch);
if ($resp === false) {
throw new RuntimeException('OAuth token request failed: ' . curl_error($ch));
}
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($resp, true);
if ($code < 200 || $code >= 300 || !isset($data['access_token'])) {
throw new RuntimeException('OAuth token response error (' . $code . '): ' . $resp);
}
return $data['access_token'];
}
public function api(string $method, string $path, array $payload = null, array $headers = []): array
{
$url = $this->baseApi . $path;
$accessToken = $this->getAccessToken();
$defaultHeaders = [
'Content-Type: application/json',
'Authorization: Bearer ' . $accessToken,
'PayPal-Partner-Attribution-Id: EZSSOFTWAREINC_SP_PPCP',
];
$allHeaders = array_merge($defaultHeaders, $headers);
$debugId = null;
$ch = curl_init($url);
$opts = [
CURLOPT_HTTPHEADER => $allHeaders,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$debugId) {
if (stripos($header, 'paypal-debug-id:') === 0) {
$debugId = trim(explode(':', $header, 2)[1]);
}
return strlen($header);
},
];
if ($payload !== null) {
$opts[CURLOPT_POSTFIELDS] = json_encode($payload);
}
curl_setopt_array($ch, $opts + $this->tlsOpts());
$resp = curl_exec($ch);
if ($resp === false) {
throw new RuntimeException('API request failed: ' . curl_error($ch));
}
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (function_exists('writeLog')) {
writeLog('PayPal API ' . strtoupper($method) . ' ' . $path . ' => HTTP ' . $code . ' debug-id=' . ($debugId ?? 'n/a'));
}
$data = json_decode($resp, true);
if ($data === null && $resp !== '' && $resp !== 'null') {
$data = ['raw' => $resp];
}
return ['status' => $code, 'body' => $data, 'debug_id' => $debugId];
}
public function buildAuthAssertionJwt(string $merchantId): string
{
$b64url = fn(string $v) => rtrim(strtr(base64_encode($v), '+/', '-_'), '=');
$header = $b64url(json_encode(['alg' => 'none']));
$payload = $b64url(json_encode(['iss' => $this->clientId, 'payer_id' => $merchantId]));
return $header . '.' . $payload . '.';
}
public function createOrder(string $merchantId, array $payload, string $fraudnetId = ''): array
{
$headers = [
'PayPal-Auth-Assertion: ' . $this->buildAuthAssertionJwt($merchantId),
'PayPal-Request-Id: ' . bin2hex(random_bytes(16)), // idempotency — prevents duplicate orders on network retry
];
if ($fraudnetId !== '') {
$headers[] = 'paypal-client-metadata-id: ' . $fraudnetId;
}
return $this->api('POST', '/v2/checkout/orders', $payload, $headers);
}
public function captureOrder(string $merchantId, string $orderId): array
{
return $this->api(
'POST',
'/v2/checkout/orders/' . urlencode($orderId) . '/capture',
null,
[
'PayPal-Auth-Assertion: ' . $this->buildAuthAssertionJwt($merchantId),
'Prefer: return=representation', // ensures capture ID is in the response body
]
);
}
/**
* Refund a captured payment (full or partial).
*
* @param array $refundData Optional. Keys: 'amount' (array with 'value'+'currency_code'),
* 'note_to_payer' (string), 'invoice_id' (string).
* Pass [] for a full refund.
* @param string $idempotencyKey Caller-supplied key so safe retries don't double-refund.
* Auto-generated if empty.
*/
public function refundCapture(
string $merchantId,
string $captureId,
array $refundData = [],
string $idempotencyKey = ''
): array {
if ($idempotencyKey === '') {
$idempotencyKey = bin2hex(random_bytes(16));
}
return $this->api(
'POST',
'/v2/payments/captures/' . urlencode($captureId) . '/refund',
empty($refundData) ? null : $refundData,
[
'PayPal-Auth-Assertion: ' . $this->buildAuthAssertionJwt($merchantId),
'PayPal-Request-Id: ' . $idempotencyKey,
]
);
}
public function getSellerStatus(string $partnerMerchantId, string $merchantId): array
{
$path = '/v1/customer/partners/' . urlencode($partnerMerchantId)
. '/merchant-integrations/' . urlencode($merchantId);
$resp = $this->api('GET', $path);
$body = $resp['body'] ?? [];
$emailConfirmed = (bool)($body['primary_email_confirmed'] ?? false);
$paymentsReceivable = (bool)($body['payments_receivable'] ?? false);
$oauthIntegrations = $body['oauth_integrations'] ?? [];
$oauthOk = !empty($oauthIntegrations);
$sellerEmail = $body['primary_email'] ?? null;
$products = $body['products'] ?? [];
$capabilities = $body['capabilities'] ?? [];
// ── ACDC (Advanced Card Processing) readiness ─────────────────────
// Guide: check PPCP_CUSTOM vetting + CUSTOM_CARD_PROCESSING capability
$acdcVettingStatus = null;
foreach ($products as $product) {
if (($product['name'] ?? '') === 'PPCP_CUSTOM') {
$acdcVettingStatus = strtoupper($product['vetting_status'] ?? '');
break;
}
}
$customCardCap = null;
foreach ($capabilities as $cap) {
if (($cap['name'] ?? '') === 'CUSTOM_CARD_PROCESSING') {
$customCardCap = $cap;
break;
}
}
$acdcCapActive = ($customCardCap['status'] ?? '') === 'ACTIVE';
$acdcNoLimits = !isset($customCardCap['limits']);
$acdcReady = $acdcVettingStatus === 'SUBSCRIBED' && $acdcCapActive && $acdcNoLimits;
$acdcDenied = $acdcVettingStatus === 'DENIED';
$acdcPending = $acdcVettingStatus !== null && !$acdcReady && !$acdcDenied;
$issues = [];
if (!$emailConfirmed) {
$issues[] = 'PRIMARY_EMAIL_CONFIRMED is false — seller must confirm their PayPal email before receiving payments.';
}
if (!$paymentsReceivable) {
$issues[] = 'PAYMENTS_RECEIVABLE is false — there may be a restriction on the seller\'s PayPal account.';
}
if (!$oauthOk) {
$issues[] = 'OAUTH_INTEGRATIONS is empty — seller has not granted third-party permissions.';
}
if ($acdcDenied) {
$issues[] = 'ACDC DENIED — seller can only use PayPal Wallet buttons, not Advanced Card Processing.';
}
if ($acdcPending) {
$issues[] = 'ACDC PENDING (vetting_status=' . $acdcVettingStatus . ') — waiting for PayPal to approve Advanced Card Processing.';
}
return [
'status' => $resp['status'],
'debug_id' => $resp['debug_id'],
'paypal_ready' => $emailConfirmed && $paymentsReceivable && $oauthOk,
'email_confirmed' => $emailConfirmed,
'payments_receivable' => $paymentsReceivable,
'oauth_ok' => $oauthOk,
'seller_email' => $sellerEmail,
'issues' => $issues,
// ACDC fields
'acdc_ready' => $acdcReady,
'acdc_vetting_status' => $acdcVettingStatus,
'acdc_denied' => $acdcDenied,
'acdc_pending' => $acdcPending,
];
}
public function getBaseApi(): string
{
return $this->baseApi;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
class Storage
{
private string $dir;
public function __construct(string $dir)
{
$this->dir = rtrim($dir, DIRECTORY_SEPARATOR);
if (!is_dir($this->dir)) {
mkdir($this->dir, 0777, true);
}
if (!is_writable($this->dir)) {
throw new RuntimeException('Storage path not writable: ' . $this->dir);
}
}
private function path(string $name): string
{
return $this->dir . DIRECTORY_SEPARATOR . $name . '.json';
}
public function get(string $name, $default = [])
{
$p = $this->path($name);
if (!file_exists($p)) return $default;
$data = json_decode((string)file_get_contents($p), true);
return $data ?? $default;
}
public function set(string $name, $value): void
{
$p = $this->path($name);
file_put_contents($p, json_encode($value, JSON_PRETTY_PRINT));
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
class TenantResolver
{
/**
* Returns the current subdomain from HTTP_HOST (e.g., school1 for school1.example.com).
*/
public static function currentSubdomain(): ?string
{
$host = $_SERVER['HTTP_HOST'] ?? '';
if (!$host) return null;
// Strip port
$host = preg_replace('/:\\d+$/', '', $host);
$parts = explode('.', $host);
if (count($parts) < 3) {
// No subdomain (like localhost or example.com)
return null;
}
// Return first label (handles multi-level like school1.dev.example.com -> school1)
return strtolower($parts[0]);
}
/**
* Map subdomain to a school display name using data/tenants.json if present.
* tenants.json structure: { "subdomain-key": "School Display Name" }
*/
public static function schoolFromSubdomain(Storage $storage): ?string
{
$sub = self::currentSubdomain();
if (!$sub) return null;
try {
$tenants = $storage->get('tenants', []);
if (!empty($tenants[$sub]) && is_string($tenants[$sub])) {
return $tenants[$sub];
}
} catch (\Throwable $e) {
// ignore
}
// Fallback heuristic: humanize the subdomain (school-one -> School One)
$guess = trim($sub);
if ($guess === '') return null;
$guess = str_replace(['-', '_'], ' ', $guess);
return ucwords($guess);
}
/**
* Determine a school candidate for the current request:
* - Prefer explicit $provided (POST/GET)
* - Then subdomain mapping
*/
public static function resolveSchool(Storage $storage, ?string $provided = null): ?string
{
$provided = is_string($provided) ? trim($provided) : '';
if ($provided !== '') return $provided;
return self::schoolFromSubdomain($storage);
}
}
+799
View File
@@ -0,0 +1,799 @@
<?php
ini_set('display_errors', 0);
$ValidAccess = 2010;
// require_once("../include/globals.php");
$cdir = "/m/data1/app.hotlunches.net";
$AppDir = $cdir;
ob_start();
require_once("../include/logging-functions.php");
startLog("includes");
require_once("../include/date-time.php");
require_once("../include/config.php");
require_once("../include/school-functions.php");
require_once("../include/database.php");
require_once("../include/session.php");
$sessionName = 'hotlunch';
$sessionTimeout = 60 * 30;
$cookiePath = '/';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_name($sessionName);
session_start();
}
// ── Diagnostic endpoint ──────────────────────────────────────────────────
// Visit onboard.php?debug=1 in a browser to see exactly where data is stored,
// whether it is writable, and what is currently saved. No login required.
if (isset($_GET['debug'])) {
header('Content-Type: text/plain');
$dataDir = __DIR__ . '/data';
$merchantsFile = $dataDir . '/school_merchants.json';
$pendingFile = $dataDir . '/pending_onboarding.json';
echo "=== onboard.php diagnostics ===\n";
echo "__DIR__ : " . __DIR__ . "\n";
echo "realpath(__DIR__) : " . realpath(__DIR__) . "\n";
echo "SCRIPT_FILENAME : " . ($_SERVER['SCRIPT_FILENAME'] ?? 'n/a') . "\n";
echo "dataDir : $dataDir\n";
echo "dataDir realpath : " . (realpath($dataDir) ?: '(does not exist)') . "\n";
echo "dataDir is_dir : " . (is_dir($dataDir) ? 'yes' : 'NO') . "\n";
echo "dataDir is_writable : " . (is_writable($dataDir) ? 'yes' : 'NO') . "\n";
$probe = $dataDir . '/_write_probe.txt';
$ok = @file_put_contents($probe, 'probe ' . date('c'));
echo "test write : " . ($ok === false ? 'FAILED' : "ok ($ok bytes)") . "\n";
if ($ok !== false) @unlink($probe);
echo "\n--- school_merchants.json (" . (file_exists($merchantsFile) ? filesize($merchantsFile) . ' bytes' : 'MISSING') . ") ---\n";
echo file_exists($merchantsFile) ? file_get_contents($merchantsFile) : '(none)';
echo "\n\n--- pending_onboarding.json (" . (file_exists($pendingFile) ? filesize($pendingFile) . ' bytes' : 'MISSING') . ") ---\n";
echo file_exists($pendingFile) ? file_get_contents($pendingFile) : '(none)';
exit();
}
// PayPal redirects the merchant's browser back to this URL when onboarding
// finishes. That is an external, top-level return from paypal.com — the app
// session is often no longer active by then, so the auth gate below must NOT
// apply to it or the callback never runs and the merchant is never saved.
// The callback is authenticated instead by the tracking_id we generated.
$isPayPalCallback = isset($_GET['callback']) && $_GET['callback'] == '1';
if (!$isPayPalCallback) {
if (SessionActive() != 1 || ! UserLoggedIn()) {
header("Location: {$FormTarget}");
exit();
}
$AllowedUserTypes = array('SuperUser','System Administrator');
if (checkPermission($AllowedUserTypes) != 1) {
$error_message = getLanguageText('ERRORMSG-ACCESS-DENIED');
writeLog($error_message);
$naction = '';
header("Location: {$FormTarget}?action=$action&naction=$naction$headerArgs&form_message=" . urlencode($form_message) . "&error_message=" . urlencode($error_message));
exit();
}
}
$buffer = ob_get_contents();
if (strlen($buffer)) writeLog("buffer: $buffer");
ob_end_clean();
writeLog("Header complete");
startLog("pp-integration");
// Database connection helper
function getDbConnection() {
writeLog("getDbConnection: ".__DIR__ . '/config.php');
$dbConfig = require __DIR__ . '/db_config.php';
$conn = new mysqli(
$dbConfig['host'],
$dbConfig['username'],
$dbConfig['password'],
$dbConfig['database']
);
if ($conn->connect_error) {
throw new Exception('Database connection failed: ' . $conn->connect_error);
}
$conn->set_charset($dbConfig['charset']);
return $conn;
}
// Handle AJAX requests
if (isset($_POST['action'])) writeLog("POST action: ".$_POST['action']);
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
header('Content-Type: application/json');
writeLog(__DIR__ . '/config.php');
$config = require __DIR__ . '/config.php';
require __DIR__ . '/lib/PayPalClient.php';
$dataDir = __DIR__ . '/data';
writeLog("dataDir: $dataDir");
if (!is_dir($dataDir)) {
mkdir($dataDir, 0755, true);
}
try {
writeLog("new PayPalClient");
$client = new PayPalClient($config);
writeLog("new PayPalClient - complete");
// ACTION: Create connection and redirect to PayPal
writeLog($_POST['action']);
if ($_POST['action'] === 'connect') {
$schoolName = trim($_POST['school_name'] ?? '');
if ($schoolName === '') {
echo json_encode(['success' => false, 'error' => 'School name required']);
exit();
}
writeLog("schoolName: $schoolName");
$trackingId = 'school_' . substr(md5($schoolName . time()), 0, 10);
writeLog("trackingId: $trackingId");
// base_url already resolves to the directory this script is served from
// (auto-detected from SCRIPT_NAME). Don't append a hardcoded subdir like
// '/sm' or the return URL points to a path that doesn't exist (404).
$baseUrl = rtrim($config['base_url'], '/');
writeLog("baseUrl: $baseUrl");
$returnUrl = $baseUrl . '/onboard.php?callback=1&school=' . urlencode($schoolName) . '&track=' . urlencode($trackingId);
writeLog("returnUrl: $returnUrl");
$payload = [
'tracking_id' => $trackingId,
'operations' => [[
'operation' => 'API_INTEGRATION',
'api_integration_preference' => [
'rest_api_integration' => [
'integration_method' => 'PAYPAL',
'integration_type' => 'THIRD_PARTY',
'third_party_details' => ['features' => ['PAYMENT', 'REFUND', 'VAULT', 'BILLING_AGREEMENT']]
]
]
]],
'products' => ['PPCP', 'PAYMENT_METHODS', 'ADVANCED_VAULTING'],
'capabilities' => ['APPLE_PAY', 'GOOGLE_PAY', 'PAYPAL_WALLET_VAULTING_ADVANCED'],
'legal_consents' => [['type' => 'SHARE_DATA_CONSENT', 'granted' => true]],
'partner_config_override' => [
'return_url' => $returnUrl,
'return_url_description' => 'Return to School Setup',
'show_add_credit_card' => false
]
];
writeLog("api call");
$resp = $client->api('POST', '/v2/customer/partner-referrals', $payload);
writeLog('Partner referral status: ' . $resp['status']);
writeLog('Partner referral body: ' . json_encode($resp['body']));
writeLog("api call - complete");
writeLog("resp status: ".$resp['status']);
if ($resp['status'] < 200 || $resp['status'] >= 300) {
echo json_encode(['success' => false, 'error' => 'Partner referral failed']);
exit();
}
$body = $resp['body'];
writeLog("body partner_referral_id: ".$body['partner_referral_id']);
$partnerReferralId = $body['partner_referral_id'] ?? $body['referral_id'] ?? null;
$actionUrl = null;
foreach ($body['links'] ?? [] as $link) {
if (($link['rel'] ?? '') === 'action_url') {
$actionUrl = $link['href'];
break;
}
}
writeLog("actionUrl: $actionUrl");
if (!$actionUrl) {
writeLog("No onboarding URL from PayPal");
echo json_encode(['success' => false, 'error' => 'No onboarding URL from PayPal']);
exit();
}
$pendingFile = $dataDir . '/pending_onboarding.json';
writeLog("pendingFile: $pendingFile");
$pending = file_exists($pendingFile) ? json_decode(file_get_contents($pendingFile), true) ?: [] : [];
$pending[$trackingId] = [
'school' => $schoolName,
'created_at' => time(),
'partner_referral_id' => $partnerReferralId,
'status' => 'pending'
];
file_put_contents($pendingFile, json_encode($pending, JSON_PRETTY_PRINT));
writeLog("json_encode call");
echo json_encode(['success' => true, 'redirect' => $actionUrl]);
exit();
writeLog("still going...");
}
// ACTION: Sync pending merchants
if ($_POST['action'] === 'sync') {
$pendingFile = $dataDir . '/pending_onboarding.json';
if (!file_exists($pendingFile)) {
echo json_encode(['success' => false, 'error' => 'No pending schools']);
exit();
}
$pending = json_decode(file_get_contents($pendingFile), true) ?: [];
$synced = 0;
$db = getDbConnection();
foreach ($pending as $trackingId => $data) {
if (!in_array($data['status'] ?? '', ['pending', 'waiting_for_paypal', 'incomplete_onboarding'])) {
continue;
}
$school = $data['school'] ?? 'Unknown';
$partnerReferralId = $data['partner_referral_id'] ?? null;
$merchantId = null;
// Try 3 approaches
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id'])
. '/merchant-integrations?tracking_id=' . urlencode($trackingId);
$resp = $client->api('GET', $path);
if ($resp['status'] >= 200 && $resp['status'] < 300) {
$foundId = $resp['body']['merchant_id'] ?? null;
if ($foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
}
if (!$merchantId) {
foreach ($resp['body']['merchant_integrations'] ?? [] as $integration) {
$foundId = $integration['merchant_id'] ?? null;
if ($foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break;
}
}
}
}
if (!$merchantId && $partnerReferralId) {
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id'])
. '/merchant-integrations?onboarded=true';
$resp = $client->api('GET', $path);
if ($resp['status'] >= 200 && $resp['status'] < 300) {
foreach ($resp['body']['merchant_integrations'] ?? [] as $integration) {
$refId = $integration['partner_referral_id'] ?? $integration['referral_id'] ?? null;
$foundId = $integration['merchant_id'] ?? null;
if ($refId && strcasecmp($refId, $partnerReferralId) === 0
&& $foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break;
}
}
}
}
if (!$merchantId) {
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id'])
. '/merchant-integrations?page_size=200';
$resp = $client->api('GET', $path);
if ($resp['status'] >= 200 && $resp['status'] < 300) {
foreach ($resp['body']['merchant_integrations'] ?? [] as $integration) {
$tid = $integration['tracking_id'] ?? null;
$refId = $integration['partner_referral_id'] ?? $integration['referral_id'] ?? null;
$foundId = $integration['merchant_id'] ?? null;
if ((($tid && strcasecmp($tid, $trackingId) === 0)
|| ($partnerReferralId && $refId && strcasecmp($refId, $partnerReferralId) === 0))
&& $foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break;
}
}
}
}
if ($merchantId) {
// Save to database. Match by school_name — $school_num is not
// defined in this sync scope; the callback path keys on school_name
// too, so this keeps both write paths consistent.
$stmt = $db->prepare("UPDATE schools SET paypal_merchant_id = ?, paypal_connected_at = NOW(), paypal_tracking_id = ? WHERE school_name = ?");
$stmt->bind_param('sss', $merchantId, $trackingId, $school);
$stmt->execute();
$stmt->close();
// Check seller status
$sellerStatus = $client->getSellerStatus($config['partner_merchant_id'], $merchantId);
writeLog('Seller status [sync] ' . $school . ': paypal_ready=' . ($sellerStatus['paypal_ready'] ? 'true' : 'false') . ' debug_id=' . ($sellerStatus['debug_id'] ?? 'n/a'));
if (!empty($sellerStatus['issues'])) {
writeLog('Seller status issues [sync] ' . $school . ': ' . implode(' | ', $sellerStatus['issues']));
}
// Also keep in JSON as backup
$merchantsFile = $dataDir . '/school_merchants.json';
$merchants = file_exists($merchantsFile) ? json_decode(file_get_contents($merchantsFile), true) ?: [] : [];
$merchants[$school] = [
'merchant_id' => $merchantId,
'tracking_id' => $trackingId,
'connected_at' => time(),
'paypal_ready' => $sellerStatus['paypal_ready'],
'email_confirmed' => $sellerStatus['email_confirmed'],
'payments_receivable' => $sellerStatus['payments_receivable'],
'oauth_ok' => $sellerStatus['oauth_ok'],
'seller_email' => $sellerStatus['seller_email'],
'status_issues' => $sellerStatus['issues'],
'status_checked_at' => time(),
];
file_put_contents($merchantsFile, json_encode($merchants, JSON_PRETTY_PRINT));
$pending[$trackingId]['merchant_id'] = $merchantId;
$pending[$trackingId]['status'] = 'connected';
$pending[$trackingId]['connected_at'] = time();
$pending[$trackingId]['paypal_ready'] = $sellerStatus['paypal_ready'];
$synced++;
}
}
$db->close();
file_put_contents($pendingFile, json_encode($pending, JSON_PRETTY_PRINT));
echo json_encode([
'success' => true,
'synced' => $synced,
'message' => $synced > 0 ? "Connected $synced school(s)" : 'No merchant IDs found yet'
]);
exit();
}
// ACTION: Re-check seller status for a specific school
if ($_POST['action'] === 'check_status') {
$schoolName = trim($_POST['school_name'] ?? '');
if ($schoolName === '') {
echo json_encode(['success' => false, 'error' => 'School name required']);
exit();
}
$merchantsFile = $dataDir . '/school_merchants.json';
if (!file_exists($merchantsFile)) {
echo json_encode(['success' => false, 'error' => 'No merchant data found']);
exit();
}
$merchants = json_decode(file_get_contents($merchantsFile), true) ?: [];
$merchantId = $merchants[$schoolName]['merchant_id'] ?? null;
if (!$merchantId) {
echo json_encode(['success' => false, 'error' => 'School not found or not yet connected']);
exit();
}
$sellerStatus = $client->getSellerStatus($config['partner_merchant_id'], $merchantId);
writeLog('Seller status [manual] ' . $schoolName . ': paypal_ready=' . ($sellerStatus['paypal_ready'] ? 'true' : 'false') . ' debug_id=' . ($sellerStatus['debug_id'] ?? 'n/a'));
if (!empty($sellerStatus['issues'])) {
writeLog('Seller status issues [manual] ' . $schoolName . ': ' . implode(' | ', $sellerStatus['issues']));
}
$merchants[$schoolName]['paypal_ready'] = $sellerStatus['paypal_ready'];
$merchants[$schoolName]['email_confirmed'] = $sellerStatus['email_confirmed'];
$merchants[$schoolName]['payments_receivable'] = $sellerStatus['payments_receivable'];
$merchants[$schoolName]['oauth_ok'] = $sellerStatus['oauth_ok'];
$merchants[$schoolName]['seller_email'] = $sellerStatus['seller_email'];
$merchants[$schoolName]['status_issues'] = $sellerStatus['issues'];
$merchants[$schoolName]['status_checked_at'] = time();
file_put_contents($merchantsFile, json_encode($merchants, JSON_PRETTY_PRINT));
echo json_encode([
'success' => true,
'paypal_ready' => $sellerStatus['paypal_ready'],
'email_confirmed' => $sellerStatus['email_confirmed'],
'payments_receivable' => $sellerStatus['payments_receivable'],
'oauth_ok' => $sellerStatus['oauth_ok'],
'seller_email' => $sellerStatus['seller_email'],
'issues' => $sellerStatus['issues'],
]);
exit();
}
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
exit();
}
}
// Handle PayPal callback
if (isset($_GET['callback']) && $_GET['callback'] == '1') {
writeLog('PayPal callback received: ' . ($_SERVER['QUERY_STRING'] ?? ''));
$config = require __DIR__ . '/config.php';
require __DIR__ . '/lib/PayPalClient.php';
$school = $_GET['school'] ?? '';
$trackingId = $_GET['track'] ?? '';
// NOTE: PayPal's "merchantId" return param is OUR tracking_id, not the PayPal
// merchant ID. The real PayPal merchant ID is "merchantIdInPayPal".
$merchantIdFromUrl = $_GET['merchantIdInPayPal'] ?? $_GET['merchant_id'] ?? null;
writeLog("callback parsed: school=$school track=$trackingId merchantIdInPayPal=" . ($merchantIdFromUrl ?? 'null'));
$dataDir = __DIR__ . '/data';
if (!is_dir($dataDir)) {
@mkdir($dataDir, 0755, true);
writeLog("created dataDir: $dataDir");
}
if (!is_writable($dataDir)) {
writeLog("WARNING: dataDir not writable: $dataDir");
}
if ($school && $trackingId) {
try {
$client = new PayPalClient($config);
$merchantId = $merchantIdFromUrl;
$pendingFile = $dataDir . '/pending_onboarding.json';
$partnerReferralId = null;
if (file_exists($pendingFile)) {
$pending = json_decode(file_get_contents($pendingFile), true) ?: [];
$partnerReferralId = $pending[$trackingId]['partner_referral_id'] ?? null;
}
if (!$merchantId) {
for ($i = 0; $i < 15; $i++) {
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id'])
. '/merchant-integrations?tracking_id=' . urlencode($trackingId);
$resp = $client->api('GET', $path);
if ($resp['status'] >= 200 && $resp['status'] < 300) {
$foundId = $resp['body']['merchant_id'] ?? null;
if ($foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break;
}
foreach ($resp['body']['merchant_integrations'] ?? [] as $integration) {
$foundId = $integration['merchant_id'] ?? null;
if ($foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break 2;
}
}
}
if (!$merchantId && $partnerReferralId) {
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id'])
. '/merchant-integrations?onboarded=true';
$resp = $client->api('GET', $path);
if ($resp['status'] >= 200 && $resp['status'] < 300) {
foreach ($resp['body']['merchant_integrations'] ?? [] as $integration) {
$refId = $integration['partner_referral_id'] ?? $integration['referral_id'] ?? null;
$foundId = $integration['merchant_id'] ?? null;
if ($refId && strcasecmp($refId, $partnerReferralId) === 0
&& $foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break 2;
}
}
}
}
if (!$merchantId) {
$path = '/v1/customer/partners/' . urlencode($config['partner_merchant_id'])
. '/merchant-integrations?page_size=200';
$resp = $client->api('GET', $path);
if ($resp['status'] >= 200 && $resp['status'] < 300) {
foreach ($resp['body']['merchant_integrations'] ?? [] as $integration) {
$tid = $integration['tracking_id'] ?? null;
$refId = $integration['partner_referral_id'] ?? $integration['referral_id'] ?? null;
$foundId = $integration['merchant_id'] ?? null;
if ((($tid && strcasecmp($tid, $trackingId) === 0)
|| ($partnerReferralId && $refId && strcasecmp($refId, $partnerReferralId) === 0))
&& $foundId && strcasecmp($foundId, $config['partner_merchant_id']) !== 0) {
$merchantId = $foundId;
break 2;
}
}
}
}
if ($i < 14) sleep(1);
}
}
if ($merchantId) {
// Persist the merchant ID immediately. We already have it from the
// callback URL, so the connection MUST be recorded (and the dashboard
// updated) even if the follow-up seller-status check fails or PayPal
// hasn't finished propagating the new integration yet.
$merchantsFile = $dataDir . '/school_merchants.json';
$merchants = file_exists($merchantsFile) ? json_decode(file_get_contents($merchantsFile), true) ?: [] : [];
$merchants[$school] = array_merge($merchants[$school] ?? [], [
'merchant_id' => $merchantId,
'tracking_id' => $trackingId,
'connected_at' => time(),
]);
$written = file_put_contents($merchantsFile, json_encode($merchants, JSON_PRETTY_PRINT));
writeLog('Merchant saved [callback] ' . $school . ' => ' . $merchantId
. ' (' . ($written === false ? 'WRITE FAILED to ' . $merchantsFile : $written . ' bytes to ' . $merchantsFile) . ')');
// If we can't persist, do NOT show a fake success page — surface the
// real problem (almost always: the data/ directory is not writable
// by the web-server user) so it can be fixed.
if ($written === false) {
$writable = is_writable($dataDir) ? 'writable' : 'NOT writable';
header('Location: onboard.php?status=error&msg=' . urlencode(
'Could not save merchant data. data/ directory (' . $dataDir . ') is ' . $writable
. '. Fix filesystem permissions so PHP can write to it.'));
exit();
}
if (file_exists($pendingFile)) {
$pending = json_decode(file_get_contents($pendingFile), true) ?: [];
if (isset($pending[$trackingId])) {
$pending[$trackingId]['merchant_id'] = $merchantId;
$pending[$trackingId]['status'] = 'connected';
$pending[$trackingId]['connected_at'] = time();
file_put_contents($pendingFile, json_encode($pending, JSON_PRETTY_PRINT));
}
}
// Save to database (non-fatal — a DB failure must not lose the JSON record)
try {
$db = getDbConnection();
$stmt = $db->prepare("UPDATE schools SET paypal_merchant_id = ?, paypal_connected_at = NOW(), paypal_tracking_id = ? WHERE school_name = ?");
$stmt->bind_param('sss', $merchantId, $trackingId, $school);
$stmt->execute();
writeLog('DB update [callback] ' . $school . ': affected_rows=' . $stmt->affected_rows);
$stmt->close();
$db->close();
} catch (Exception $e) {
writeLog('DB Error [callback]: ' . $e->getMessage());
}
// Enrich with seller status — guarded so a status-check failure
// never discards the connection we just saved above.
try {
$sellerStatus = $client->getSellerStatus($config['partner_merchant_id'], $merchantId);
writeLog('Seller status [callback] ' . $school . ': paypal_ready=' . ($sellerStatus['paypal_ready'] ? 'true' : 'false') . ' debug_id=' . ($sellerStatus['debug_id'] ?? 'n/a'));
if (!empty($sellerStatus['issues'])) {
writeLog('Seller status issues [callback] ' . $school . ': ' . implode(' | ', $sellerStatus['issues']));
}
$merchants[$school] = array_merge($merchants[$school], [
'paypal_ready' => $sellerStatus['paypal_ready'],
'email_confirmed' => $sellerStatus['email_confirmed'],
'payments_receivable' => $sellerStatus['payments_receivable'],
'oauth_ok' => $sellerStatus['oauth_ok'],
'seller_email' => $sellerStatus['seller_email'],
'status_issues' => $sellerStatus['issues'],
'status_checked_at' => time(),
]);
file_put_contents($merchantsFile, json_encode($merchants, JSON_PRETTY_PRINT));
if (file_exists($pendingFile)) {
$pending = json_decode(file_get_contents($pendingFile), true) ?: [];
if (isset($pending[$trackingId])) {
$pending[$trackingId]['paypal_ready'] = $sellerStatus['paypal_ready'];
file_put_contents($pendingFile, json_encode($pending, JSON_PRETTY_PRINT));
}
}
} catch (Exception $e) {
writeLog('Seller status check failed [callback] ' . $school . ': ' . $e->getMessage());
}
header('Location: onboard.php?success=1&school=' . urlencode($school));
exit();
} else {
if (file_exists($pendingFile)) {
$pending = json_decode(file_get_contents($pendingFile), true) ?: [];
if (isset($pending[$trackingId])) {
$pending[$trackingId]['status'] = 'incomplete_onboarding';
$pending[$trackingId]['last_callback'] = time();
file_put_contents($pendingFile, json_encode($pending, JSON_PRETTY_PRINT));
}
}
header('Location: onboard.php?status=error&msg=' . urlencode('Please complete PayPal onboarding or sync later'));
exit();
}
} catch (Exception $e) {
header('Location: onboard.php?status=error&msg=' . urlencode($e->getMessage()));
exit();
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>School Merchant ID Setup</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
max-width: 500px;
width: 100%;
}
h1 { color: #333; margin-bottom: 10px; font-size: 28px; }
.subtitle { color: #666; margin-bottom: 30px; font-size: 14px; }
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 8px; color: #555; font-weight: 500; }
input[type="text"] {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 16px;
}
input[type="text"]:focus { outline: none; border-color: #667eea; }
.btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
}
.btn:hover { transform: translateY(-2px); box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3); }
.btn:disabled { background: #ccc; cursor: not-allowed; }
.btn.success { background: linear-gradient(135deg, #28a745 0%, #20c997 100%); }
.alert {
padding: 12px;
border-radius: 6px;
margin-bottom: 20px;
font-size: 14px;
}
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.alert-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.success-page {
text-align: center;
padding: 20px;
}
.success-icon {
font-size: 64px;
color: #28a745;
margin-bottom: 20px;
}
.success-page h2 {
color: #28a745;
margin-bottom: 15px;
font-size: 24px;
}
.success-page p {
color: #666;
margin-bottom: 10px;
line-height: 1.6;
}
.school-name {
font-weight: bold;
color: #667eea;
font-size: 18px;
}
.sync-section {
margin-top: 30px;
padding-top: 30px;
border-top: 2px solid #f0f0f0;
}
</style>
</head>
<body>
<div class="container">
<?php if (isset($_GET['success']) && $_GET['success'] == '1'): ?>
<!-- Success Page -->
<div class="success-page">
<div class="success-icon">✓</div>
<h2>Welcome!</h2>
<p>You have successfully connected your PayPal account.</p>
<p class="school-name"><?php echo htmlspecialchars($_GET['school'] ?? 'Your school'); ?></p>
<p style="margin-top: 20px; font-size: 13px;">Your merchant ID has been saved to the database.</p>
<button onclick="window.location.href='onboard.php'" class="btn success" style="margin-top: 30px;">
← Back to Home
</button>
</div>
<?php else: ?>
<!-- Main Form -->
<h1>🏫 School Merchant Setup</h1>
<p class="subtitle">Connect your school's PayPal account</p>
<?php
$message = '';
$messageType = '';
if (isset($_GET['status'])) {
if ($_GET['status'] === 'error') {
$message = $_GET['msg'] ?? 'An error occurred. Please try again.';
$messageType = 'error';
}
}
if ($message): ?>
<div class="alert alert-<?php echo htmlspecialchars($messageType); ?>">
<?php echo htmlspecialchars($message); ?>
</div>
<?php endif; ?>
<form id="connectForm">
<div class="form-group">
<label for="school_name"><?php displayLanguageText('TEXT-SCHOOL-NAME'); ?></label>
<input type="text" id="school_name" name="school_name" placeholder="Enter school name" value="<?php echo($school_record['school_name']) ?>" required>
</div>
<button type="submit" class="btn" id="connectBtn">Connect PayPal Account</button>
</form>
<?php endif; ?>
</div>
<script>
// Connect form handler (the form is absent on the success page)
var connectForm = document.getElementById('connectForm');
if (connectForm) connectForm.addEventListener('submit', function(e) {
e.preventDefault();
const btn = document.getElementById('connectBtn');
const schoolName = document.getElementById('school_name').value.trim();
if (!schoolName) {
alert('Please enter a school name');
return;
}
btn.disabled = true;
btn.textContent = 'Connecting...';
fetch('onboard.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'action=connect&school_name=' + encodeURIComponent(schoolName)
})
.then(r => r.json())
.then(data => {
if (data.success && data.redirect) {
window.location.href = data.redirect;
} else {
alert('Error: ' + (data.error || 'Connection failed'));
btn.disabled = false;
btn.textContent = 'Connect PayPal Account';
}
})
.catch(err => {
alert('Error: ' + err.message);
btn.disabled = false;
btn.textContent = 'Connect PayPal Account';
});
});
</script>
</body>
</html>
+469
View File
@@ -0,0 +1,469 @@
<?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()]);
}
+585
View File
@@ -0,0 +1,585 @@
<?php
/**
* PayPal Checkout Widget
* ---------------------
* Drop-in include for any school payment page.
* Renders PayPal buttons FIRST, above all other payment options.
*
* Usage — set $ppCheckout before including this file:
*
* $ppCheckout = [
* 'school_name' => 'Springfield Elementary', // required
* 'amount' => '25.00', // required (string)
* 'currency' => 'USD', // optional, default USD
* 'items' => [ // optional line items
* ['name' => 'Lunch x5', 'quantity' => '5',
* 'unit_amount' => ['currency_code' => 'USD', 'value' => '5.00']],
* ],
* 'buyer_email' => 'parent@school.com', // optional — prefills PayPal login
* 'page_type' => 'checkout', // checkout | product-detail | cart
* 'order_api_url' => '/sm/order-api.php', // URL to order-api.php
* 'success_url' => '/payment-success', // redirect after capture
* 'cancel_url' => '', // optional — redirect on cancel
* ];
* require_once '/path/to/ppp/paypal-checkout-widget.php';
*
* After the widget, add your other payment options (card, etc.) — they will appear below the divider.
*/
if (!isset($ppCheckout) || !is_array($ppCheckout)) {
throw new RuntimeException('$ppCheckout array must be set before including paypal-checkout-widget.php');
}
// -- Resolve merchant ID -------------------------------------------------------
$_ppConfig = require __DIR__ . '/config.php';
$_ppMerchantId = null;
$_ppSchoolReady = true;
$_ppMerchantsFile = __DIR__ . '/data/school_merchants.json';
if (file_exists($_ppMerchantsFile)) {
$_ppMerchants = json_decode(file_get_contents($_ppMerchantsFile), true) ?: [];
$_ppRecord = $_ppMerchants[$ppCheckout['school_name']] ?? null;
if ($_ppRecord) {
$_ppMerchantId = $_ppRecord['merchant_id'] ?? null;
if (isset($_ppRecord['paypal_ready'])) {
$_ppSchoolReady = (bool)$_ppRecord['paypal_ready'];
}
}
}
// Silently skip rendering if school is not connected or not ready
if (!$_ppMerchantId || !$_ppSchoolReady) {
return;
}
// -- FraudNet correlation ID (one per session) ---------------------------------
if (session_status() !== PHP_SESSION_ACTIVE) {
// Session started by the main app — we just read from it
}
if (empty($_SESSION['pp_fraudnet_id'])) {
$_SESSION['pp_fraudnet_id'] = bin2hex(random_bytes(16));
}
$_ppFraudnetId = $_SESSION['pp_fraudnet_id'];
// -- Params --------------------------------------------------------------------
$_ppClientId = $_ppConfig['client_id'];
$_ppCurrency = strtoupper($ppCheckout['currency'] ?? 'USD');
$_ppPageType = $ppCheckout['page_type'] ?? 'checkout';
// PayPal's SDK only accepts a fixed set of data-page-type values; an unknown
// value makes the SDK throw "Invalid page type" and no buttons render. Map
// common aliases and fall back to 'checkout' for anything unrecognised.
$_ppValidPageTypes = [
'home', 'category', 'product-listing', 'search-results', 'product-details',
'cart', 'mini-cart', 'checkout', 'order-review', 'order-confirmation',
'account', 'profile', 'blog', 'about', 'contact', 'landing', 'marketing',
'help', 'search', 'unknown',
];
$_ppPageTypeAliases = ['product-detail' => 'product-details', 'product' => 'product-details'];
$_ppPageType = $_ppPageTypeAliases[$_ppPageType] ?? $_ppPageType;
if (!in_array($_ppPageType, $_ppValidPageTypes, true)) {
$_ppPageType = 'checkout';
}
$_ppApiUrl = $ppCheckout['order_api_url'] ?? './order-api.php';
$_ppSuccessUrl = $ppCheckout['success_url'] ?? '';
$_ppCancelUrl = $ppCheckout['cancel_url'] ?? '';
$_ppAmount = $ppCheckout['amount'];
$_ppItems = json_encode($ppCheckout['items'] ?? [], JSON_UNESCAPED_UNICODE);
$_ppSchool = $ppCheckout['school_name'];
$_ppEmail = $ppCheckout['buyer_email'] ?? '';
// Optional — forwarded to order-api.php on capture so the payment can be booked
// to the buyer's account. Defaults to 0 (e.g. demos) where there's no app user.
$_ppUserNum = (int)($ppCheckout['user_num'] ?? 0);
$_ppEnv = strtolower($_ppConfig['env'] ?? 'sandbox');
// Pay Later promo messaging — the SDK's messaging component has thrown
// unhandled "PAGE_TYPE" exceptions in some environments. Set
// 'paylater_messaging' => false in config.php to exclude the component and
// its render entirely (buttons/wallets are unaffected).
// Defaults to FALSE: the SDK's messaging component throws an unhandled
// "Cannot set properties of undefined (setting 'PAGE_TYPE')" that aborts the
// SDK onload handler and stops the wallet buttons initialising. Opt in
// explicitly with 'paylater_messaging' => true once that SDK bug is resolved —
// a config file that predates this key must not silently re-enable the crash.
$_ppMessagesOn = (bool)($_ppConfig['paylater_messaging'] ?? false);
// -- JS SDK URL ----------------------------------------------------------------
$_ppSdkUrl = 'https://www.paypal.com/sdk/js'
. '?client-id=' . urlencode($_ppClientId)
. '&merchant-id=' . urlencode($_ppMerchantId)
. '&currency=' . $_ppCurrency
. '&intent=capture'
. '&commit=true'
. '&enable-funding=venmo,paylater'
. '&components=buttons' . ($_ppMessagesOn ? ',messages' : '') . ',applepay,googlepay';
// -- Unique widget ID (allows multiple widgets on same page) -------------------
// NOTE: no hyphens — this id is interpolated into the pp_initButtons_<id>
// JavaScript function name, and hyphens would make that an invalid identifier
// (JS would read them as minus operators ? SyntaxError). It's also used for DOM
// ids / CSS selectors, where an alphanumeric id is perfectly valid.
$_ppWidgetId = 'ppwidget' . substr(md5($_ppSchool . $_ppAmount), 0, 8);
?>
<?php /* -- FraudNet (invisible, must load before buttons) -- */ ?>
<script type="application/json" fncls="fnparams-dede7cc5-15fd-4c75-a9f4-36c430ee3a99">
{"f":"<?= htmlspecialchars($_ppFraudnetId) ?>","s":"HOTLUNCHES_CHECKOUT"}
</script>
<script type="text/javascript" src="https://c.paypal.com/da/r/fb.js"></script>
<?php /* -- Widget HTML -- */ ?>
<div id="<?= $_ppWidgetId ?>" class="pp-checkout-wrap">
<?php /* Acceptance banner — "We accept PayPal and Venmo" (checklist row 12) */ ?>
<div class="pp-accept-banner">
<svg class="pp-icon-paypal" viewBox="0 0 101 32" xmlns="http://www.w3.org/2000/svg" aria-label="PayPal">
<path fill="#003087" d="M12.237 2.179H5.771C5.338 2.179 4.97 2.49 4.903 2.918L2.179 20.165c-.05.314.193.599.511.599h3.07c.433 0 .801-.311.868-.739l.695-4.407c.066-.428.434-.739.867-.739h2.049c4.266 0 6.729-2.063 7.376-6.152.29-1.788.012-3.192-.818-4.176-.913-1.079-2.532-1.372-4.56-1.372zm.748 6.061c-.354 2.322-2.132 2.322-3.851 2.322h-.978l.686-4.343c.041-.261.267-.453.531-.453h.449c1.17 0 2.275 0 2.845.667.341.399.445.99.318 1.807z"/>
<path fill="#009cde" d="M33.952 8.18h-3.08c-.265 0-.491.192-.531.453l-.137.867-.217-.314c-.671-.974-2.167-1.299-3.661-1.299-3.425 0-6.348 2.596-6.918 6.234-.296 1.815.125 3.549 1.148 4.757 1.027 1.218 2.493 1.727 4.237 1.727 2.762 0 4.294-1.776 4.294-1.776l-.138.861c-.05.314.194.599.512.599h2.773c.434 0 .802-.311.868-.739l1.664-10.57c.051-.314-.193-.6-.814-.8zm-4.299 6.032c-.299 1.773-1.707 2.964-3.501 2.964-.899 0-1.619-.289-2.081-.836-.458-.543-.631-1.316-.487-2.176.279-1.757 1.706-2.986 3.474-2.986.88 0 1.595.291 2.066.841.473.554.661 1.33.529 2.193z"/>
</svg>
<span>We accept</span>
<strong>PayPal</strong> &amp; <strong>Venmo</strong>
</div>
<?php /* Pay Later message — promotional text above the buttons (placement: 'payment') */ ?>
<div id="<?= $_ppWidgetId ?>-message" class="pp-paylater-message"></div>
<?php /* PayPal buttons — rendered FIRST before all other payment options */ ?>
<div class="pp-buttons-section">
<p class="pp-section-label">Fastest checkout</p>
<?php /* Apple Pay / Google Pay — hidden until the SDK confirms buyer + merchant eligibility */ ?>
<div id="<?= $_ppWidgetId ?>-applepay" class="pp-wallet-btn pp-applepay-btn" style="display:none;"></div>
<div id="<?= $_ppWidgetId ?>-googlepay" class="pp-wallet-btn pp-googlepay-btn" style="display:none;"></div>
<div id="<?= $_ppWidgetId ?>-buttons" class="pp-buttons-container"></div>
<div id="<?= $_ppWidgetId ?>-error" class="pp-error-msg" style="display:none;"></div>
<div id="<?= $_ppWidgetId ?>-loading" class="pp-loading">
<span class="pp-spinner"></span> Loading payment options…
</div>
</div>
<?php /* Divider before other payment methods */ ?>
<div class="pp-divider"><span>or pay another way</span></div>
</div><?php /* end widget */ ?>
<?php /* -- Scoped styles -- */ ?>
<style>
#<?= $_ppWidgetId ?> {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
width: 100%;
max-width: 500px;
}
/* Acceptance banner */
.pp-accept-banner {
display: flex;
align-items: center;
gap: 6px;
background: #f5f7fa;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px 14px;
font-size: 13px;
color: #444;
margin-bottom: 16px;
}
.pp-accept-banner strong { color: #003087; }
.pp-icon-paypal { height: 18px; width: auto; }
/* Buttons section */
.pp-buttons-section {
background: #fff;
border: 1.5px solid #e2e8f0;
border-radius: 10px;
padding: 18px 16px 14px;
}
.pp-section-label {
font-size: 12px;
font-weight: 600;
color: #718096;
text-transform: uppercase;
letter-spacing: .5px;
margin: 0 0 12px;
}
.pp-buttons-container { min-height: 45px; }
/* Apple Pay / Google Pay wallet buttons */
.pp-wallet-btn { margin-bottom: 10px; min-height: 45px; }
.pp-wallet-btn > * { width: 100%; }
.pp-applepay-btn apple-pay-button {
--apple-pay-button-width: 100%;
--apple-pay-button-height: 45px;
--apple-pay-button-border-radius: 8px;
display: block;
}
.pp-googlepay-btn .gpay-card-info-container,
.pp-googlepay-btn button { width: 100% !important; border-radius: 8px !important; }
/* Loading skeleton */
.pp-loading {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #718096;
padding: 10px 0;
}
.pp-spinner {
display: inline-block;
width: 16px; height: 16px;
border: 2px solid #e2e8f0;
border-top-color: #003087;
border-radius: 50%;
animation: pp-spin .7s linear infinite;
}
@keyframes pp-spin { to { transform: rotate(360deg); } }
/* Error message */
.pp-error-msg {
background: #fff5f5;
border: 1px solid #feb2b2;
color: #c53030;
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
margin-top: 12px;
}
/* Divider */
.pp-divider {
display: flex;
align-items: center;
gap: 12px;
margin: 20px 0;
color: #a0aec0;
font-size: 13px;
}
.pp-divider::before,
.pp-divider::after {
content: '';
flex: 1;
height: 1px;
background: #e2e8f0;
}
/* Pay Later message */
.pp-paylater-message {
min-height: 20px;
margin-bottom: 10px;
font-size: 13px;
}
</style>
<?php /* -- PayPal JS SDK (one load per page via global flag) -- */ ?>
<?php
$_ppSdkAlreadyLoaded = $GLOBALS['_pp_sdk_loaded'] ?? false;
if (!$_ppSdkAlreadyLoaded) { $GLOBALS['_pp_sdk_loaded'] = true; }
?>
<?php
/* Wallet SDKs (Apple Pay web component + Google Pay client) load once whenever the
* checkout widget is on the page — independent of which include loaded the PayPal SDK,
* since the PayPal SDK may have been loaded earlier by the Pay Later message. */
$_ppWalletSdkLoaded = $GLOBALS['_pp_wallet_sdk_loaded'] ?? false;
if (!$_ppWalletSdkLoaded) { $GLOBALS['_pp_wallet_sdk_loaded'] = true; }
?>
<?php if (!$_ppWalletSdkLoaded): ?>
<script src="https://applepay.cdn-apple.com/jsapi/v1.1.0/apple-pay-sdk.js"></script>
<script src="https://pay.google.com/gp/p/js/pay.js"></script>
<?php endif; ?>
<?php /* -- Button initialisation -- */ ?>
<script>
(function () {
var WIDGET_ID = <?= json_encode($_ppWidgetId) ?>;
var SCHOOL = <?= json_encode($_ppSchool) ?>;
var AMOUNT = <?= json_encode($_ppAmount) ?>;
var CURRENCY = <?= json_encode($_ppCurrency) ?>;
var ITEMS = <?= $_ppItems ?>;
var BUYER_EMAIL = <?= json_encode($_ppEmail) ?>;
var USER_NUM = <?= json_encode((string)$_ppUserNum) ?>;
var FRAUDNET_ID = <?= json_encode($_ppFraudnetId) ?>;
var API_URL = <?= json_encode($_ppApiUrl) ?>;
var SUCCESS_URL = <?= json_encode($_ppSuccessUrl) ?>;
var CANCEL_URL = <?= json_encode($_ppCancelUrl) ?>;
var GP_ENV = <?= json_encode($_ppEnv === 'live' ? 'PRODUCTION' : 'TEST') ?>;
var $loading = document.getElementById(WIDGET_ID + '-loading');
var $error = document.getElementById(WIDGET_ID + '-error');
var $buttons = document.getElementById(WIDGET_ID + '-buttons');
function showError(msg) {
if ($error) { $error.style.display = 'block'; $error.textContent = msg; }
if ($loading) $loading.style.display = 'none';
}
function post(body) {
return fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(function (r) { return r.json(); });
}
window['pp_sdkError_' + WIDGET_ID] = function () {
showError('Could not load PayPal. Please refresh the page or try another payment method.');
};
// -- Shared order helpers (used by PayPal, Apple Pay and Google Pay) --
function createOrder() {
if ($error) $error.style.display = 'none';
return post({
action: 'create_order',
school_name: SCHOOL,
amount: AMOUNT,
currency: CURRENCY,
items: ITEMS,
buyer_email: BUYER_EMAIL,
fraudnet_id: FRAUDNET_ID,
}).then(function (d) {
if (!d.success) throw new Error(d.error || 'Order creation failed');
return d.order_id;
});
}
function captureOrder(orderId) {
return post({
action: 'capture_order',
order_id: orderId,
school_name: SCHOOL,
user_num: USER_NUM,
}).then(function (d) {
if (!d.success) throw new Error(d.error || 'Payment could not be completed');
if (SUCCESS_URL) {
var sep = SUCCESS_URL.indexOf('?') === -1 ? '?' : '&';
window.location.href = SUCCESS_URL + sep + 'capture_id=' + encodeURIComponent(d.capture_id || '');
}
return d;
});
}
window['pp_initButtons_' + WIDGET_ID] = function () {
if ($loading) $loading.style.display = 'none';
var paypalNS = window.paypal;
if (!paypalNS || !paypalNS.Buttons) {
showError('PayPal failed to initialise. Please refresh the page.');
return;
}
paypalNS.Buttons({
style: {
layout: 'vertical',
color: 'gold',
shape: 'rect',
label: 'pay',
},
// -- Create order -------------------------------------------
createOrder: function () {
return createOrder();
},
// -- Approve ? capture ---------------------------------------
onApprove: function (data) {
return captureOrder(data.orderID);
},
// -- Cancel -------------------------------------------------
onCancel: function () {
if (CANCEL_URL) window.location.href = CANCEL_URL;
},
// -- Error --------------------------------------------------
onError: function (err) {
console.error('PayPal error:', err);
showError('Something went wrong with PayPal. Please try again or use another payment method.');
},
}).render('#' + WIDGET_ID + '-buttons');
// Pay Later messaging — renders inline promotional text (e.g. "Pay in 4 payments of $X").
// No `placement`/`pageType` params: the SDK's internal placement?PAGE_TYPE mapping
// has crashed with "Cannot set properties of undefined (setting 'PAGE_TYPE')";
// with only amount + style the component picks sane defaults.
<?php if ($_ppMessagesOn): ?>
if (paypalNS.Messages) {
try {
paypalNS.Messages({
amount: parseFloat(AMOUNT),
style: { layout: 'text', logo: { type: 'inline' }, text: { size: 13 } },
}).render('#' + WIDGET_ID + '-message');
} catch (e) {
console.warn('Pay Later message skipped:', e);
}
}
<?php endif; ?>
// Wallet buttons — each self-hides if buyer device or merchant isn't eligible.
// Wrapped individually: a throw inside either init (e.g. the SDK's messaging
// component raising "Cannot set properties of undefined (setting 'PAGE_TYPE')")
// must not abort this handler, or the other wallet — and any code after it —
// silently never runs. PayPal/card buttons render above and are unaffected.
try { initApplePay(paypalNS); } catch (e) { console.warn('Apple Pay init skipped:', e); }
try { initGooglePay(paypalNS); } catch (e) { console.warn('Google Pay init skipped:', e); }
};
// -- Apple Pay --------------------------------------------------
// Eligibility: Safari + a device with Apple Pay, served over HTTPS on a
// domain registered with PayPal, and the merchant granted the APPLE_PAY
// capability. If any of these fail the button simply never shows.
function initApplePay(paypalNS) {
// Diagnostics — open the browser console to see which gate hides the button.
if (!paypalNS.Applepay) {
console.warn('[ApplePay] hidden: SDK applepay component not loaded (paypal.Applepay missing).');
return;
}
if (!window.ApplePaySession) {
console.warn('[ApplePay] hidden: not Safari / no Apple Pay on this device (window.ApplePaySession undefined). Apple Pay only renders in Safari on Apple hardware.');
return;
}
if (!ApplePaySession.canMakePayments()) {
console.warn('[ApplePay] hidden: ApplePaySession.canMakePayments() is false (no card in Wallet, or device cannot pay).');
return;
}
var applepay = paypalNS.Applepay();
applepay.config().then(function (cfg) {
console.log('[ApplePay] config:', cfg);
if (!cfg || !cfg.isEligible) {
console.warn('[ApplePay] hidden: merchant not eligible (isEligible=false). Merchant lacks the APPLE_PAY capability, or the domain is not registered/verified with PayPal.');
return;
}
var $ap = document.getElementById(WIDGET_ID + '-applepay');
$ap.innerHTML = '<apple-pay-button buttonstyle="black" type="buy" locale="en-US"></apple-pay-button>';
$ap.style.display = 'block';
$ap.querySelector('apple-pay-button').addEventListener('click', function () {
var paymentRequest = {
countryCode: cfg.countryCode,
currencyCode: CURRENCY,
merchantCapabilities: cfg.merchantCapabilities,
supportedNetworks: cfg.supportedNetworks,
requiredBillingContactFields: ['name', 'phone', 'email', 'postalAddress'],
total: { label: SCHOOL, amount: AMOUNT, type: 'final' },
};
var session = new ApplePaySession(4, paymentRequest);
session.onvalidatemerchant = function (event) {
applepay.validateMerchant({ validationUrl: event.validationURL })
.then(function (payload) { session.completeMerchantValidation(payload.merchantSession); })
.catch(function (err) { console.error('Apple Pay validateMerchant:', err); session.abort(); });
};
session.onpaymentauthorized = function (event) {
createOrder().then(function (orderId) {
return applepay.confirmOrder({
orderId: orderId,
token: event.payment.token,
billingContact: event.payment.billingContact,
}).then(function () {
session.completePayment(ApplePaySession.STATUS_SUCCESS);
return captureOrder(orderId);
});
}).catch(function (err) {
console.error('Apple Pay error:', err);
session.completePayment(ApplePaySession.STATUS_FAILURE);
showError('Apple Pay payment could not be completed. Please try another method.');
});
};
session.begin();
});
}).catch(function (err) { console.error('Apple Pay config:', err); });
}
// -- Google Pay -------------------------------------------------
// Eligibility: Google Pay JS loaded, merchant granted the GOOGLE_PAY
// capability, and isReadyToPay() true for this buyer. Self-hides otherwise.
function initGooglePay(paypalNS) {
if (!paypalNS.Googlepay) return;
if (!window.google || !google.payments || !google.payments.api) return;
var googlepay = paypalNS.Googlepay();
googlepay.config().then(function (cfg) {
if (!cfg || !cfg.isEligible) return;
var paymentsClient = new google.payments.api.PaymentsClient({ environment: GP_ENV });
paymentsClient.isReadyToPay({
apiVersion: cfg.apiVersion,
apiVersionMinor: cfg.apiVersionMinor,
allowedPaymentMethods: cfg.allowedPaymentMethods,
}).then(function (res) {
if (!res || !res.result) return;
var $gp = document.getElementById(WIDGET_ID + '-googlepay');
var btn = paymentsClient.createButton({
onClick: onGooglePayClicked,
buttonType: 'pay',
buttonSizeMode: 'fill',
});
$gp.appendChild(btn);
$gp.style.display = 'block';
}).catch(function (err) { console.error('Google Pay isReadyToPay:', err); });
function onGooglePayClicked() {
var paymentDataRequest = {
apiVersion: cfg.apiVersion,
apiVersionMinor: cfg.apiVersionMinor,
allowedPaymentMethods: cfg.allowedPaymentMethods,
merchantInfo: cfg.merchantInfo,
transactionInfo: {
countryCode: cfg.countryCode || 'US',
currencyCode: CURRENCY,
totalPriceStatus: 'FINAL',
totalPrice: AMOUNT,
},
};
paymentsClient.loadPaymentData(paymentDataRequest).then(function (paymentData) {
return createOrder().then(function (orderId) {
return googlepay.confirmOrder({
orderId: orderId,
paymentMethodData: paymentData.paymentMethodData,
}).then(function (confirm) {
if (confirm.status === 'APPROVED' || confirm.status === 'PAYER_ACTION_REQUIRED') {
return captureOrder(orderId);
}
throw new Error('Google Pay not approved: ' + confirm.status);
});
});
}).catch(function (err) {
if (err && err.statusCode === 'CANCELED') return; // buyer closed the sheet
console.error('Google Pay error:', err);
showError('Google Pay payment could not be completed. Please try another method.');
});
}
}).catch(function (err) { console.error('Google Pay config:', err); });
}
}());
</script>
<?php /* -- PayPal JS SDK — loaded AFTER the init/handler definitions above.
A script element's load event can fire before later inline scripts
execute, so putting the SDK tag first raced its onload against the
pp_initButtons_<id> definition (ReferenceError, widget stuck on
"Loading payment options…"). Emitting the SDK tag last guarantees
the handlers exist before onload/onerror can possibly fire. -- */ ?>
<?php if (!$_ppSdkAlreadyLoaded): ?>
<?php /* NOTE: no data-page-type attribute — the SDK has an internal bug where
that attribute makes it throw "Cannot set properties of undefined
(setting 'PAGE_TYPE')" asynchronously after load, which can break the
Pay Later message and wallet eligibility checks. It is an optional
analytics hint only; buttons and messages work fine without it. */ ?>
<script
src="<?= htmlspecialchars($_ppSdkUrl) ?>"
data-partner-attribution-id="EZSSOFTWAREINC_SP_PPCP"
onload="pp_initButtons_<?= $_ppWidgetId ?>()"
onerror="pp_sdkError_<?= $_ppWidgetId ?>()"
></script>
<?php else: ?>
<script>
// SDK was loaded earlier on this page — call init directly (function is defined above)
pp_initButtons_<?= $_ppWidgetId ?>();
</script>
<?php endif; ?>
+142
View File
@@ -0,0 +1,142 @@
<?php
/**
* PayPal Pay Later Messaging — standalone promotional banner.
* Shows text like "Pay in 4 payments of $X" near prices on product / cart pages.
*
* Usage — set $ppMessage before including:
*
* $ppMessage = [
* 'school_name' => 'Springfield Elementary', // required — used to resolve merchant ID
* 'amount' => '95.00', // required — string
* 'currency' => 'USD', // optional, default USD
* 'placement' => 'product', // product | cart | payment | home
* 'page_type' => 'product-detail', // for SDK data-page-type attr
* 'style' => [ // optional — PayPal Messages style object
* 'layout' => 'text',
* 'logo' => ['type' => 'inline'], // inline | primary | alternative | none
* 'text' => ['size' => 13, 'color' => 'black'],
* ],
* ];
* require_once '/path/to/ppp/paypal-paylater-message.php';
*
* Coordinates with paypal-checkout-widget.php via $GLOBALS['_pp_sdk_loaded'] so the
* SDK is never loaded twice on the same page regardless of include order.
*/
if (!isset($ppMessage) || !is_array($ppMessage)) {
throw new RuntimeException('$ppMessage array must be set before including paypal-paylater-message.php');
}
// -- Resolve merchant and check readiness -------------------------------------
$_ppm_config = require __DIR__ . '/config.php';
// Kill-switch shared with the checkout widget: set 'paylater_messaging' => false
// in config.php to disable Pay Later promo messaging everywhere (the SDK's
// messaging component has thrown unhandled PAGE_TYPE exceptions in some envs).
// Defaults to FALSE — see paypal-checkout-widget.php. A config predating this
// key must not re-enable the SDK messaging crash.
if (!(bool)($_ppm_config['paylater_messaging'] ?? false)) return;
$_ppm_merchantId = null;
$_ppm_merchantsFile = __DIR__ . '/data/school_merchants.json';
if (file_exists($_ppm_merchantsFile)) {
$_ppm_merchants = json_decode(file_get_contents($_ppm_merchantsFile), true) ?: [];
$_ppm_record = $_ppm_merchants[$ppMessage['school_name']] ?? null;
if ($_ppm_record) {
if ($ppMessage['school_name'] === '') return;
if ($ppMessage['school_name'] !== '' && ($GLOBALS['_pp_msg_skipped'][$ppMessage['school_name']] ?? false)) return;
if ($_ppm_record['consent_revoked'] ?? false) return;
if (isset($_ppm_record['paypal_ready']) && !$_ppm_record['paypal_ready']) return;
$_ppm_merchantId = $_ppm_record['merchant_id'] ?? null;
}
}
if (!$_ppm_merchantId) return;
// -- Unique container ID (counter prevents collisions when included multiple times) --
$GLOBALS['_pp_msg_count'] = ($GLOBALS['_pp_msg_count'] ?? 0) + 1;
$_ppm_containerId = 'pp-msg-' . $GLOBALS['_pp_msg_count'];
// -- Params --------------------------------------------------------------------
$_ppm_clientId = $_ppm_config['client_id'];
$_ppm_amount = $ppMessage['amount'];
$_ppm_currency = strtoupper($ppMessage['currency'] ?? 'USD');
$_ppm_placement = $ppMessage['placement'] ?? 'product';
$_ppm_pageType = $ppMessage['page_type'] ?? 'product-details';
// PayPal's SDK rejects unknown data-page-type values ("Invalid page type"),
// which aborts the whole SDK load. Map aliases and fall back to a safe value.
$_ppmValidPageTypes = [
'home', 'category', 'product-listing', 'search-results', 'product-details',
'cart', 'mini-cart', 'checkout', 'order-review', 'order-confirmation',
'account', 'profile', 'blog', 'about', 'contact', 'landing', 'marketing',
'help', 'search', 'unknown',
];
$_ppmPageTypeAliases = ['product-detail' => 'product-details', 'product' => 'product-details'];
$_ppm_pageType = $_ppmPageTypeAliases[$_ppm_pageType] ?? $_ppm_pageType;
if (!in_array($_ppm_pageType, $_ppmValidPageTypes, true)) {
$_ppm_pageType = 'product-details';
}
$_ppm_style = $ppMessage['style'] ?? [
'layout' => 'text',
'logo' => ['type' => 'inline'],
'text' => ['size' => 13, 'color' => 'black'],
];
// -- SDK: load once per page ---------------------------------------------------
$_ppmSdkAlready = $GLOBALS['_pp_sdk_loaded'] ?? false;
if (!$_ppmSdkAlready) {
$GLOBALS['_pp_sdk_loaded'] = true;
// Include the full component set (buttons,messages + applepay,googlepay) so the
// checkout widget can share this SDK if it loads after us. The PayPal SDK can only
// be loaded once per page, so if we omit applepay/googlepay here, paypal.Applepay /
// paypal.Googlepay would be undefined in the widget and the wallet buttons vanish.
$_ppm_sdkUrl = 'https://www.paypal.com/sdk/js'
. '?client-id=' . urlencode($_ppm_clientId)
. '&merchant-id=' . urlencode($_ppm_merchantId)
. '&currency=' . $_ppm_currency
. '&components=buttons,applepay,googlepay';
}
?>
<?php /* NOTE: no data-page-type attribute — the SDK throws "Cannot set
properties of undefined (setting 'PAGE_TYPE')" asynchronously when it
is present (internal SDK bug). Optional analytics hint only. */ ?>
<?php if (!$_ppmSdkAlready): ?>
<script
src="<?= htmlspecialchars($_ppm_sdkUrl) ?>"
data-partner-attribution-id="EZSSOFTWAREINC_SP_PPCP"
></script>
<?php endif; ?>
<div id="<?= $_ppm_containerId ?>" class="pp-paylater-banner"></div>
<style>
#<?= $_ppm_containerId ?>.pp-paylater-banner { min-height: 18px; margin: 4px 0 8px; }
</style>
<script>
(function () {
var id = <?= json_encode('#' . $_ppm_containerId) ?>;
// No placement/pageType — the SDK's placement?PAGE_TYPE mapping has crashed
// with "Cannot set properties of undefined"; defaults are fine for text promos.
var config = {
amount: parseFloat(<?= json_encode($_ppm_amount) ?>),
style: <?= json_encode($_ppm_style, JSON_UNESCAPED_UNICODE) ?>,
};
function render() {
if (window.paypal && window.paypal.Messages) {
window.paypal.Messages(config).render(id);
}
}
// If SDK is already present (loaded above or by widget earlier on the page) render now.
// Otherwise DOMContentLoaded is the safe fallback — by then all blocking scripts have run.
if (window.paypal && window.paypal.Messages) {
render();
} else {
document.addEventListener('DOMContentLoaded', render);
}
}());
</script>
+23
View File
@@ -0,0 +1,23 @@
{
"school_9cba7ae0d6": {
"school": "Central Valley Elementary",
"created_at": 1762292589,
"partner_referral_id": null,
"status": "pending"
},
"school_81af9380a7": {
"school": "Central Valley Elementary",
"created_at": 1762292895,
"partner_referral_id": null,
"status": "pending"
},
"school_2cee11f670": {
"school": "test",
"created_at": 1780016094,
"partner_referral_id": null,
"status": "connected",
"merchant_id": "MHHP4TXF2ZYRQ",
"connected_at": 1780016115,
"paypal_ready": false
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"Central Valley Elementary": {
"merchant_id": "7A68CU68VGK8J",
"tracking_id": "school_45ecf71cad",
"connected_at": 1765317686
},
"test": {
"merchant_id": "MHHP4TXF2ZYRQ",
"tracking_id": "school_2cee11f670",
"connected_at": 1780016115,
"paypal_ready": false,
"email_confirmed": false,
"payments_receivable": false,
"oauth_ok": false,
"seller_email": null,
"status_issues": [
"PRIMARY_EMAIL_CONFIRMED is false \u2014 seller must confirm their PayPal email before receiving payments.",
"PAYMENTS_RECEIVABLE is false \u2014 there may be a restriction on the seller's PayPal account.",
"OAUTH_INTEGRATIONS is empty \u2014 seller has not granted third-party permissions."
],
"status_checked_at": 1780016139
}
}
+281
View File
@@ -0,0 +1,281 @@
<?php
/**
* PayPal Webhook Handler
* ──────────────────────
* Setup (do once):
* 1. Deploy this file and note its public URL, e.g. https://app.hotlunches.net/sm/webhook.php
* 2. In PayPal Developer Dashboard → Your App → Webhooks → Add Webhook
* Paste the URL and subscribe to:
* • MERCHANT.PARTNER-CONSENT.REVOKED
* • CUSTOMER.MERCHANT-INTEGRATION.PRODUCT-SUBSCRIPTION-UPDATED
* 3. Copy the Webhook ID that PayPal generates and set 'webhook_id' in config.php
*
* PayPal retries on any non-200 response, so we always respond 200 — errors are logged.
*/
// Read raw body immediately before any output can corrupt it
$rawBody = file_get_contents('php://input');
// Pull in app logging if available
ob_start();
if (file_exists('../include/logging-functions.php')) {
require_once '../include/logging-functions.php';
startLog('webhook');
}
ob_end_clean();
// Always respond 200 to stop PayPal from retrying
http_response_code(200);
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
require_once __DIR__ . '/lib/PayPalClient.php';
// ── Header helper (works on Apache + Nginx + PHP-FPM) ────────────────────────
function ppWebhookHeader(string $name): string
{
if (function_exists('getallheaders')) {
foreach (getallheaders() as $k => $v) {
if (strcasecmp($k, $name) === 0) return $v;
}
}
$serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
return $_SERVER[$serverKey] ?? '';
}
// ── Guard: webhook_id must be configured ────────────────────────────────────
$webhookId = $config['webhook_id'] ?? '';
if ($webhookId === '') {
if (function_exists('writeLog')) {
writeLog('webhook: webhook_id is not set in config.php — skipping signature check');
}
echo json_encode(['status' => 'webhook_id_not_configured']);
exit();
}
// ── Collect PayPal signature headers ─────────────────────────────────────────
$transmissionId = ppWebhookHeader('PAYPAL-TRANSMISSION-ID');
$transmissionTime = ppWebhookHeader('PAYPAL-TRANSMISSION-TIME');
$transmissionSig = ppWebhookHeader('PAYPAL-TRANSMISSION-SIG');
$certUrl = ppWebhookHeader('PAYPAL-CERT-URL');
$authAlgo = ppWebhookHeader('PAYPAL-AUTH-ALGO');
if (!$transmissionId || !$transmissionSig || !$certUrl) {
if (function_exists('writeLog')) {
writeLog('webhook: missing PayPal signature headers — request ignored');
}
echo json_encode(['status' => 'missing_signature_headers']);
exit();
}
// ── Verify signature via PayPal API ──────────────────────────────────────────
// This is the simplest and most reliable verification method.
// PayPal docs: /v1/notifications/verify-webhook-signature
try {
$client = new PayPalClient($config);
$verifyResp = $client->api('POST', '/v1/notifications/verify-webhook-signature', [
'auth_algo' => $authAlgo,
'cert_url' => $certUrl,
'transmission_id' => $transmissionId,
'transmission_sig' => $transmissionSig,
'transmission_time' => $transmissionTime,
'webhook_id' => $webhookId,
'webhook_event' => json_decode($rawBody, true) ?: [],
]);
$verificationStatus = $verifyResp['body']['verification_status'] ?? 'FAILURE';
if ($verificationStatus !== 'SUCCESS') {
if (function_exists('writeLog')) {
writeLog('webhook: signature FAILED verification_status=' . $verificationStatus
. ' debug_id=' . ($verifyResp['debug_id'] ?? 'n/a'));
}
echo json_encode(['status' => 'signature_verification_failed']);
exit();
}
} catch (Throwable $e) {
if (function_exists('writeLog')) {
writeLog('webhook: signature verification threw — ' . $e->getMessage());
}
echo json_encode(['status' => 'verification_error']);
exit();
}
// ── Parse event ───────────────────────────────────────────────────────────────
$event = json_decode($rawBody, true) ?: [];
$eventType = $event['event_type'] ?? '';
$resource = $event['resource'] ?? [];
if (function_exists('writeLog')) {
writeLog('webhook: verified event_type=' . $eventType . ' event_id=' . ($event['id'] ?? '?'));
}
$dataDir = __DIR__ . '/data';
$merchantsFile = $dataDir . '/school_merchants.json';
// ── Helper: persist merchants JSON ───────────────────────────────────────────
function ppSaveMerchants(string $file, array $merchants): void
{
file_put_contents($file, json_encode($merchants, JSON_PRETTY_PRINT));
}
// ── Helper: find school name by merchant_id ───────────────────────────────────
function ppFindSchoolByMerchant(array $merchants, string $merchantId): ?string
{
foreach ($merchants as $school => $data) {
if (($data['merchant_id'] ?? '') === $merchantId) {
return $school;
}
}
return null;
}
// ════════════════════════════════════════════════════════════════════════════
// EVENT: MERCHANT.PARTNER-CONSENT.REVOKED
// The seller has revoked your API permissions from their PayPal account.
// Action: immediately disable PayPal for this school.
// ════════════════════════════════════════════════════════════════════════════
if ($eventType === 'MERCHANT.PARTNER-CONSENT.REVOKED') {
$merchantId = $resource['merchant_id']
?? $resource['payer_id']
?? null;
if (!$merchantId) {
if (function_exists('writeLog')) {
writeLog('webhook: CONSENT.REVOKED — no merchant_id in resource, skipping');
}
echo json_encode(['status' => 'ok']);
exit();
}
if (function_exists('writeLog')) {
writeLog('webhook: CONSENT.REVOKED merchant_id=' . $merchantId);
}
// ── Update JSON ───────────────────────────────────────────────────────
if (file_exists($merchantsFile)) {
$merchants = json_decode(file_get_contents($merchantsFile), true) ?: [];
$schoolName = ppFindSchoolByMerchant($merchants, $merchantId);
if ($schoolName) {
$merchants[$schoolName]['paypal_ready'] = false;
$merchants[$schoolName]['consent_revoked'] = true;
$merchants[$schoolName]['consent_revoked_at'] = time();
$merchants[$schoolName]['status_issues'] = [
'MERCHANT.PARTNER-CONSENT.REVOKED — the seller has revoked API permissions.'
. ' They must reconnect their PayPal account before payments can resume.',
];
ppSaveMerchants($merchantsFile, $merchants);
if (function_exists('writeLog')) {
writeLog('webhook: CONSENT.REVOKED — disabled PayPal for school=' . $schoolName);
}
} else {
if (function_exists('writeLog')) {
writeLog('webhook: CONSENT.REVOKED — merchant_id=' . $merchantId . ' not found in school_merchants.json');
}
}
}
// ── Update DB ─────────────────────────────────────────────────────────
// We keep the paypal_merchant_id so re-onboarding can match the school,
// but a NULL signals "not ready" in any direct DB check.
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');
// If your schema has a paypal_consent_revoked column, set it here.
// Otherwise, null out paypal_merchant_id to block payments at the DB level.
$stmt = $conn->prepare(
"UPDATE schools SET paypal_merchant_id = NULL WHERE paypal_merchant_id = ?"
);
$stmt->bind_param('s', $merchantId);
$stmt->execute();
$affected = $stmt->affected_rows;
$stmt->close();
$conn->close();
if (function_exists('writeLog')) {
writeLog('webhook: CONSENT.REVOKED — nulled paypal_merchant_id for ' . $affected . ' row(s)');
}
}
} catch (Throwable $e) {
if (function_exists('writeLog')) {
writeLog('webhook: CONSENT.REVOKED — DB update failed: ' . $e->getMessage());
}
}
echo json_encode(['status' => 'ok']);
exit();
}
// ════════════════════════════════════════════════════════════════════════════
// EVENT: CUSTOMER.MERCHANT-INTEGRATION.PRODUCT-SUBSCRIPTION-UPDATED
// A product subscription (e.g. PPCP_CUSTOM vetting) has changed.
// Action: re-check seller status to pick up new ACDC / Apple Pay / Google Pay readiness.
// ════════════════════════════════════════════════════════════════════════════
if ($eventType === 'CUSTOMER.MERCHANT-INTEGRATION.PRODUCT-SUBSCRIPTION-UPDATED') {
$merchantId = $resource['merchant_id'] ?? null;
if (!$merchantId) {
if (function_exists('writeLog')) {
writeLog('webhook: PRODUCT-SUBSCRIPTION-UPDATED — no merchant_id in resource, skipping');
}
echo json_encode(['status' => 'ok']);
exit();
}
if (function_exists('writeLog')) {
writeLog('webhook: PRODUCT-SUBSCRIPTION-UPDATED merchant_id=' . $merchantId);
}
if (file_exists($merchantsFile)) {
$merchants = json_decode(file_get_contents($merchantsFile), true) ?: [];
$schoolName = ppFindSchoolByMerchant($merchants, $merchantId);
if ($schoolName) {
try {
$sellerStatus = $client->getSellerStatus($config['partner_merchant_id'], $merchantId);
$merchants[$schoolName] = array_merge($merchants[$schoolName], [
'paypal_ready' => $sellerStatus['paypal_ready'],
'email_confirmed' => $sellerStatus['email_confirmed'],
'payments_receivable' => $sellerStatus['payments_receivable'],
'oauth_ok' => $sellerStatus['oauth_ok'],
'seller_email' => $sellerStatus['seller_email'],
'status_issues' => $sellerStatus['issues'],
'status_checked_at' => time(),
// ACDC fields — updated whenever subscription status changes
'acdc_ready' => $sellerStatus['acdc_ready'],
'acdc_vetting_status' => $sellerStatus['acdc_vetting_status'],
'acdc_denied' => $sellerStatus['acdc_denied'],
'acdc_pending' => $sellerStatus['acdc_pending'],
]);
ppSaveMerchants($merchantsFile, $merchants);
if (function_exists('writeLog')) {
writeLog('webhook: PRODUCT-SUBSCRIPTION-UPDATED — re-checked school=' . $schoolName
. ' paypal_ready=' . ($sellerStatus['paypal_ready'] ? 'true' : 'false'));
}
} catch (Throwable $e) {
if (function_exists('writeLog')) {
writeLog('webhook: PRODUCT-SUBSCRIPTION-UPDATED — getSellerStatus failed: ' . $e->getMessage());
}
}
} else {
if (function_exists('writeLog')) {
writeLog('webhook: PRODUCT-SUBSCRIPTION-UPDATED — merchant_id=' . $merchantId . ' not found');
}
}
}
echo json_encode(['status' => 'ok']);
exit();
}
// ── Unhandled event — still 200 so PayPal stops retrying ─────────────────────
if (function_exists('writeLog')) {
writeLog('webhook: unhandled event_type=' . $eventType . ' — no action taken');
}
echo json_encode(['status' => 'ok', 'note' => 'event_type not handled by this integration']);