First Commit
This commit is contained in:
1 parent
5eb9dd037d
commit
2e62c0e60b
11877 files changed
+2035312
No files matched your search
@@ -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
@@ -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>&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 · Onboarding status</h2>
|
||||
<?php if ($onb['status'] === 'connected_ready'): ?>
|
||||
<div class="ok"><strong>Connected & 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 · 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 · 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>
|
||||
@@ -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">🔒 Secure checkout</span>
|
||||
</nav>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="page-head">
|
||||
<h1>Review & 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 — your card details never touch our servers.</p>
|
||||
|
||||
<?php require __DIR__ . '/paypal-checkout-widget.php'; ?>
|
||||
|
||||
<a class="back-link" href="<?= htmlspecialchars($returnUrl) ?>">← 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>
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"Central Valley Elementary": {
|
||||
"merchant_id": "7A68CU68VGK8J",
|
||||
"tracking_id": "school_45ecf71cad",
|
||||
"connected_at": 1765317686
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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'
|
||||
];
|
||||
|
||||
@@ -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=<connected school name></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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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=<connected school name></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>
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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)
|
||||
. '¤cy=' . $_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> & <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; ?>
|
||||
@@ -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)
|
||||
. '¤cy=' . $_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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
Reference in new issue
Block a user