Files
HotLunches/paypal_ppcp_button.php
T
2026-10-19 15:22:52 -07:00

240 lines
11 KiB
PHP

<?php
/**
* "Pay with PayPal" button — production bridge to the PPCP checkout page
* ----------------------------------------------------------------------
* Distinct from the legacy paypal_checkout_button.php / paypal_form.php
* (the older Express Checkout on-page form). This file renders a single
* "Pay with PayPal" button; clicking it opens /sm/checkout.php, where the
* buyer reviews the order details and pays with any PayPal-supported
* method (PayPal, Venmo, Pay Later, Apple Pay, Google Pay — eligibility
* handled by the checkout widget).
*
* Deployment path: the integration folder is auto-detected under the
* document root (checks /pp-integration, then /sm — the production deploy
* is /pp-integration, e.g. https://dev.hotlunches.net/pp-integration/).
* Define PPP_WEB_BASE before this include to override.
*
* This file is include()d from payment_form.php inside the PayPal block,
* AFTER $balance, $trx_fee and $owing have been computed. It runs in that
* file's variable scope, so $school_record, $school_num, $user_num,
* $currencySymbol, / and $selected_amount are available.
*
* -- Gateway condition -------------------------------------------------
* A school's PayPal gateway can be "Express Checkout" (the current
* gateway — what every live school is on today) or "REST API Checkout".
* This button renders ONLY for "Express Checkout" schools — schools on
* "REST API Checkout" already have their own button and keep it. An
* unset/empty gateway value counts as Express Checkout, since that is
* the current gateway.
*
* -- How the money reaches the school ----------------------------------
* The school completes onboarding in the integration's onboard.php,
* which stores its
* PayPal merchant_id (keyed by the EXACT school name) in the `schools`
* table and data/school_merchants.json. order-api.php sets
* payee.merchant_id = <that merchant id> on every order it creates, so
* the capture settles directly into the school's own PayPal account.
* After capture, order-api.php also books the payment into the
* user_account ledger (idempotent on capture_id).
*
* The button only renders when the school has finished onboarding and
* PayPal reports it ready to receive payments — never a dead button.
*/
// Web path where the ppp/ integration is served. Auto-detected by probing the
// known deploy folders under the document root; define PPP_WEB_BASE before
// this include to override (e.g. if the folder is renamed again).
if (!defined('PPP_WEB_BASE')) {
$pppDocroot = rtrim($_SERVER['DOCUMENT_ROOT'] ?? '', '/');
foreach (['/pp-integration', '/sm'] as $pppCandidate) {
if (is_file($pppDocroot . $pppCandidate . '/checkout.php')) {
define('PPP_WEB_BASE', $pppCandidate);
break;
}
}
}
// Every hidden-return leaves an HTML comment in the page source saying WHY,
// so a missing button can be diagnosed with View Source instead of guesswork.
$pppWhy = function (string $reason): void {
echo "\n<!-- ppp-paypal-button hidden: " . htmlspecialchars($reason, ENT_QUOTES) . " -->\n";
};
// -- Guard: only render if the checkout page is actually deployed --------------
if (!defined('PPP_WEB_BASE')) {
$pppWhy('integration not found — checked ' . ($pppDocroot ?? '(no document root)')
. '{/pp-integration,/sm}/checkout.php');
return;
}
$pppFsBase = rtrim($_SERVER['DOCUMENT_ROOT'] ?? '', '/') . PPP_WEB_BASE;
$pppCheckoutFs = $pppFsBase . '/checkout.php';
if (!is_file($pppCheckoutFs)) {
$pppWhy('checkout.php missing at ' . $pppCheckoutFs);
return;
}
// -- Gateway condition: Express Checkout ONLY ----------------------------------
// "REST API Checkout" schools have their own existing button — never render
// this one for them. Unset/empty counts as Express (the current gateway).
// The real column on `schools` is paypal_checkout (verified against the live
// schema); the others are kept as fallbacks for older copies of the table.
$pppGateway = trim((string)($school_record['paypal_checkout']
?? $school_record['paypal_gateway']
?? $school_record['paypal_checkout_type'] ?? ''));
if ($pppGateway === '') $pppGateway = 'Express Checkout';
if ($pppGateway !== 'Express Checkout') {
$pppWhy('school gateway is "' . $pppGateway . '" — only Express Checkout schools get this button');
return;
}
// -- School must have completed PayPal onboarding and be ready -----------------
// School name MUST match the key used during onboarding exactly, or the
// merchant lookup in order-api.php will fail.
$pppSchoolName = trim($school_record['school_name'] ?? '');
if ($pppSchoolName === '') {
$pppWhy('school_record has no school_name');
return;
}
$pppMerchantsFile = $pppFsBase . '/data/school_merchants.json';
if (!is_file($pppMerchantsFile)) {
$pppWhy('merchants file missing at ' . $pppMerchantsFile);
return;
}
$pppMerchants = json_decode((string)file_get_contents($pppMerchantsFile), true) ?: [];
$pppRecord = $pppMerchants[$pppSchoolName] ?? null;
if (!$pppRecord || empty($pppRecord['merchant_id'])) {
$pppWhy('school "' . $pppSchoolName . '" not found in merchants file (keys: '
. implode(' | ', array_keys($pppMerchants)) . ') — onboarding name must match exactly');
return;
}
if (isset($pppRecord['paypal_ready']) && !$pppRecord['paypal_ready']) {
$pppWhy('school "' . $pppSchoolName . '" connected but paypal_ready=false — '
. 'PayPal has not confirmed it can receive payments yet');
return;
}
// -- Amounts -------------------------------------------------------------------
// payment_form.php has just computed:
// $balance — account balance owing (or the selected deposit amount)
// $trx_fee — the school's PayPal transaction fee
// $owing — $balance + $trx_fee ? this is what the buyer must be charged
$pppBase = isset($balance) ? (float)$balance : 0.0;
$pppFee = isset($trx_fee) ? (float)$trx_fee : 0.0;
$pppTotal = isset($owing) ? (float)$owing : $pppBase + $pppFee;
if ($pppTotal <= 0) {
$pppWhy('nothing to charge (total ' . number_format($pppTotal, 2) . ')');
return;
}
$pppIsDeposit = isset($selected_amount) && $selected_amount > 0;
$pppFeeTitle = trim($school_record['paypal_fee_title'] ?? '');
if ($pppFeeTitle === '' && function_exists('getLanguageText')) $pppFeeTitle = (string)getLanguageText('TEXT-TRX-FEE');
if ($pppFeeTitle === '') $pppFeeTitle = 'Transaction fee';
// Currency CODE (not symbol) — ISO 4217. Prefer the school's own
// paypal_currency column; then the PPP_CURRENCY constant; then USD.
$pppCurrency = strtoupper(trim((string)($school_record['paypal_currency'] ?? '')));
if (!preg_match('/^[A-Z]{3}$/', $pppCurrency)) {
$pppCurrency = defined('PPP_CURRENCY') ? PPP_CURRENCY : 'USD';
}
// -- Store the checkout context for /sm/checkout.php ---------------------------
// The session (name 'hotlunch') is shared with the checkout page, so amounts
// travel server-side and can't be tampered with through the URL.
$_SESSION['ppp_checkout'] = [
'school_num' => (int)($school_num ?? 0),
'school_name' => $pppSchoolName,
'user_num' => (int)($user_num ?? ($_SESSION['UserInfo']['record_num'] ?? 0)),
'buyer_email' => (string)($_SESSION['UserInfo']['email'] ?? ''),
'base_amount' => number_format($pppBase, 2, '.', ''),
'fee_amount' => number_format($pppFee, 2, '.', ''),
'total_amount' => number_format($pppTotal, 2, '.', ''),
'base_label' => $pppIsDeposit ? 'Deposit amount' : 'Account balance',
'fee_label' => $pppFeeTitle,
'currency' => $pppCurrency,
'currency_symbol' => (string)($currencySymbol ?? '$'),
'gateway' => $pppGateway,
'return_url' => (string)('/'),
'created_at' => time(),
];
$pppCheckoutUrl = PPP_WEB_BASE . '/checkout.php';
?>
<div class="ppp-pay-wrap">
<a href="<?php echo htmlspecialchars($pppCheckoutUrl); ?>" class="ppp-pay-btn">
<span class="ppp-pay-txt">Pay with</span>
<svg class="ppp-pay-logo" viewBox="0 0 100 26" xmlns="http://www.w3.org/2000/svg"
role="img" aria-label="PayPal">
<?php /* PayPal wordmark: dark-blue "Pay" + light-blue "Pal", drawn as one lockup. */ ?>
<g fill="#003087">
<path d="M11.9 1.2H4.6c-.5 0-.93.36-1 .86L.63 21.3c-.06.37.23.7.6.7h3.5c.5 0 .92-.36 1-.86l.8-5.08c.08-.5.5-.87 1-.87h2.31c4.81 0 7.59-2.33 8.32-6.94.33-2.02.01-3.6-.92-4.71C16.21 1.53 14.38 1.2 11.9 1.2zm.84 6.84c-.4 2.62-2.4 2.62-4.34 2.62h-1.1l.77-4.9c.05-.3.3-.51.6-.51h.51c1.32 0 2.56 0 3.21.75.38.45.5 1.12.35 2.04z"/>
<path d="M35.37 7.96h-3.48c-.3 0-.55.21-.6.51l-.15.97-.24-.35c-.76-1.1-2.45-1.47-4.13-1.47-3.87 0-7.17 2.93-7.81 7.04-.34 2.05.14 4.01 1.3 5.37 1.15 1.38 2.8 1.95 4.78 1.95 3.12 0 4.85-2 4.85-2l-.16.96c-.06.37.23.7.6.7h3.13c.5 0 .92-.37 1-.87l1.88-11.93c.06-.37-.23-.7-.6-.7zm-4.85 6.81c-.34 2-1.93 3.35-3.96 3.35-1.01 0-1.83-.33-2.35-.95-.52-.61-.71-1.48-.55-2.45.31-1.98 1.93-3.37 3.92-3.37.99 0 1.8.33 2.34.95.53.62.74 1.5.6 2.47z"/>
<path d="M53.9 7.96h-3.5c-.34 0-.65.17-.84.45l-4.82 7.1-2.04-6.82c-.13-.43-.52-.72-.97-.72h-3.44c-.42 0-.71.4-.58.79l3.85 11.29-3.62 5.1c-.28.4 0 .95.49.95h3.5c.33 0 .64-.16.83-.44L54.4 8.9c.28-.4 0-.94-.5-.94z"/>
</g>
<g fill="#0070e0">
<path d="M65.44 1.2h-7.3c-.5 0-.93.36-1 .86L54.17 21.3c-.06.37.23.7.6.7h3.75c.35 0 .65-.25.7-.6l.84-5.34c.08-.5.5-.87 1-.87h2.31c4.81 0 7.59-2.33 8.32-6.94.33-2.02.01-3.6-.92-4.71-1.03-1.21-2.85-1.54-5.33-1.54zm.84 6.84c-.4 2.62-2.4 2.62-4.34 2.62h-1.1l.78-4.9c.04-.3.3-.51.59-.51h.51c1.32 0 2.56 0 3.21.75.39.45.5 1.12.35 2.04z"/>
<path d="M88.91 7.96h-3.48c-.3 0-.55.21-.6.51l-.15.97-.25-.35c-.75-1.1-2.44-1.47-4.12-1.47-3.87 0-7.17 2.93-7.81 7.04-.34 2.05.14 4.01 1.3 5.37 1.15 1.38 2.8 1.95 4.78 1.95 3.12 0 4.85-2 4.85-2l-.16.96c-.06.37.23.7.6.7h3.13c.5 0 .92-.37 1-.87l1.88-11.93c.06-.37-.23-.7-.6-.7zm-4.85 6.81c-.34 2-1.93 3.35-3.96 3.35-1.01 0-1.83-.33-2.35-.95-.52-.61-.71-1.48-.55-2.45.31-1.98 1.93-3.37 3.92-3.37.99 0 1.8.33 2.34.95.53.62.74 1.5.6 2.47z"/>
<path d="M93.02 1.73L90 21.3c-.06.37.23.7.6.7h3c.5 0 .92-.37 1-.87L97.57 1.7c.06-.37-.23-.7-.6-.7h-3.35c-.3 0-.55.22-.6.52z"/>
</g>
</svg>
</a>
</div>
<style>
.ppp-pay-wrap {
max-width: 340px;
margin: 0 auto;
text-align: center;
}
.ppp-pay-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: 48px;
padding: 12px 24px;
box-sizing: border-box;
background: #ffc439;
border: none;
border-radius: 24px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 16px;
font-weight: 600;
line-height: 1;
color: #2c2e2f;
text-decoration: none;
cursor: pointer;
transition: background .15s ease, box-shadow .15s ease;
}
.ppp-pay-btn:hover,
.ppp-pay-btn:focus {
background: #f2ba36;
box-shadow: 0 2px 8px rgba(0, 0, 0, .15);
text-decoration: none;
color: #2c2e2f;
}
.ppp-pay-btn:focus-visible {
outline: 2px solid #003087;
outline-offset: 2px;
}
.ppp-pay-btn:active {
background: #e5ad2c;
box-shadow: none;
}
.ppp-pay-txt {
/* Optical alignment: the wordmark's cap-height sits slightly high. */
transform: translateY(1px);
}
/* Height drives the lockup; width follows the 100:26 viewBox aspect ratio. */
.ppp-pay-logo {
height: 19px;
width: auto;
display: block;
flex: none;
}
</style>