First Commit

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

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
# Deny direct web access to files that hold secrets or internal data.
<FilesMatch "^(config|db_config|local-dev-bootstrap|seed_test_connection)\.php$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
<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>
+234
View File
@@ -0,0 +1,234 @@
# Square Onboarding (Phase 1 — seller connect via OAuth)
Schools (sellers) connect their own Square accounts to the platform. Each school
gets its own per-seller OAuth tokens; later phases use those tokens to charge
parents so funds land directly in the school's Square balance.
This is the Square equivalent of the PayPal onboarding in `../ppp`, built in the
same style (plain PHP, a `lib/` client, a `config.php` array, JSON + DB storage,
the host-app session/permission gate). The key difference: **Square issues a
per-seller access token + refresh token that we store (encrypted)**, whereas the
PayPal partner model needed no stored seller tokens.
## Files
| File | Role |
|------|------|
| `config.php` | Credentials, scopes, redirect URI, API version, encryption key |
| `db_config.php` | **No longer used.** All persistence goes through the host app's procedural `db_*` layer (`../include/database.php`) — the same one the `saving/` API uses — so the real credentials live in the host app, not here |
| `onboard.php` | The flow: `connect` action → Square authorize → OAuth callback → token exchange → storage + readiness check. Plus `check_status` / `disconnect` actions and a `?debug=1` endpoint |
| `lib/SquareClient.php` | cURL client: `authorizeUrl`, `obtainToken`, `refreshToken`, `revokeToken`, `api`, `getSellerStatus` |
| `lib/MerchantStore.php` | **Single source of truth** for reading/writing connections, encryption, token refresh (`ensureFreshToken`) and revocation (`markAuthorizationRevoked`). Shared by the web flow, the cron job, and the webhook |
| `lib/Secret.php` | AES-256-GCM encryption of tokens at rest |
| `lib/Storage.php` | Generic JSON store (carried over from PayPal; unused by the Square flow now everything is in the DB) |
| `helper.php` | `getMerchantId`, `getValidAccessToken` (auto-refresh), `getAllMerchants` — what Phase 4 payments will call |
| `refresh_tokens.php` | **Phase 2** CLI cron job — refreshes tokens before they expire |
| `webhook.php` | **Phase 2** Square webhook handler — verifies the HMAC signature and handles `oauth.authorization.revoked` |
| `square-checkout-widget.php` | **Phase 3** drop-in "Pay with Square" widget — loads the Web Payments SDK, renders the card form, tokenizes, POSTs the token |
| `order-api.php` | **Phase 3/4** public endpoint — validates the token, looks up the school's seller token, and charges via `CreatePayment` (with optional `app_fee_money`) |
| `demo-checkout.php` | **Phase 3** standalone test page that embeds the widget |
| `schema.sql` | `square_*` columns on `schools` + the `square_payments` and `square_pending_onboarding` tables (run once) |
| `data/` | Legacy JSON dir — no longer written or read by the Square flow (everything is now in the DB) |
## Setup (Phase 0 is assumed done)
1. **Fill `config.php`:**
- `client_id` / `client_secret` — from Developer Dashboard → your app → OAuth (use the Sandbox pair while `env => 'sandbox'`).
- `redirect_uri` — set this to the EXACT URL registered as the app's "Redirect URL" in the dashboard (e.g. `https://app.hotlunches.net/square/onboard.php`). If you leave it blank it falls back to the auto-detected `base_url + /onboard.php`, which must still match the dashboard.
- `token_encryption_key` — generate once:
```
php -r "echo bin2hex(random_bytes(32));"
```
Paste the 64-hex-char result (or set the `SQUARE_TOKEN_KEY` env var). **Without it, tokens are stored in plaintext** (a warning is logged).
2. **Register the Redirect URL** in the dashboard to match `redirect_uri` exactly.
3. **Run the migration:** `mysql hotlunch < schema.sql`
4. **Verify:** open `onboard.php?debug=1` — confirm `client_id set: yes`, `token_encryption_key: set`, `dataDir is_writable: yes`, and that the effective redirect URI matches the dashboard.
## Flow
```
School admin opens onboard.php (must be logged in: SuperUser / System Administrator)
→ enters school name, clicks "Connect Square Account"
→ POST action=connect: we store a pending record keyed by a random `state`,
and return Square's authorize URL
→ browser redirects to Square; the school logs into THEIR Square account and approves
→ Square redirects back to the registered Redirect URL with ?code=...&state=...
→ onboard.php validates `state`, exchanges `code` for tokens (obtainToken),
encrypts + stores them (DB + JSON), then checks readiness (getSellerStatus)
→ success page
```
## Security notes
- Tokens are encrypted at rest (AES-256-GCM). The `?debug=1` dump and the
`helper.php` JSON output both redact them.
- The callback is **not** behind the login gate (it is a top-level return from
squareup.com with no app session); it is authenticated by the random `state`
matched against the server-side pending store (CSRF protection).
- `data/*.json` is blocked from direct web access via `.htaccess`.
## Phase 2 — Token maintenance (done)
**Refresh job — `refresh_tokens.php` (CLI only):**
```
php refresh_tokens.php # refresh tokens within 5 days of expiry
php refresh_tokens.php --force # refresh ALL connected schools now
php refresh_tokens.php --dry-run # report only, change nothing
php refresh_tokens.php --days=7 # custom expiry window
php refresh_tokens.php --school="Maple School"
```
Square recommends renewing access tokens **every 7 days or less**, so the
recommended cron is a weekly `--force` sweep, with an optional daily threshold
sweep as a safety net:
```
30 3 * * 0 /usr/bin/php /path/to/square/refresh_tokens.php --force >> /var/log/square_refresh.log 2>&1
15 3 * * * /usr/bin/php /path/to/square/refresh_tokens.php --days=7 >> /var/log/square_refresh.log 2>&1
```
The web side also self-heals: `helper.php::getValidAccessToken()` refreshes
lazily whenever a token is used near expiry. Both paths go through
`MerchantStore::ensureFreshToken()`, so the behavior is identical.
**Disconnect / revoke:**
- **We initiate** (admin clicks disconnect): `onboard.php` POST `action=disconnect`
→ revokes the token at Square, then clears our record.
- **School initiates** (removes the app on Square's side): Square sends the
`oauth.authorization.revoked` webhook → `webhook.php` verifies the HMAC
signature and calls `markAuthorizationRevoked()`, which nulls the tokens and
flags the school so payments stop and staff know a reconnect is required.
**Webhook setup:** Dashboard → your App → Webhooks → add subscription pointing at
`webhook.php`, subscribe to `oauth.authorization.revoked`, then set
`webhook_signature_key` and `webhook_url` (the exact Notification URL) in
`config.php`. The signature is HMAC-SHA256 over `webhook_url + rawBody`.
## Phase 3 — The "Pay with Square" button (done)
A drop-in widget renders a card form on a school's page. The browser tokenizes
the card with Square's Web Payments SDK (card data never reaches our server) and
POSTs the resulting single-use `source_id` to `order-api.php`, which validates it.
**Embed it** (server-side include, same pattern as the PayPal widget):
```php
$sqCheckout = [
'school_name' => 'Maple School', // a Square-connected school
'amount' => '25.00',
'currency' => 'CAD',
'order_api_url' => '/square/order-api.php',
'success_url' => '/payment-success',
];
require_once '/path/to/square/square-checkout-widget.php';
```
The widget resolves server-side:
- **appId** = `web_payments_app_id` or `client_id` (the Application ID — public, safe in the browser).
- **locationId** = the school's active Square location (cached from onboarding, or fetched live and cached).
- If the school isn't connected / ready, it renders a muted notice and stops.
**Flow:** SDK loads → card form attaches → buyer clicks Pay → `card.tokenize()`
→ `source_id` → `POST {action, source_id, amount, currency, school_name,
idempotency_key}` → `order-api.php` validates (amount > 0, currency, school
connected + ready, location resolvable) and returns `{ success:true,
charged:false, stage:"validated" }`. The widget shows a confirmation.
**Phase boundary:** `order-api.php` deliberately stops at validation — it does
**not** move money. The exact spot where Phase 4 will call `CreatePayment` (with
the seller's token + `app_fee_money`) is marked `PHASE 4 INSERTION POINT`. When
Phase 4 returns `charged:true` + `payment_id`, the widget auto-redirects to
`success_url`.
**Test it:** open `demo-checkout.php` (auto-picks a connected school). In sandbox
use Square's test card `4111 1111 1111 1111`, any future expiry, any CVV, postal
`K1A 0B1`.
## Phase 4 — Charging (done)
`order-api.php` now charges. The handler `sq_handle_create_payment()`:
1. Validates the request + that the school is connected, ready, and has a location.
2. Looks up the school's **seller access token** via `MerchantStore::ensureFreshToken()` (auto-refreshing if near expiry). **This token is what routes the money into the school's account.**
3. Calls `SquareClient::createPayment($token, …)` → `POST /v2/payments` with `source_id`, `idempotency_key`, `amount_money` (integer cents, CAD), `location_id`, `autocomplete:true`, and optional `buyer_email_address` / `verification_token`.
4. On `COMPLETED`/`APPROVED`: records the payment to `data/square_payments.json` (ids/amounts only, never card data) and returns `{ success:true, charged:true, payment_id }` → the widget redirects to `success_url`.
5. On failure: logs the real Square error, returns a parent-friendly message (card-method errors pass through Square's detail; anything else stays generic so internals aren't leaked).
**Platform fee (your cut)** — `config.php → platform_fee` (default **off**):
```php
'platform_fee' => ['enabled' => true, 'percentage' => 2.0, 'fixed_cents' => 30],
```
`fee = round(amount * percentage/100) + fixed_cents`, capped at 90% of the amount
(Square's limit), added as `app_fee_money` so it settles to YOUR account.
⚠️ **To enable fees you must also:** (a) add the `PAYMENTS_WRITE_ADDITIONAL_RECIPIENTS`
scope in `config.php`, (b) have every seller **re-authorize** (new scope), and
(c) be approved by Square to take app fees. Without (a)+(b) Square rejects the
`app_fee_money` payment. The base charge (no fee) needs none of this.
**Kill switch:** `config.php → 'payments_enabled' => false` makes `order-api.php`
validate but not charge (returns `stage:"validated"`) — deploy the frontend
before going live.
**Idempotency:** every charge sends an `idempotency_key` (the widget generates a
UUID per attempt), so a network retry can't double-charge.
## Phase 5 — Embeddable button on the school's OWN website (done)
Phases 3–4 rendered the button via a server-side PHP `require`, which only works
on pages this app renders. Phase 5 lets a school drop the button onto **their own
external website** (WordPress/Wix/custom HTML) with one script tag.
**Per-school public key.** Each connected school gets a stable, unguessable
`merchant_key` (`sqpk_…`), generated lazily by `MerchantStore::merchantKey()` and
stored in `schools.square_merchant_key`. The embed references this key, never the
school name or any token.
**The snippet** (shown on the onboarding success page, copy-paste ready):
```html
<script src="https://app.hotlunches.net/square/embed.js"
data-square-key="sqpk_xxxxxxxxxxxxxxxx"
data-amount="25.00"
data-currency="CAD"
async></script>
```
**Flow:** `embed.js` reads its own `data-*` + base URL → injects an `<iframe>`
to `pay-frame.php?key=…&amount=…` → `pay-frame.php` resolves the school by key
and renders the existing Phase 3 widget inside the frame → the frame auto-reports
its height back to `embed.js` (via `postMessage`) so it sizes with no scrollbars.
Card tokenization still happens inside Square's SDK; card data never touches the
school's site or ours. The charge POST to `order-api.php` now carries
`merchant_key`, which `order-api.php` resolves to the school server-side.
| File | Role |
|------|------|
| `embed.js` | The one script tag a school pastes; injects the iframe, auto-resizes it |
| `pay-frame.php` | Iframe target; resolves the key and renders the widget |
| `demo-embed.php` | A fake "school website" that uses the snippet — end-to-end local test |
**CORS / origin security.** `order-api.php` reflects the requesting `Origin` so
cross-site `fetch` works, and answers the `OPTIONS` preflight. Each school can
register the domain(s) their button lives on (`allowed_origins` in the merchant
record, via `MerchantStore::addAllowedOrigin()`); set
`config.php → 'enforce_embed_origins' => true` to then **block** charges from any
other site (anti-impersonation). When enabled, `pay-frame.php` also emits a CSP
`frame-ancestors` restricting who may frame it. Leave it **off** until every
connected school has registered its origin, or legitimate buttons get blocked.
**Test it locally:** `php -S 127.0.0.1:8000`, open `demo-embed.php` (auto-picks a
connected school, prints the live snippet). Sandbox test card `4111 1111 1111 1111`.
## Not in this phase
- **Refunds** (`RefundPayment`) — straightforward to add to `order-api.php` next.
- Buyer SCA / 3DS verification (`payments.verifyBuyer` → `verification_token`) — the backend already forwards the token if the frontend sends it; the frontend call is the remaining piece.
- Webhook events `payment.updated` / `refund.updated` — Phase 5.
+2957
View File
File diff suppressed because it is too large. Load diff
View File
Whitespace-only changes.
+163
View File
@@ -0,0 +1,163 @@
<?php
/**
* Square Checkout — order details page
* Opened by square_button.php. Amounts come from $_SESSION['sq_checkout'],
* never the URL. After a successful charge, order-api.php books the payment
* into user_account and money settles in the school's Square account.
*/
if (session_status() !== PHP_SESSION_ACTIVE) {
@session_name('hotlunch');
@session_start();
}
$ctx = $_SESSION['sq_checkout'] ?? null;
$ctxValid = is_array($ctx)
&& (float)($ctx['total_amount'] ?? 0) > 0
&& (int)($ctx['school_num'] ?? 0) > 0
&& (time() - (int)($ctx['created_at'] ?? 0)) < 3600;
$schoolReady = false;
$schoolName = '';
$returnUrl = is_array($ctx) ? (string)($ctx['return_url'] ?? '/') : '/';
$readyIssues = [];
if ($ctxValid) {
require_once __DIR__ . '/lib/MerchantStore.php';
require_once __DIR__ . '/lib/SquareClient.php';
$cfg = require __DIR__ . '/config.php';
$store = new MerchantStore($cfg);
$client = new SquareClient($cfg);
$num = (int)$ctx['school_num'];
$meta = $store->getMetaByNum($num);
$schoolName = (string)($ctx['school_name'] ?: ($store->schoolNameByNum($num) ?? ''));
$readyIssues = is_array($meta['status_issues'] ?? null) ? $meta['status_issues'] : [];
// Same rule as the button: only an onboarded school can be paid.
$schoolReady = $store->isOnboarded($num, $client);
if (!$schoolReady) {
$meta = $store->getMetaByNum($num);
$readyIssues = is_array($meta['status_issues'] ?? null) ? $meta['status_issues'] : [];
}
$symbol = (string)($ctx['currency_symbol'] ?? '$');
$baseAmount = (string)$ctx['base_amount'];
$total = (string)$ctx['total_amount'];
$baseLabel = (string)($ctx['base_label'] ?? 'Account balance');
$buyerEmail = (string)($ctx['buyer_email'] ?? '');
$currency = strtoupper((string)($ctx['currency'] ?? ($meta['currency'] ?? 'CAD')));
if (!preg_match('/^[A-Z]{3}$/', $currency)) $currency = 'CAD';
$sqCheckout = [
'school_name' => $schoolName,
'school_num' => $num,
'user_num' => (int)($ctx['user_num'] ?? 0),
// First-party checkout is bound to the session; never fall back to the
// public embed key, or a paid checkout could be resubmitted as an embed.
'merchant_key' => '',
'amount' => $total,
'currency' => $currency,
'buyer_email' => $buyerEmail,
'order_api_url' => './order-api.php',
'success_url' => $returnUrl,
];
}
?>
<!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: #006aff; }
.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: #006aff; }
.topbar-secure { margin-left: auto; font-size: 12px; color: #718096; }
.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; } }
.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; }
.sum-row { display: flex; justify-content: space-between; gap: 12px; padding: 9px 0; font-size: 14px; color: #4a5568; border-bottom: 1px solid #f0f2f5; }
.sum-total { display: flex; justify-content: space-between; padding: 14px 0 2px; font-size: 16px; font-weight: 700; }
.sum-total .amt { font-size: 22px; }
.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; }
.back-link { display: inline-block; margin-top: 12px; font-size: 13.5px; }
.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: #006aff; color: #fff; text-decoration: none; font-size: 14px; font-weight: 600; padding: 11px 26px; border-radius: 8px; }
.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 Square</strong> again.</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 Square payments at the moment.
Please go back and choose another payment method, or contact the school office.</p>
<?php if (!empty($readyIssues)): ?>
<p style="text-align:left;font-size:13px;color:#4a5568;margin-top:8px;">
<?= htmlspecialchars(implode(' ', array_map('strval', $readyIssues))) ?>
</p>
<?php endif; ?>
<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 &amp; pay</h1>
<p>You're paying <?= htmlspecialchars($schoolName) ?> through Square. Funds go to the school's Square account.</p>
</div>
<div class="cols">
<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>
<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 Square account and is credited
to your family account as soon as Square confirms it.
</div>
<?php if ($buyerEmail !== ''): ?>
<p class="sum-account">Paying as <?= htmlspecialchars($buyerEmail) ?></p>
<?php endif; ?>
</div>
<div class="card">
<h2>Payment method</h2>
<p class="card-sub">Card details are tokenized by Square and never touch our servers.</p>
<?php require __DIR__ . '/square-checkout-widget.php'; ?>
<a class="back-link" href="<?= htmlspecialchars($returnUrl) ?>">&larr; Go back and choose a different payment method</a>
</div>
</div>
<p class="foot">Questions about a charge? Contact the school office at <?= htmlspecialchars($schoolName) ?>.</p>
</div>
<?php endif; ?>
</body>
</html>
+135
View File
@@ -0,0 +1,135 @@
<?php
// Basic configuration for the Square integration.
// Fill in your Sandbox (or Production) credentials from developer.squareup.com
// (Developer Dashboard → your Application → OAuth).
// Compute a runtime base URL if no explicit override is provided (supports multi-tenant subdomains).
$envBaseUrl = getenv('SQUARE_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., /square from /square/index.php
$scriptDir = isset($_SERVER['SCRIPT_NAME']) ? str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'])) : '';
$scriptDir = rtrim($scriptDir, '/');
$computedBaseUrl = $scheme . '://' . $host . ($scriptDir ?: '');
}
return [
// 'sandbox' or 'production'
'env' => 'production',
// Square Application credentials for your platform app (Dashboard → your app
// → Credentials; flip the Sandbox/Production toggle to match 'env' above).
// - client_id IS the Application ID. Production: sq0idp-... Sandbox: the
// Sandbox Application ID (sandbox-sq0idb-...). This SAME value is used as
// the Web Payments SDK appId on the frontend (see web_payments_app_id).
// - client_secret is the OAuth Application Secret. Production: sq0csp-...
// Sandbox: sandbox-sq0csb-...
'client_id' => 'sq0idp-gx2ebQJw0LVnELCNf7jaPA',
// OAuth Application Secret (sandbox-sq0csb-...), from the dashboard's OAuth page.
// Only needed for OAuth onboarding (onboard.php) — NOT for the local charge test.
// NOTE: the EAAA... value you had here was your Sandbox ACCESS TOKEN, which is
// not the OAuth secret — use that access token as the seed helper's --token.
'client_secret' => 'sq0csp-U1L2aDIsPDf7ix7hYfN4v2zrklxn4-6Q2tlDHg916YY',
// Web Payments SDK application id used by the browser to tokenize cards. This
// is the public Application ID and is normally identical to client_id, so we
// default to it. Only set this if your frontend must use a different app id.
'web_payments_app_id' => '',
// OAuth permissions we request from each seller (space-joined in the URL).
// PAYMENTS_WRITE/READ -> charge cards + read payments on the seller's behalf
// MERCHANT_PROFILE_READ -> read merchant + locations (needed to verify readiness)
// PAYMENTS_WRITE_ADDITIONAL_RECIPIENTS -> REQUIRED to take app_fee_money (your
// platform cut). Only needed if platform_fee.enabled is true below; if you
// turn fees on you must add it here AND have sellers re-authorize.
// Changing scopes requires sellers to re-authorize.
'scopes' => [
'MERCHANT_PROFILE_READ',
'PAYMENTS_WRITE',
'PAYMENTS_READ',
'PAYMENTS_WRITE_ADDITIONAL_RECIPIENTS', // uncomment to take app fees
],
// The OAuth redirect URL. Leave BLANK (recommended): Square then returns every
// school to the "Redirect URL" registered in the Square Developer Dashboard,
// which must be https://<any host serving this app>/square/onboard.php (e.g.
// https://app.hotlunches.net/square/onboard.php). If you do set it, it must
// match the dashboard value byte for byte (scheme, host, and path).
'redirect_uri' => 'https://dev.hotlunches.net/square/onboard.php',
// When true, the authorize URL sends session=false, forcing Square to show the
// seller LOGIN screen every time. Square REQUIRES this for production apps so a
// school admin who is already signed into a different Square account (e.g. a
// personal one) cannot connect the wrong account by accident.
// ⚠️ Set FALSE in Sandbox — the sandbox authorize page can't render a login and
// returns 400 when session=false is sent.
'oauth_force_login' => true,
// Base URL used by browser redirects (auto-detected; override via SQUARE_BASE_URL env if needed)
'base_url' => $computedBaseUrl,
// Master switch for moving money. When false, order-api.php validates the
// request but does NOT charge (returns stage:"validated") — useful to deploy
// the frontend before you're ready to take live payments.
'payments_enabled' => true,
// When true, order-api.php rejects a charge whose browser Origin is not in
// the school's registered embed allowlist (schools.square_allowed_origins).
// Leave false until every connected school has registered
// the domain(s) their "Pay" button lives on, or legitimate buttons may be
// blocked. A first-party call with no Origin header is always allowed.
'enforce_embed_origins' => false,
// Your platform's per-transaction cut, taken via Square's app_fee_money.
// fee = round(amount * percentage / 100) + fixed_cents
// DEFAULT OFF. To enable: set enabled=true, add the
// PAYMENTS_WRITE_ADDITIONAL_RECIPIENTS scope above, have sellers re-authorize,
// and ensure your app is approved by Square to take app fees. The fee is
// capped at 90% of the amount (Square's limit) and must use the same currency.
'platform_fee' => [
'enabled' => false,
'percentage' => 0.0, // e.g. 2.0 for 2%
'fixed_cents' => 0, // e.g. 30 for $0.30
],
// API version sent on every request (the date-based Square-Version header).
// Pin this so a future API change can't silently alter behavior; bump it
// deliberately after reviewing the changelog.
'square_version' => '2026-05-20',
// Storage path (must be writable by Apache/PHP)
'storage_path' => __DIR__ . DIRECTORY_SEPARATOR . 'data',
// Key used to encrypt seller access/refresh tokens at rest (AES-256-GCM).
// Generate one once with: php -r "echo bin2hex(random_bytes(32));"
// and paste the 64-hex-char string here (or set the SQUARE_TOKEN_KEY env var).
// If left empty, tokens are stored in PLAINTEXT and a warning is logged — do
// not ship to production without a key.
'token_encryption_key' => getenv('SQUARE_TOKEN_KEY') ?: '296dfee5fbf2f334b542c982eed54384d670fc9aef6c896ab8e18317caf029b2',
// CA bundle for cURL TLS verification. Some environments (notably Windows
// PHP builds) ship without a configured curl.cainfo, which makes every
// HTTPS call to Square 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,
// Webhook signature key from Square Dashboard → your App → Webhooks.
// Used to verify the x-square-hmacsha256-signature header on incoming events
// such as oauth.authorization.revoked.
'webhook_signature_key' => 'KszGraQZC-6Bkidoz1Fd_A',
// The EXACT Notification URL registered for the webhook subscription. Square's
// signature is computed over (this URL + raw body), so it must match byte for
// byte (e.g. https://app.hotlunches.net/square/webhook.php). If left blank,
// webhook.php reconstructs it from the request, which can break behind a proxy.
'webhook_url' => 'https://app.hotlunches.net/square/webhook.php',
];
+12
View File
@@ -0,0 +1,12 @@
<?php
// Database configuration
// Update these with your MySQL credentials
return [
'host' => 'localhost',
'database' => 'hotlunch', // UPDATE THIS
'username' => 'ezssoftwaredb', // UPDATE THIS
'password' => 'EZsoftware7785422700@@', // UPDATE THIS
'charset' => 'utf8mb4'
];
+78
View File
@@ -0,0 +1,78 @@
<?php
/**
* Demo: standalone "Pay with Square" checkout page (Phase 3 test harness).
*
* Auto-resolves to a Square-connected school so the card form actually renders.
* Pass ?school=Name to force one, and ?amount=25.00 to set the amount.
*
* In your real app, $school / $amount / $buyerEmail come from session + DB and
* you simply `require square-checkout-widget.php` with a $sqCheckout array.
*/
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
$config = require __DIR__ . '/config.php';
$store = new MerchantStore($config);
// Resolve a connected school: explicit ?school=, else the first connected one.
$requested = isset($_GET['school']) ? trim((string)$_GET['school']) : '';
$school = null;
if ($requested !== '' && ($store->getMeta($requested)['merchant_id'] ?? null)) {
$school = $requested;
} else {
$all = $store->merchantSchools();
if (!empty($all)) $school = $all[0];
}
$amount = isset($_GET['amount']) && is_numeric($_GET['amount']) ? number_format((float)$_GET['amount'], 2, '.', '') : '25.00';
$schoolLabel = $school ?? '(no connected school found)';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pay — <?= 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:#eef2f7; min-height:100vh; display:flex; align-items:center;
justify-content:center; padding:24px; }
.wrap { width:100%; max-width:520px; }
.summary { background:#fff; border:1.5px solid #e2e8f0; border-radius:12px;
padding:20px; margin-bottom:18px; }
.summary h1 { font-size:18px; color:#1a202c; margin-bottom:6px; }
.summary p { color:#718096; font-size:13px; }
.summary .row { display:flex; justify-content:space-between; margin-top:14px;
padding-top:14px; border-top:1px solid #edf2f7; font-weight:600; color:#1a202c; }
</style>
</head>
<body>
<div class="wrap">
<div class="summary">
<h1>School Lunch Payment</h1>
<p>Paying <strong><?= htmlspecialchars($schoolLabel) ?></strong></p>
<div class="row"><span>Total</span><span>CAD <?= htmlspecialchars($amount) ?></span></div>
</div>
<?php
if ($school === null) {
echo '<div style="background:#fff5f5;border:1px solid #feb2b2;color:#c53030;'
. 'border-radius:10px;padding:16px;font-size:14px;">'
. 'No Square-connected school found. Complete onboarding first '
. '(onboard.php), then reload this page.</div>';
} else {
$sqCheckout = [
'school_name' => $school,
'amount' => $amount,
'currency' => 'CAD',
'order_api_url' => './order-api.php',
'success_url' => './demo-checkout.php?school=' . urlencode($school),
];
require __DIR__ . '/square-checkout-widget.php';
}
?>
</div>
</body>
</html>
+74
View File
@@ -0,0 +1,74 @@
<?php
/**
* Demo: simulates a SCHOOL'S OWN external website embedding the Pay button.
*
* In reality the school pastes the <script> snippet shown on the onboarding
* success page into their site. Here we resolve a connected school, ensure it
* has a public embed key, and render that exact snippet so you can test the
* full cross-site flow (embed.js → pay-frame.php → order-api.php) locally.
*
* ?school=Name to force one, ?amount=25.00 to set the amount.
*/
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
$config = require __DIR__ . '/config.php';
$store = new MerchantStore($config);
$requested = isset($_GET['school']) ? trim((string)$_GET['school']) : '';
$school = null;
if ($requested !== '' && ($store->getMeta($requested)['merchant_id'] ?? null)) {
$school = $requested;
} else {
foreach ($store->merchantSchools() as $s) { $school = $s; break; }
}
$amount = isset($_GET['amount']) && is_numeric($_GET['amount']) ? number_format((float)$_GET['amount'], 2, '.', '') : '25.00';
$key = $school !== null ? $store->merchantKey($school) : '';
$base = rtrim($config['base_url'], '/') . '/';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Greenfield Academy — Lunch Payments (demo external site)</title>
<style>
body { font-family:Georgia, 'Times New Roman', serif; background:#fbfaf7; color:#2d2a26;
margin:0; line-height:1.6; }
.banner { background:#234d20; color:#fff; padding:22px 0; text-align:center; }
.banner h1 { margin:0; font-size:24px; letter-spacing:.5px; }
.wrap { max-width:680px; margin:0 auto; padding:32px 20px 60px; }
.card { background:#fff; border:1px solid #e7e3da; border-radius:8px; padding:26px; margin-top:24px; }
.note { font-size:13px; color:#8a8378; margin-top:8px; }
code { background:#f0ede6; padding:2px 6px; border-radius:4px; font-size:13px; }
</style>
</head>
<body>
<div class="banner"><h1>🌳 Greenfield Academy</h1></div>
<div class="wrap">
<p style="font-style:italic;color:#6b655c;">(This page imitates a school's own website. The only Square-specific
thing on it is the one script tag below.)</p>
<div class="card">
<h2>Pay for School Lunch</h2>
<p>Amount due: <strong>CAD <?= htmlspecialchars($amount) ?></strong> — <?= htmlspecialchars($school ?? 'no connected school') ?></p>
<?php if ($school === null): ?>
<p style="color:#b03030;">No Square-connected school found. Complete <code>onboard.php</code> first.</p>
<?php else: ?>
<!-- ▼▼▼ This is the snippet a school pastes onto their site ▼▼▼ -->
<script src="<?= htmlspecialchars($base) ?>embed.js"
data-square-key="<?= htmlspecialchars($key) ?>"
data-amount="<?= htmlspecialchars($amount) ?>"
data-currency="CAD"
async></script>
<!-- ▲▲▲ nothing else required ▲▲▲ -->
<?php endif; ?>
</div>
<p class="note">Embed key in use: <code><?= htmlspecialchars($key ?: '(none)') ?></code></p>
</div>
</body>
</html>
+88
View File
@@ -0,0 +1,88 @@
/*
* Square "Pay" button embed loader.
* ─────────────────────────────────
* A school drops ONE script tag onto their own website:
*
* <script src="https://app.hotlunches.net/square/embed.js"
* data-square-key="sqpk_xxxxxxxxxxxxxxxx"
* data-amount="25.00"
* data-currency="CAD"
* data-email="" (optional, prefill receipt email)
* data-success="" (optional, redirect URL after payment)
* async></script>
*
* It injects a responsive <iframe> (pay-frame.php) right where the script tag
* sits, so the Square card form renders inline on their page. The card data is
* tokenized by Square inside the iframe and never touches their site or ours.
*
* To place the button somewhere specific, give a target:
* data-target="#pay-here" → renders into that element instead of inline.
*/
(function () {
'use strict';
// The currently executing script tag (so we can read its data-* and the base
// URL it was served from). document.currentScript is reliable for sync loads;
// for async we fall back to the last script that points at embed.js.
var me = document.currentScript || (function () {
var all = document.getElementsByTagName('script');
for (var i = all.length - 1; i >= 0; i--) {
if ((all[i].src || '').indexOf('embed.js') !== -1) return all[i];
}
return null;
})();
if (!me) return;
var key = me.getAttribute('data-square-key') || '';
if (!key) {
console.error('[square-embed] missing data-square-key on the embed script tag.');
return;
}
// Derive the integration base URL (…/square/) from this script's own src so
// the school never has to configure a second URL.
var base = me.src.replace(/embed\.js(\?.*)?$/, '');
var amount = me.getAttribute('data-amount') || '';
var currency = me.getAttribute('data-currency') || '';
var email = me.getAttribute('data-email') || '';
var success = me.getAttribute('data-success') || '';
var qs = 'key=' + encodeURIComponent(key) + '&amount=' + encodeURIComponent(amount);
if (currency) qs += '&currency=' + encodeURIComponent(currency);
if (email) qs += '&email=' + encodeURIComponent(email);
if (success) qs += '&success=' + encodeURIComponent(success);
var iframe = document.createElement('iframe');
iframe.src = base + 'pay-frame.php?' + qs;
iframe.setAttribute('title', 'Pay with Square');
iframe.setAttribute('scrolling', 'no');
iframe.setAttribute('allow', 'payment');
iframe.style.cssText =
'width:100%;max-width:520px;border:0;overflow:hidden;display:block;height:280px;' +
'transition:height .15s ease;';
// A stable name lets us match height messages to THIS iframe if a page has
// several embedded buttons.
var frameName = 'sqpayframe_' + Math.random().toString(36).slice(2);
iframe.name = frameName;
// Resize the iframe to its content height as reported by pay-frame.php.
window.addEventListener('message', function (ev) {
var d = ev.data;
if (!d || d.sqPayFrame !== true || d.type !== 'height') return;
// Only react to messages from our own frame's content window.
if (ev.source !== iframe.contentWindow) return;
var h = parseInt(d.height, 10);
if (h > 0) iframe.style.height = (h + 4) + 'px';
});
// Insert: into data-target if given, else right where the script tag is.
var targetSel = me.getAttribute('data-target');
if (targetSel) {
var target = document.querySelector(targetSel);
if (target) { target.appendChild(iframe); return; }
console.warn('[square-embed] data-target "' + targetSel + '" not found; inlining instead.');
}
(me.parentNode || document.body).insertBefore(iframe, me);
})();
+91
View File
@@ -0,0 +1,91 @@
<?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();
}
/**
* Helpers to look up a school's Square connection (merchant id + tokens).
*
* The PayPal helper only returned a merchant_id because PayPal acts via the
* partner credentials. Square requires the SELLER's access token to charge on
* their behalf, so getValidAccessToken() returns a decrypted, auto-refreshed
* bearer token. All persistence/encryption/refresh lives in MerchantStore so
* the web flow, the CLI cron, and the webhook share one implementation.
*/
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
function squareConfig(): array {
return require __DIR__ . '/config.php';
}
function squareStore(): MerchantStore {
static $store = null;
if ($store === null) $store = new MerchantStore(squareConfig());
return $store;
}
function getMerchantId(string $schoolName): ?string
{
$conn = squareStore()->get($schoolName);
return $conn['merchant_id'] ?? null;
}
/**
* Return a usable (decrypted, non-expired) access token for a school, refreshing
* it lazily via the refresh token if it is near expiry. Returns null if the
* school is not connected. This is what the Phase 4 payments code calls.
*/
function getValidAccessToken(string $schoolName): ?string
{
$client = new SquareClient(squareConfig());
$result = squareStore()->ensureFreshToken($schoolName, $client);
return $result['access_token'] ?? null;
}
function getAllMerchants(): array
{
$store = squareStore();
$merchants = [];
foreach ($store->connectedSchools() as $school) {
$rec = $store->get($school);
if (!$rec) continue;
$merchants[$school] = [
'merchant_id' => $rec['merchant_id'],
'expires_at' => $rec['expires_at'],
];
}
return $merchants;
}
// Return JSON when accessed directly (never expose tokens here).
if (basename(__FILE__) === basename($_SERVER['SCRIPT_FILENAME'])) {
header('Content-Type: application/json');
$school = $_GET['school'] ?? '';
if ($school) {
echo json_encode([
'school' => $school,
'merchant_id' => getMerchantId($school),
'connected' => getMerchantId($school) !== null,
]);
} else {
echo json_encode(['merchants' => getAllMerchants()]);
}
}
+506
View File
@@ -0,0 +1,506 @@
<?php
if (!isset($PAYMENT_FORM_INCLUDED)) $PAYMENT_FORM_INCLUDED = true;
else if ($PAYMENT_FORM_INCLUDED) return;
if (!isset($user_num)) $user_num = intval($_SESSION['UserInfo']['record_num']);
unset($selected_amount);
if (isset($naction) && $naction == 'deposit' && array_key_exists('selected_amount',$_POST)) $selected_amount = $_POST['selected_amount'];
?>
<table width='500' cellpadding='5' cellspacing='2' border='0'>
<tr>
<td align='center'>
<?php
// Get processing timestamp
$now = time();
// Get current balance
$balance = db_getVal("select sum(transaction_amount) as balance from user_account where school_num = $school_num and user_num = $user_num AND (deleted != 'Y' OR deleted IS NULL)");
if (!$balance) $balance = 0.00;
$onlineMessage = db_getVal("SELECT text_data FROM text_data WHERE school_num = $school_num AND text_id = 'Online Payment Message'");
if (strlen($onlineMessage)) $onlineMessage .= "<br><br>\n";
switch ($school_num) {
case 251:
case 19438:
$onlineMessage .= "-- Please note that Interac Debit cards are not accepted --";
break;
default:
$onlineMessage .= "-- Please note that MasterCard Debit and Interac Debit cards are not accepted --";
break;
}
$onlineMessageDisplayed = false;
//
// School account processing
//
if ($balance > 0 && !isset($selected_amount)) {
if ($school_record['school_account_enabled'] == 'Y') {
?>
<table width='400' class='content-table'>
<tr>
<td align='center' class='warning'>
<?php if (strlen($school_record['school_account_title'])) echo("<p><b>" . $school_record['school_account_title'] . "</b></p>\n"); ?>
<?php if (strlen($school_record['school_account_text'])) echo("<p>" . $school_record['school_account_text'] . "</p>\n"); ?>
<?php echo("<p><b>" . getLanguageText('TEXT-BALANCE') . ": $currencySymbol" . number_format($balance,2) . "</b></p>\n"); ?>
<p><a href='/?action=charge-school-account' balance='<?php echo($balance) ?>' id='charge-school-account' class='btn btn--primary>'><?php echo($school_record['school_account_button_text']) ?></a></p>
</td>
</tr>
</table>
<br>
<br>
<script>
$(document).ready(function(e) {
<?php include "include/ajax.Setup.php" ?>
$("#charge-school-account").click(function(e) {
e.preventDefault();
var balance = $(this).attr('balance');
var msg = "<?php echo($school_record['school_account_message']) ?>";
msg = msg + "\n\n<?php displayLanguageText('TEXT-AMOUNT'); ?>: <?php echo($currencySymbol) ?>" + balance;
var ans = confirm(msg);
if (ans) {
var data = "&user_num=<?php echo($_SESSION['UserInfo']['record_num']) ?>&balance="+balance;
$.ajax({
url: "ajax.ChargeAccount.php",
data: data
}).done(function(data) {
window.location = '/';
});
}
});
});
</script>
<?php
}
}
//
// Bambora Form
//
if ($school_record['bambora_enabled'] == 'Y' && strlen($school_record['bambora_merchant_id']) > 0) {
// Get current balance
$balance = db_getVal("select sum(transaction_amount) as balance from user_account where school_num = $school_num and user_num = $user_num AND (deleted != 'Y' OR deleted IS NULL)");
if (!$balance) $balance = 0.00;
if (isset($selected_amount) && $selected_amount > 0) $balance = $selected_amount;
if ($balance >= $school_record['bambora_minimum_amount']) {
$trx_time = date("Y-m-d H:i:s",$now);
// Calculate the transaction fee
$trx_time = date("Y-m-d H:i:s",$now);
$trx_fee = intval($balance * $school_record['bambora_percentage']) / 100 + $school_record['bambora_fee'];
$owing = $balance + $trx_fee;
if (!$onlineMessageDisplayed && strlen($onlineMessage)) {
displayStatusMessage($onlineMessage,"warningframedspan");
$onlineMessageDisplayed = true;
}
// Calculate any payment discounts
// Check for an entry to the online payments table
$query = "select * from user_online_payments " .
"where school_num = $school_num " .
"and user_num = $user_num " .
"and status = 'Pending'";
$results = db_query($query);
if (db_num_rows($results) > 0) {
$row = db_fetch_array($results);
$trxnum = $row['record_num'];
$query = "update user_online_payments " .
"set user_account_balance = $balance, " .
"transaction_fee = $trx_fee, " .
"balance_owing = $owing, " .
"transaction_datetime = '$trx_time', " .
"transaction_time = $now " .
"where school_num = $school_num " .
"and user_num = $user_num " .
"and status = 'Pending'";
db_query($query);
}
else {
$query = "insert into user_online_payments (school_num,user_num,transaction_datetime,transaction_time,user_account_balance,transaction_fee,balance_owing,status) " .
"values ($school_num,$user_num,'$trx_time',$now,$balance,$trx_fee,$owing,'Pending')";
db_query($query);
$trxnum = db_insert_id();
}
// If there is a transaction fee
if ($trx_fee != 0 || strlen(trim($school_record['bambora_fee_message'])) > 0) {
$trx_title = getLanguageText('TEXT-TRX-FEE');
if (strlen($school_record['bambora_fee_title'])) $trx_title = $school_record['bambora_fee_title'];
?>
<table width='100%' class='content-table'>
<tr>
<td align='center' class='warning'>
<br>
<?php if (strlen(trim($school_record['bambora_fee_message']))) { ?>
<b><?php echo(nl2br(trim($school_record['bambora_fee_message']))); ?></b><br>
<br>
<?php } ?>
<?php if ($trx_fee != 0) { ?>
<?php if (isset($selected_amount) && $selected_amount > 0) { ?>
<b><nobr><?php echo(getLanguageText('TEXT-DEPOSIT-AMOUNT') . ": " . $currencySymbol . number_format($owing,2)) ?></nobr></b><br>
<?php } else { ?>
<b><nobr><?php echo(getLanguageText('TEXT-ACCOUNT-BALANCE') . ": " . $currencySymbol . number_format($owing,2)) ?></nobr></b><br>
<?php } ?>
<b><nobr><?php echo($trx_title . ": " . $currencySymbol . number_format($trx_fee,2)) ?></nobr></b>
<br>
<br>
<?php } ?>
</td>
</tr>
</table>
<br>
<?php
}
if ($school_num == 0) include "bambora_form2.php";
else {
if ($school_record['display_bambora_form'] != 'Link') include "bambora_form.php";
else {
echo("<a href='?action=bambora-payment' class='btn btn--primary>'>".getLanguageText('TEXT-CREDIT-CARD-PAYMENT')."</a>");
}
}
echo("<br>\n");
echo("<br>\n");
echo("<br>\n");
echo("<br>\n");
}
}
//
// PayPal Account Payments
//
if ($school_record['paypal_enabled'] == 'Y' && strlen($school_record['paypal_code']) > 0) {
// Get current balance
$balance = db_getVal("select sum(transaction_amount) as balance from user_account where school_num = $school_num and user_num = $user_num AND (deleted != 'Y' OR deleted IS NULL)");
if (!$balance) $balance = 0.00;
if (isset($selected_amount) && $selected_amount > 0) $balance = $selected_amount;
if ($balance >= $school_record['paypal_minimum_amount']) {
$trx_time = date("Y-m-d H:i:s",$now);
// if (!$onlineMessageDisplayed && strlen($onlineMessage)) {
// displayStatusMessage($onlineMessage,"warningframedspan");
// $onlineMessageDisplayed = true;
// }
// Calculate the transaction fee
$trx_time = date("Y-m-d H:i:s",$now);
$trx_fee = intval($balance * $school_record['paypal_percentage']) / 100 + $school_record['paypal_fee'];
$owing = $balance + $trx_fee;
// Calculate any payment discounts
// Check for an entry to the online payments table
$query = "select * from user_online_payments " .
"where school_num = $school_num " .
"and user_num = $user_num " .
"and status = 'Pending'";
$results = db_query($query);
if (db_num_rows($results) > 0) {
$row = db_fetch_array($results);
$trxnum = $row['record_num'];
$query = "update user_online_payments " .
"set user_account_balance = $balance, " .
"transaction_fee = $trx_fee, " .
"balance_owing = $owing, " .
"transaction_datetime = '$trx_time', " .
"transaction_time = $now " .
"where school_num = $school_num " .
"and user_num = $user_num " .
"and status = 'Pending'";
db_query($query);
}
else {
$query = "insert into user_online_payments (school_num,user_num,transaction_datetime,transaction_time,user_account_balance,transaction_fee,balance_owing,status) " .
"values ($school_num,$user_num,'$trx_time',$now,$balance,$trx_fee,$owing,'Pending')";
db_query($query);
$trxnum = db_insert_id();
}
$message = getLanguageText('TEXT-PAYPAL-DELAY-MSG');
// displayStatusMessage($message,"warningframedspan");
// If there is a transaction fee
if ($trx_fee != 0) {
$trx_title = getLanguageText('TEXT-TRX-FEE');
if (strlen($school_record['paypal_fee_title'])) $trx_title = $school_record['paypal_fee_title'];
?>
<table width='400' class='content-table'>
<tr>
<td align='center' class='warning'>
<br>
<?php if (strlen(trim($school_record['paypal_fee_message']))) { ?>
<b><?php echo(nl2br(trim($school_record['paypal_fee_message']))); ?></b><br>
<br>
<?php } ?>
<?php if (isset($selected_amount) && $selected_amount > 0) { ?>
<b><nobr><?php echo(getLanguageText('TEXT-DEPOSIT-AMOUNT') . ": " . $currencySymbol . number_format($balance,2)) ?></nobr></b><br>
<?php } else { ?>
<b><nobr><?php echo(getLanguageText('TEXT-ACCOUNT-BALANCE') . ": " . $currencySymbol . number_format($balance,2)) ?></nobr></b><br>
<?php } ?>
<b><nobr><?php echo($trx_title . ": " . $currencySymbol . number_format($trx_fee,2)) ?></nobr></b>
<br>
<br>
</td>
</tr>
</table>
<br>
<?php
}
// Single-account PayPal / credit card button, using PayPal Payments
// Standard: posts straight to PayPal's hosted checkout with the
// school's paypal_code as the receiving account (no client id/secret).
// Always shown alongside the button above, regardless of onboarding status.
// Buffered + wrapped so a failure here is visible in View Source
// instead of silently vanishing or breaking the rest of the page.
$oldPpBtnFile = __DIR__ . "/paypal_standard_button.php";
echo "<!-- old-paypal-button: file=" . htmlspecialchars($oldPpBtnFile, ENT_QUOTES) . " exists=" . (is_file($oldPpBtnFile) ? 'yes' : 'NO') . " -->\n";
try {
ob_start();
include $oldPpBtnFile;
$oldPpBtnOut = ob_get_clean();
echo "<!-- old-paypal-button: rendered " . strlen($oldPpBtnOut) . " bytes -->\n";
echo $oldPpBtnOut;
} catch (\Throwable $e) {
if (ob_get_level() > 0) ob_end_clean();
echo "<!-- old-paypal-button: ERROR " . htmlspecialchars($e->getMessage(), ENT_QUOTES) . " in " . htmlspecialchars($e->getFile(), ENT_QUOTES) . ":" . $e->getLine() . " -->\n";
}
echo("<br>\n");
}
} // End of PayPal Code
//
// E-xact Account Payments
//
if ($school_record['exact_enabled'] == 'Y' && strlen($school_record['exact_code']) > 0) {
// Calculate the transaction fee
$trx_time = date("Y-m-d H:i:s",$now);
$trx_fee = intval($balance * $school_record['exact_percentage']) / 100 + $school_record['exact_fee'];
$owing = $balance + $trx_fee;
// Check for an entry to the online payments table
$query = "select * from user_online_payments " .
"where school_num = $school_num " .
"and user_num = $user_num " .
"and status = 'Pending'";
$results = db_query($query);
if (db_num_rows($results) > 0) {
$row = db_fetch_array($results);
$trxnum = $row['record_num'];
$query = "update user_online_payments " .
"set user_account_balance = $balance, " .
"transaction_fee = $trx_fee, " .
"balance_owing = $owing, " .
"transaction_datetime = '$trx_time', " .
"transaction_time = $now " .
"where school_num = $school_num " .
"and user_num = $user_num " .
"and status = 'Pending'";
db_query($query);
}
else {
$query = "insert into user_online_payments (school_num,user_num,transaction_datetime,transaction_time,user_account_balance,transaction_fee,balance_owing,status) " .
"values ($school_num,$user_num,'$trx_time',$now,$balance,$trx_fee,$owing,'Pending')";
db_query($query);
$trxnum = db_insert_id();
}
// If there is a transaction fee
if ($trx_fee > 0) {
?>
<table width='300' class='content-table'>
<tr>
<td align='center' class='warning'>
<br>
<?php if (strlen(trim($school_record['exact_fee_message']))) { ?>
<b><?php echo(nl2br(trim($school_record['exact_fee_message']))); ?></b><br>
<br>
<?php } ?>
<b><nobr><?php echo(getLanguageText('TEXT-ACCOUNT-BALANCE') . ": " . $currencySymbol . number_format($balance,2)) ?></nobr></b><br>
<b><nobr><?php echo(getLanguageText('TEXT-TRX-FEE') . ": " . $currencySymbol . number_format($trx_fee,2)) ?></nobr></b>
<br>
<br>
</td>
</tr>
</table>
<br>
<?php
}
include "exact_form.php";
echo("<br>\n");
} // End of Exact code
//
// Merchant Account Payments
//
if ($school_record['merchant_account_enabled'] != 'None' && strlen($school_record['merchant_account_key_id']) > 0) {
// Get current balance
$balance = db_getVal("select sum(transaction_amount) as balance from user_account where school_num = $school_num and user_num = $user_num AND (deleted != 'Y' OR deleted IS NULL)");
if (!$balance) $balance = 0.00;
$owing = $balance;
// Calculate any payment discounts
$today = date("Y-m-d",$now);
$payment_discount = 0;
$query = "SELECT * FROM payment_discounts WHERE school_num = $school_num AND start_date <= '$today' AND end_date >= '$today'";
$results = db_query($query);
if (db_num_rows($results)) {
$row = db_fetch_assoc($results);
$discount_type = $row['discount_type'];
$discount = $row['discount'];
$discount_message = $row['discount_message'];
switch ($discount_type) {
case 'Amount':
$payment_discount = $discount;
break;
case 'Percentage':
$payment_discount = round($owing * $discount) / 100;
break;
}
$owing = $owing - $payment_discount;
}
// Calculate the transaction fee
$trx_time = date("Y-m-d H:i:s",$now);
$trx_fee = intval($owing * $school_record['merchant_account_percentage']) / 100 + $school_record['merchant_account_fee'];
$owing = $owing + $trx_fee;
// Check for an entry to the online payments table
$query = "select * from user_online_payments " .
"where school_num = $school_num " .
"and user_num = $user_num " .
"and status = 'Pending'";
$results = db_query($query);
if (db_num_rows($results) > 0) {
$row = db_fetch_array($results);
$trxnum = $row['record_num'];
$query = "update user_online_payments " .
"set user_account_balance = $balance, " .
"payment_discount = $payment_discount, " .
"transaction_fee = $trx_fee, " .
"balance_owing = $owing, " .
"transaction_datetime = '$trx_time', " .
"transaction_time = $now " .
"where school_num = $school_num " .
"and user_num = $user_num " .
"and status = 'Pending'";
db_query($query);
}
else {
$query = "insert into user_online_payments (school_num,user_num,transaction_datetime,transaction_time,user_account_balance,payment_discount,transaction_fee,balance_owing,status) " .
"values ($school_num,$user_num,'$trx_time',$now,$balance,$payment_discount,$trx_fee,$owing,'Pending')";
db_query($query);
$trxnum = db_insert_id();
}
// If there is a payment discount message
if ($payment_discount > 0) {
?>
<table width='300' class='content-table'>
<tr>
<td align='center' class='success'>
<br>
<?php if (strlen($discount_message)) { ?>
<b><?php echo(nl2br(trim($discount_message))); ?></b><br>
<br>
<?php } ?>
<table cellpadding='2' cellspacing='0' border='0'>
<tr>
<td align='right'><?php displayLanguageText('TEXT-DISCOUNT'); ?>:&nbsp;&nbsp;</td>
<td align='right'><?php echo('-' . $currencySymbol . number_format($payment_discount,2)) ?></td>
</tr>
<tr>
<td align='right'><?php displayLanguageText('TEXT-BALANCE'); ?>:&nbsp;&nbsp;</td>
<td align='right'><?php echo($currencySymbol . number_format($owing,2)) ?></td>
</tr>
</table>
<br>
</td>
</tr>
</table>
<br>
<?php
}
// If there is a transaction fee
if ($trx_fee > 0) {
?>
<table width='300' class='content-table'>
<tr>
<td align='center' class='warning'>
<br>
<?php if (strlen(trim($school_record['merchant_account_fee_message']))) { ?>
<b><?php echo(nl2br(trim($school_record['merchant_account_fee_message']))); ?></b><br>
<br>
<?php } ?>
<b><nobr><?php echo(getLanguageText('TEXT-ACCOUNT-BALANCE') . ": " . $currencySymbol . number_format($balance,2)) ?></nobr></b><br>
<b><nobr><?php echo(getLanguageText('TEXT-TRX-FEE') . ": " . $currencySymbol . number_format($trx_fee,2)) ?></nobr></b>
<br>
<br>
</td>
</tr>
</table>
<br>
<?php
}
include "merchant_account_form.php";
echo("<br>\n");
} // End of Merchant Account code
if ($balance > 0 && !isset($selected_amount)) {
if ($school_record['display_remittance_button'] == 'Y') {
$btnText = trim($school_record['remittance_button_text']);
if (strlen($btnText) == 0) $btnText = getLanguageText('TEXT-PRINT-REMITTANCE');
?>
<br>
<form method='post' action='/'>
<input type='submit' value='<?php echo($btnText) ?>' class='btn btn--primary'>
<input type='hidden' name='action' value='print-remittance-form'>
</form>
<br>
<?php
if ($school_record['display_remittance_msg_with_button'] == 'Y') {
$text = db_getVal("SELECT text_data FROM text_data WHERE school_num = $school_num AND text_id = 'Remittance Form'");
if (strlen($text) > 0) {
echo("<p>" . nl2br($text) . "</p>\n");
}
}
}
}
?>
</td>
</tr>
</table>
+542
View File
@@ -0,0 +1,542 @@
<?php
if (!isset($PAYMENT_FORM_INCLUDED)) $PAYMENT_FORM_INCLUDED = true;
else if ($PAYMENT_FORM_INCLUDED) return;
if (!isset($user_num)) $user_num = intval($_SESSION['UserInfo']['record_num']);
unset($selected_amount);
// Cast to a 2-decimal number: this value is placed directly into SQL below.
if (isset($naction) && $naction == 'deposit' && array_key_exists('selected_amount',$_POST)) $selected_amount = round((float)$_POST['selected_amount'], 2);
?>
<table width='500' cellpadding='5' cellspacing='2' border='0'>
<tr>
<td align='center'>
<?php
// Get processing timestamp
$now = time();
// Get current balance
$balance = db_getVal("select sum(transaction_amount) as balance from user_account where school_num = $school_num and user_num = $user_num AND (deleted != 'Y' OR deleted IS NULL)");
if (!$balance) $balance = 0.00;
$onlineMessage = db_getVal("SELECT text_data FROM text_data WHERE school_num = $school_num AND text_id = 'Online Payment Message'");
if (strlen($onlineMessage)) $onlineMessage .= "<br><br>\n";
switch ($school_num) {
case 251:
case 19438:
$onlineMessage .= "-- Please note that Interac Debit cards are not accepted --";
break;
default:
$onlineMessage .= "-- Please note that MasterCard Debit and Interac Debit cards are not accepted --";
break;
}
$onlineMessageDisplayed = false;
//
// School account processing
//
if ($balance > 0 && !isset($selected_amount)) {
if ($school_record['school_account_enabled'] == 'Y') {
?>
<table width='400' class='content-table'>
<tr>
<td align='center' class='warning'>
<?php if (strlen($school_record['school_account_title'])) echo("<p><b>" . $school_record['school_account_title'] . "</b></p>\n"); ?>
<?php if (strlen($school_record['school_account_text'])) echo("<p>" . $school_record['school_account_text'] . "</p>\n"); ?>
<?php echo("<p><b>" . getLanguageText('TEXT-BALANCE') . ": $currencySymbol" . number_format($balance,2) . "</b></p>\n"); ?>
<p><a href='/?action=charge-school-account' balance='<?php echo($balance) ?>' id='charge-school-account' class='btn btn--primary>'><?php echo($school_record['school_account_button_text']) ?></a></p>
</td>
</tr>
</table>
<br>
<br>
<script>
$(document).ready(function(e) {
<?php include "include/ajax.Setup.php" ?>
$("#charge-school-account").click(function(e) {
e.preventDefault();
var balance = $(this).attr('balance');
var msg = "<?php echo($school_record['school_account_message']) ?>";
msg = msg + "\n\n<?php displayLanguageText('TEXT-AMOUNT'); ?>: <?php echo($currencySymbol) ?>" + balance;
var ans = confirm(msg);
if (ans) {
var data = "&user_num=<?php echo($_SESSION['UserInfo']['record_num']) ?>&balance="+balance;
$.ajax({
url: "ajax.ChargeAccount.php",
data: data
}).done(function(data) {
window.location = '/';
});
}
});
});
</script>
<?php
}
}
//
// Bambora Form
//
if ($school_record['bambora_enabled'] == 'Y' && strlen($school_record['bambora_merchant_id']) > 0) {
// Get current balance
$balance = db_getVal("select sum(transaction_amount) as balance from user_account where school_num = $school_num and user_num = $user_num AND (deleted != 'Y' OR deleted IS NULL)");
if (!$balance) $balance = 0.00;
if (isset($selected_amount) && $selected_amount > 0) $balance = $selected_amount;
if ($balance >= $school_record['bambora_minimum_amount']) {
$trx_time = date("Y-m-d H:i:s",$now);
// Calculate the transaction fee
$trx_time = date("Y-m-d H:i:s",$now);
$trx_fee = intval($balance * $school_record['bambora_percentage']) / 100 + $school_record['bambora_fee'];
$owing = $balance + $trx_fee;
if (!$onlineMessageDisplayed && strlen($onlineMessage)) {
displayStatusMessage($onlineMessage,"warningframedspan");
$onlineMessageDisplayed = true;
}
// Calculate any payment discounts
// Check for an entry to the online payments table
$query = "select * from user_online_payments " .
"where school_num = $school_num " .
"and user_num = $user_num " .
"and status = 'Pending'";
$results = db_query($query);
if (db_num_rows($results) > 0) {
$row = db_fetch_array($results);
$trxnum = $row['record_num'];
$query = "update user_online_payments " .
"set user_account_balance = $balance, " .
"transaction_fee = $trx_fee, " .
"balance_owing = $owing, " .
"transaction_datetime = '$trx_time', " .
"transaction_time = $now " .
"where school_num = $school_num " .
"and user_num = $user_num " .
"and status = 'Pending'";
db_query($query);
}
else {
$query = "insert into user_online_payments (school_num,user_num,transaction_datetime,transaction_time,user_account_balance,transaction_fee,balance_owing,status) " .
"values ($school_num,$user_num,'$trx_time',$now,$balance,$trx_fee,$owing,'Pending')";
db_query($query);
$trxnum = db_insert_id();
}
// If there is a transaction fee
if ($trx_fee != 0 || strlen(trim($school_record['bambora_fee_message'])) > 0) {
$trx_title = getLanguageText('TEXT-TRX-FEE');
if (strlen($school_record['bambora_fee_title'])) $trx_title = $school_record['bambora_fee_title'];
?>
<table width='100%' class='content-table'>
<tr>
<td align='center' class='warning'>
<br>
<?php if (strlen(trim($school_record['bambora_fee_message']))) { ?>
<b><?php echo(nl2br(trim($school_record['bambora_fee_message']))); ?></b><br>
<br>
<?php } ?>
<?php if ($trx_fee != 0) { ?>
<?php if (isset($selected_amount) && $selected_amount > 0) { ?>
<b><nobr><?php echo(getLanguageText('TEXT-DEPOSIT-AMOUNT') . ": " . $currencySymbol . number_format($owing,2)) ?></nobr></b><br>
<?php } else { ?>
<b><nobr><?php echo(getLanguageText('TEXT-ACCOUNT-BALANCE') . ": " . $currencySymbol . number_format($owing,2)) ?></nobr></b><br>
<?php } ?>
<b><nobr><?php echo($trx_title . ": " . $currencySymbol . number_format($trx_fee,2)) ?></nobr></b>
<br>
<br>
<?php } ?>
</td>
</tr>
</table>
<br>
<?php
}
if ($school_num == 0) include "bambora_form2.php";
else {
if ($school_record['display_bambora_form'] != 'Link') include "bambora_form.php";
else {
echo("<a href='?action=bambora-payment' class='btn btn--primary>'>".getLanguageText('TEXT-CREDIT-CARD-PAYMENT')."</a>");
}
}
echo("<br>\n");
echo("<br>\n");
echo("<br>\n");
echo("<br>\n");
}
}
//
// PayPal Account Payments
//
if ($school_record['paypal_enabled'] == 'Y' && strlen($school_record['paypal_code']) > 0) {
// Get current balance
$balance = db_getVal("select sum(transaction_amount) as balance from user_account where school_num = $school_num and user_num = $user_num AND (deleted != 'Y' OR deleted IS NULL)");
if (!$balance) $balance = 0.00;
if (isset($selected_amount) && $selected_amount > 0) $balance = $selected_amount;
if ($balance >= $school_record['paypal_minimum_amount']) {
$trx_time = date("Y-m-d H:i:s",$now);
// if (!$onlineMessageDisplayed && strlen($onlineMessage)) {
// displayStatusMessage($onlineMessage,"warningframedspan");
// $onlineMessageDisplayed = true;
// }
// Calculate the transaction fee
$trx_time = date("Y-m-d H:i:s",$now);
$trx_fee = intval($balance * $school_record['paypal_percentage']) / 100 + $school_record['paypal_fee'];
$owing = $balance + $trx_fee;
// Calculate any payment discounts
// Check for an entry to the online payments table
$query = "select * from user_online_payments " .
"where school_num = $school_num " .
"and user_num = $user_num " .
"and status = 'Pending'";
$results = db_query($query);
if (db_num_rows($results) > 0) {
$row = db_fetch_array($results);
$trxnum = $row['record_num'];
$query = "update user_online_payments " .
"set user_account_balance = $balance, " .
"transaction_fee = $trx_fee, " .
"balance_owing = $owing, " .
"transaction_datetime = '$trx_time', " .
"transaction_time = $now " .
"where school_num = $school_num " .
"and user_num = $user_num " .
"and status = 'Pending'";
db_query($query);
}
else {
$query = "insert into user_online_payments (school_num,user_num,transaction_datetime,transaction_time,user_account_balance,transaction_fee,balance_owing,status) " .
"values ($school_num,$user_num,'$trx_time',$now,$balance,$trx_fee,$owing,'Pending')";
db_query($query);
$trxnum = db_insert_id();
}
$message = getLanguageText('TEXT-PAYPAL-DELAY-MSG');
// displayStatusMessage($message,"warningframedspan");
// If there is a transaction fee
if ($trx_fee != 0) {
$trx_title = getLanguageText('TEXT-TRX-FEE');
if (strlen($school_record['paypal_fee_title'])) $trx_title = $school_record['paypal_fee_title'];
?>
<table width='400' class='content-table'>
<tr>
<td align='center' class='warning'>
<br>
<?php if (strlen(trim($school_record['paypal_fee_message']))) { ?>
<b><?php echo(nl2br(trim($school_record['paypal_fee_message']))); ?></b><br>
<br>
<?php } ?>
<?php if (isset($selected_amount) && $selected_amount > 0) { ?>
<b><nobr><?php echo(getLanguageText('TEXT-DEPOSIT-AMOUNT') . ": " . $currencySymbol . number_format($balance,2)) ?></nobr></b><br>
<?php } else { ?>
<b><nobr><?php echo(getLanguageText('TEXT-ACCOUNT-BALANCE') . ": " . $currencySymbol . number_format($balance,2)) ?></nobr></b><br>
<?php } ?>
<b><nobr><?php echo($trx_title . ": " . $currencySymbol . number_format($trx_fee,2)) ?></nobr></b>
<br>
<br>
</td>
</tr>
</table>
<br>
<?php
}
// Single-account PayPal / credit card button, using PayPal Payments
// Standard: posts straight to PayPal's hosted checkout with the
// school's paypal_code as the receiving account (no client id/secret).
// Always shown alongside the button above, regardless of onboarding status.
// Buffered + wrapped so a failure here is visible in View Source
// instead of silently vanishing or breaking the rest of the page.
$oldPpBtnFile = __DIR__ . "/paypal_standard_button.php";
echo "<!-- old-paypal-button: file=" . htmlspecialchars($oldPpBtnFile, ENT_QUOTES) . " exists=" . (is_file($oldPpBtnFile) ? 'yes' : 'NO') . " -->\n";
try {
ob_start();
include $oldPpBtnFile;
$oldPpBtnOut = ob_get_clean();
echo "<!-- old-paypal-button: rendered " . strlen($oldPpBtnOut) . " bytes -->\n";
echo $oldPpBtnOut;
} catch (\Throwable $e) {
if (ob_get_level() > 0) ob_end_clean();
echo "<!-- old-paypal-button: ERROR " . htmlspecialchars($e->getMessage(), ENT_QUOTES) . " in " . htmlspecialchars($e->getFile(), ENT_QUOTES) . ":" . $e->getLine() . " -->\n";
}
echo("<br>\n");
}
} // End of PayPal Code
//
// Square Account Payments
// Deploy: this file lives at / (home). Square lives at /square/.
// The "Pay with Square" button appears ONLY when this school is onboarded with
// Square (connected, not revoked, Square location able to take payments); for
// every other school nothing is rendered. Payments are charged with the
// school's own Square token, so the money goes to that school's Square account.
// Buffered + wrapped like the PayPal button so a failure here can never break
// the rest of the payment page.
//
$sqRoot = rtrim((string)($_SERVER['DOCUMENT_ROOT'] ?? ''), '/\\');
$sq_button = $sqRoot . '/square/square_button.php';
if (!is_file($sq_button) && is_file(__DIR__ . '/square/square_button.php')) {
$sq_button = __DIR__ . '/square/square_button.php';
}
if (!is_file($sq_button) && is_file(__DIR__ . '/../square_button.php')) {
$sq_button = __DIR__ . '/../square_button.php';
}
if (is_file($sq_button)) {
try {
ob_start();
include $sq_button;
$sqBtnOut = ob_get_clean();
if (strpos($sqBtnOut, 'sq-pay-btn') !== false) $sqBtnOut .= "<br>\n";
echo $sqBtnOut;
} catch (\Throwable $e) {
if (ob_get_level() > 0) ob_end_clean();
if (function_exists('writeLog')) writeLog('square button error: ' . $e->getMessage());
echo "\n<!-- sq-button hidden: error -->\n";
}
} else {
echo "\n<!-- sq-button hidden: square_button.php not found (looked in /square/) -->\n";
}
//
// E-xact Account Payments
//
if ($school_record['exact_enabled'] == 'Y' && strlen($school_record['exact_code']) > 0) {
// Calculate the transaction fee
$trx_time = date("Y-m-d H:i:s",$now);
$trx_fee = intval($balance * $school_record['exact_percentage']) / 100 + $school_record['exact_fee'];
$owing = $balance + $trx_fee;
// Check for an entry to the online payments table
$query = "select * from user_online_payments " .
"where school_num = $school_num " .
"and user_num = $user_num " .
"and status = 'Pending'";
$results = db_query($query);
if (db_num_rows($results) > 0) {
$row = db_fetch_array($results);
$trxnum = $row['record_num'];
$query = "update user_online_payments " .
"set user_account_balance = $balance, " .
"transaction_fee = $trx_fee, " .
"balance_owing = $owing, " .
"transaction_datetime = '$trx_time', " .
"transaction_time = $now " .
"where school_num = $school_num " .
"and user_num = $user_num " .
"and status = 'Pending'";
db_query($query);
}
else {
$query = "insert into user_online_payments (school_num,user_num,transaction_datetime,transaction_time,user_account_balance,transaction_fee,balance_owing,status) " .
"values ($school_num,$user_num,'$trx_time',$now,$balance,$trx_fee,$owing,'Pending')";
db_query($query);
$trxnum = db_insert_id();
}
// If there is a transaction fee
if ($trx_fee > 0) {
?>
<table width='300' class='content-table'>
<tr>
<td align='center' class='warning'>
<br>
<?php if (strlen(trim($school_record['exact_fee_message']))) { ?>
<b><?php echo(nl2br(trim($school_record['exact_fee_message']))); ?></b><br>
<br>
<?php } ?>
<b><nobr><?php echo(getLanguageText('TEXT-ACCOUNT-BALANCE') . ": " . $currencySymbol . number_format($balance,2)) ?></nobr></b><br>
<b><nobr><?php echo(getLanguageText('TEXT-TRX-FEE') . ": " . $currencySymbol . number_format($trx_fee,2)) ?></nobr></b>
<br>
<br>
</td>
</tr>
</table>
<br>
<?php
}
include "exact_form.php";
echo("<br>\n");
} // End of Exact code
//
// Merchant Account Payments
//
if ($school_record['merchant_account_enabled'] != 'None' && strlen($school_record['merchant_account_key_id']) > 0) {
// Get current balance
$balance = db_getVal("select sum(transaction_amount) as balance from user_account where school_num = $school_num and user_num = $user_num AND (deleted != 'Y' OR deleted IS NULL)");
if (!$balance) $balance = 0.00;
$owing = $balance;
// Calculate any payment discounts
$today = date("Y-m-d",$now);
$payment_discount = 0;
$query = "SELECT * FROM payment_discounts WHERE school_num = $school_num AND start_date <= '$today' AND end_date >= '$today'";
$results = db_query($query);
if (db_num_rows($results)) {
$row = db_fetch_assoc($results);
$discount_type = $row['discount_type'];
$discount = $row['discount'];
$discount_message = $row['discount_message'];
switch ($discount_type) {
case 'Amount':
$payment_discount = $discount;
break;
case 'Percentage':
$payment_discount = round($owing * $discount) / 100;
break;
}
$owing = $owing - $payment_discount;
}
// Calculate the transaction fee
$trx_time = date("Y-m-d H:i:s",$now);
$trx_fee = intval($owing * $school_record['merchant_account_percentage']) / 100 + $school_record['merchant_account_fee'];
$owing = $owing + $trx_fee;
// Check for an entry to the online payments table
$query = "select * from user_online_payments " .
"where school_num = $school_num " .
"and user_num = $user_num " .
"and status = 'Pending'";
$results = db_query($query);
if (db_num_rows($results) > 0) {
$row = db_fetch_array($results);
$trxnum = $row['record_num'];
$query = "update user_online_payments " .
"set user_account_balance = $balance, " .
"payment_discount = $payment_discount, " .
"transaction_fee = $trx_fee, " .
"balance_owing = $owing, " .
"transaction_datetime = '$trx_time', " .
"transaction_time = $now " .
"where school_num = $school_num " .
"and user_num = $user_num " .
"and status = 'Pending'";
db_query($query);
}
else {
$query = "insert into user_online_payments (school_num,user_num,transaction_datetime,transaction_time,user_account_balance,payment_discount,transaction_fee,balance_owing,status) " .
"values ($school_num,$user_num,'$trx_time',$now,$balance,$payment_discount,$trx_fee,$owing,'Pending')";
db_query($query);
$trxnum = db_insert_id();
}
// If there is a payment discount message
if ($payment_discount > 0) {
?>
<table width='300' class='content-table'>
<tr>
<td align='center' class='success'>
<br>
<?php if (strlen($discount_message)) { ?>
<b><?php echo(nl2br(trim($discount_message))); ?></b><br>
<br>
<?php } ?>
<table cellpadding='2' cellspacing='0' border='0'>
<tr>
<td align='right'><?php displayLanguageText('TEXT-DISCOUNT'); ?>:&nbsp;&nbsp;</td>
<td align='right'><?php echo('-' . $currencySymbol . number_format($payment_discount,2)) ?></td>
</tr>
<tr>
<td align='right'><?php displayLanguageText('TEXT-BALANCE'); ?>:&nbsp;&nbsp;</td>
<td align='right'><?php echo($currencySymbol . number_format($owing,2)) ?></td>
</tr>
</table>
<br>
</td>
</tr>
</table>
<br>
<?php
}
// If there is a transaction fee
if ($trx_fee > 0) {
?>
<table width='300' class='content-table'>
<tr>
<td align='center' class='warning'>
<br>
<?php if (strlen(trim($school_record['merchant_account_fee_message']))) { ?>
<b><?php echo(nl2br(trim($school_record['merchant_account_fee_message']))); ?></b><br>
<br>
<?php } ?>
<b><nobr><?php echo(getLanguageText('TEXT-ACCOUNT-BALANCE') . ": " . $currencySymbol . number_format($balance,2)) ?></nobr></b><br>
<b><nobr><?php echo(getLanguageText('TEXT-TRX-FEE') . ": " . $currencySymbol . number_format($trx_fee,2)) ?></nobr></b>
<br>
<br>
</td>
</tr>
</table>
<br>
<?php
}
include "merchant_account_form.php";
echo("<br>\n");
} // End of Merchant Account code
if ($balance > 0 && !isset($selected_amount)) {
if ($school_record['display_remittance_button'] == 'Y') {
$btnText = trim($school_record['remittance_button_text']);
if (strlen($btnText) == 0) $btnText = getLanguageText('TEXT-PRINT-REMITTANCE');
?>
<br>
<form method='post' action='/'>
<input type='submit' value='<?php echo($btnText) ?>' class='btn btn--primary'>
<input type='hidden' name='action' value='print-remittance-form'>
</form>
<br>
<?php
if ($school_record['display_remittance_msg_with_button'] == 'Y') {
$text = db_getVal("SELECT text_data FROM text_data WHERE school_num = $school_num AND text_id = 'Remittance Form'");
if (strlen($text) > 0) {
echo("<p>" . nl2br($text) . "</p>\n");
}
}
}
}
?>
</td>
</tr>
</table>
File diff suppressed because it is too large. Load diff
+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* At-rest encryption for seller OAuth tokens.
*
* Square access/refresh tokens let anyone holding them move money on the
* seller's behalf, so — unlike the PayPal merchant IDs we stored in plaintext —
* they must not sit readable on disk or in the database. We use AES-256-GCM
* (authenticated encryption) with a 32-byte key from config.
*
* Format of seal(): base64( iv[12] || tag[16] || ciphertext ), prefixed with
* "enc:v1:" so we can tell encrypted values from any legacy plaintext and can
* evolve the scheme later.
*/
class Secret
{
private const PREFIX = 'enc:v1:';
private const CIPHER = 'aes-256-gcm';
/**
* Resolve the 32-byte raw key from the configured 64-hex-char string.
* Returns null when no (valid) key is configured.
*/
private static function key(?string $configKey): ?string
{
if (!$configKey) return null;
$configKey = trim($configKey);
// Accept a 64-char hex string (preferred) or a raw 32-byte key.
if (preg_match('/^[0-9a-fA-F]{64}$/', $configKey)) {
return hex2bin($configKey);
}
if (strlen($configKey) === 32) {
return $configKey;
}
// Anything else is misconfigured; treat as "no key" so the caller can warn.
return null;
}
/**
* Encrypt a token for storage. If no usable key is configured, returns the
* plaintext unchanged and logs a warning (so dev still works, but production
* never silently ships unencrypted tokens without a trace).
*/
public static function seal(string $plaintext, ?string $configKey): string
{
$key = self::key($configKey);
if ($key === null) {
if (function_exists('writeLog')) {
writeLog('Secret::seal WARNING — no token_encryption_key set; storing token in PLAINTEXT.');
}
return $plaintext;
}
$iv = random_bytes(12);
$tag = '';
$ct = openssl_encrypt($plaintext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($ct === false) {
throw new RuntimeException('Secret::seal — encryption failed');
}
return self::PREFIX . base64_encode($iv . $tag . $ct);
}
/**
* Decrypt a stored token. Plaintext (un-prefixed) values are returned as-is
* for backward compatibility with anything stored before a key was set.
*/
public static function open(string $stored, ?string $configKey): string
{
if (strncmp($stored, self::PREFIX, strlen(self::PREFIX)) !== 0) {
return $stored; // legacy / plaintext
}
$key = self::key($configKey);
if ($key === null) {
throw new RuntimeException('Secret::open — value is encrypted but no key is configured');
}
$raw = base64_decode(substr($stored, strlen(self::PREFIX)), true);
if ($raw === false || strlen($raw) < 28) {
throw new RuntimeException('Secret::open — malformed ciphertext');
}
$iv = substr($raw, 0, 12);
$tag = substr($raw, 12, 16);
$ct = substr($raw, 28);
$pt = openssl_decrypt($ct, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($pt === false) {
throw new RuntimeException('Secret::open — decryption/auth failed (wrong key or tampered data)');
}
return $pt;
}
}
+301
View File
@@ -0,0 +1,301 @@
<?php
/**
* Thin Square API client mirroring the structure of PayPalClient.
*
* Unlike the PayPal partner model (which acts on a seller's behalf via an
* Auth-Assertion JWT built from the partner credentials), Square uses OAuth:
* each seller authorizes our application and we receive a per-seller
* access_token (30-day) + refresh_token (non-expiring for the code flow).
* Seller API calls are therefore made with that seller's bearer token, NOT the
* application secret.
*/
class SquareClient
{
private string $clientId;
private string $clientSecret;
private string $env; // sandbox|production
private string $connectBase; // https://connect.squareup(sandbox).com
private string $squareVersion;
private array $scopes;
private bool $forceLogin; // if true, send session=false to force a fresh sign-in
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->squareVersion = $config['square_version'] ?? '2026-05-20';
$this->scopes = $config['scopes'] ?? [];
$this->forceLogin = !empty($config['oauth_force_login']);
$this->caBundle = !empty($config['curl_ca_bundle']) ? $config['curl_ca_bundle'] : null;
$this->connectBase = $this->env === 'production'
? 'https://connect.squareup.com'
: 'https://connect.squareupsandbox.com';
if (!$this->clientId || !$this->clientSecret) {
throw new RuntimeException('Missing Square 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;
}
/**
* Build the seller-facing authorization URL. The seller is redirected here,
* logs into THEIR Square account, and approves our requested scopes.
*
* @param string $state Opaque anti-CSRF token we generate and later verify.
* @param string $redirectUri Optional; must match the dashboard registration
* if supplied (and must then also be sent to
* obtainToken()).
*/
public function authorizeUrl(string $state, string $redirectUri = ''): string
{
$params = [
'client_id' => $this->clientId,
'scope' => implode(' ', $this->scopes),
'state' => $state,
];
// session=false forces a fresh sign-in. Leave it OFF by default: the
// Sandbox authorize page can't render a sign-in (you must keep a test
// account's Dashboard open instead), so forcing it returns 400. Enable
// via config 'oauth_force_login' => true only if you need it in production.
if ($this->forceLogin) {
$params['session'] = 'false';
}
if ($redirectUri !== '') {
$params['redirect_uri'] = $redirectUri;
}
return $this->connectBase . '/oauth2/authorize?' . http_build_query($params);
}
/**
* Exchange an authorization code for an access/refresh token pair.
* Called from the OAuth callback. Auth codes expire after 5 minutes.
*/
public function obtainToken(string $code, string $redirectUri = ''): array
{
$payload = [
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'code' => $code,
'grant_type' => 'authorization_code',
];
if ($redirectUri !== '') {
$payload['redirect_uri'] = $redirectUri;
}
return $this->oauth('/oauth2/token', $payload);
}
/**
* Trade a refresh token for a fresh access token (Phase 2 background job).
* Code-flow refresh tokens do not expire, but access tokens last 30 days.
*/
public function refreshToken(string $refreshToken): array
{
return $this->oauth('/oauth2/token', [
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'refresh_token' => $refreshToken,
'grant_type' => 'refresh_token',
]);
}
/**
* Revoke a seller's tokens (seller disconnects, or cleanup). Note the
* special "Client {secret}" authorization scheme required by this endpoint.
* Square rejects a request carrying BOTH access_token and merchant_id
* ("provide either 'access_token' or 'merchant_id', not both"), so the
* merchant id — which revokes the whole authorization — wins when given.
*/
public function revokeToken(string $accessToken = '', string $merchantId = ''): array
{
$payload = ['client_id' => $this->clientId];
if ($merchantId !== '') {
$payload['merchant_id'] = $merchantId;
} elseif ($accessToken !== '') {
$payload['access_token'] = $accessToken;
}
return $this->request('POST', '/oauth2/revoke', $payload, [
'Authorization: Client ' . $this->clientSecret,
]);
}
/**
* OAuth token endpoint helper. These calls authenticate with the app
* credentials inside the JSON body (no bearer header).
*/
private function oauth(string $path, array $payload): array
{
return $this->request('POST', $path, $payload, []);
}
/**
* Authorized Square API call ON BEHALF OF A SELLER using that seller's
* access token. This is the Square analogue of PayPalClient::api()'s
* Auth-Assertion calls — the token identifies which seller's account the
* request acts on, so funds route to that seller.
*/
public function api(string $method, string $path, string $accessToken, array $payload = null): array
{
return $this->request($method, $path, $payload, [
'Authorization: Bearer ' . $accessToken,
]);
}
/**
* Low-level cURL wrapper. Returns ['status'=>int,'body'=>array,'debug_id'=>?string]
* to match the shape PayPalClient::api() returns, so calling code reads the same.
* Square returns a correlation id in the response body errors, but also exposes
* a Square-Version echo; we capture nothing extra beyond status + body here.
*/
private function request(string $method, string $path, ?array $payload, array $extraHeaders): array
{
$url = $this->connectBase . $path;
$headers = array_merge([
'Content-Type: application/json',
'Accept: application/json',
'Square-Version: ' . $this->squareVersion,
], $extraHeaders);
$ch = curl_init($url);
$opts = [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => strtoupper($method),
];
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('Square API request failed: ' . curl_error($ch));
}
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($resp, true);
if ($data === null && $resp !== '' && $resp !== 'null') {
$data = ['raw' => $resp];
}
// Square reports failures as a body { "errors": [ { detail, code, ... } ] }.
$debugId = $data['errors'][0]['detail'] ?? null;
if (function_exists('writeLog')) {
writeLog('Square API ' . strtoupper($method) . ' ' . $path . ' => HTTP ' . $code
. (isset($data['errors']) ? ' errors=' . json_encode($data['errors']) : ''));
}
return ['status' => $code, 'body' => $data ?? [], 'debug_id' => $debugId];
}
/**
* Charge a card ON BEHALF OF A SELLER. The $accessToken is that seller's
* OAuth token, which is what routes the money into their Square balance; any
* app_fee_money in the payload is split off to the platform's account.
*
* $payload must include at least source_id, idempotency_key, amount_money.
* Returns the same ['status','body','debug_id'] shape as api().
*/
public function createPayment(string $accessToken, array $payload): array
{
return $this->api('POST', '/v2/payments', $accessToken, $payload);
}
/**
* Determine whether a connected seller can actually accept card payments.
* Mirrors PayPalClient::getSellerStatus(): returns a normalized readiness
* summary plus a list of human-readable issues.
*
* Readiness = the merchant has at least one ACTIVE location whose
* capabilities include CREDIT_CARD_PROCESSING.
*/
public function getSellerStatus(string $accessToken): array
{
$resp = $this->api('GET', '/v2/locations', $accessToken);
$body = $resp['body'] ?? [];
$locations = $body['locations'] ?? [];
$activeLocationId = null;
$canTakeCards = false;
$merchantName = null;
$country = null;
$currency = null;
foreach ($locations as $loc) {
$merchantName = $merchantName ?? ($loc['business_name'] ?? $loc['name'] ?? null);
$country = $country ?? ($loc['country'] ?? null);
$currency = $currency ?? ($loc['currency'] ?? null);
$isActive = ($loc['status'] ?? '') === 'ACTIVE';
$caps = $loc['capabilities'] ?? [];
$hasCardProc = in_array('CREDIT_CARD_PROCESSING', $caps, true);
if ($isActive && $activeLocationId === null) {
$activeLocationId = $loc['id'] ?? null;
}
if ($isActive && $hasCardProc) {
$canTakeCards = true;
$activeLocationId = $loc['id'] ?? $activeLocationId;
}
}
// An ACTIVE location is enough to render checkout. CREDIT_CARD_PROCESSING
// is preferred, but some verified sellers omit it from capabilities.
if (!$canTakeCards && $activeLocationId !== null) {
$canTakeCards = true;
}
$issues = [];
if ($resp['status'] < 200 || $resp['status'] >= 300) {
$issues[] = 'Could not read locations (HTTP ' . $resp['status'] . ') — token may be invalid or missing MERCHANT_PROFILE_READ scope.';
} elseif (empty($locations)) {
$issues[] = 'Seller has no Square locations yet.';
} else {
if ($activeLocationId === null) {
$issues[] = 'Seller has no ACTIVE location.';
}
if (!$canTakeCards) {
$issues[] = 'No location has CREDIT_CARD_PROCESSING — seller cannot accept card payments yet (account may need verification).';
}
}
return [
'status' => $resp['status'],
'square_ready' => $canTakeCards,
'can_take_cards' => $canTakeCards,
'active_location_id' => $activeLocationId,
'merchant_name' => $merchantName,
'country' => $country,
'currency' => $currency,
'location_count' => count($locations),
'issues' => $issues,
];
}
public function getConnectBase(): string
{
return $this->connectBase;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
class Storage
{
private string $dir;
public function __construct(string $dir)
{
$this->dir = rtrim($dir, DIRECTORY_SEPARATOR);
if (!is_dir($this->dir)) {
mkdir($this->dir, 0777, true);
}
if (!is_writable($this->dir)) {
throw new RuntimeException('Storage path not writable: ' . $this->dir);
}
}
private function path(string $name): string
{
return $this->dir . DIRECTORY_SEPARATOR . $name . '.json';
}
public function get(string $name, $default = [])
{
$p = $this->path($name);
if (!file_exists($p)) return $default;
$data = json_decode((string)file_get_contents($p), true);
return $data ?? $default;
}
public function set(string $name, $value): void
{
$p = $this->path($name);
file_put_contents($p, json_encode($value, JSON_PRETTY_PRINT));
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
/**
* LOCAL DEV ONLY — stubs for the hotlunches host-app functions so onboard.php
* can run WITHOUT the real ../include/* files.
*
* onboard.php loads this ONLY when the host includes are absent. In production
* the real includes always exist, so this file never loads and onboarding stays
* behind the real SuperUser login gate.
*
* ⚠️ With these stubs the login gate is effectively OPEN (UserLoggedIn() = true).
* Do NOT deploy this file to a public/production server that lacks the host
* app. It is purely for testing the OAuth flow on http://localhost.
*/
if (!function_exists('startLog')) { function startLog($n = '') {} }
if (!function_exists('writeLog')) { function writeLog($m = '') { error_log('[square] ' . $m); } }
if (!function_exists('SessionActive')) { function SessionActive() { return 1; } }
if (!function_exists('UserLoggedIn')) { function UserLoggedIn() { return true; } }
if (!function_exists('checkPermission')) { function checkPermission($types) { return 1; } }
if (!function_exists('getLanguageText')) { function getLanguageText($k) { return $k; } }
if (!function_exists('displayLanguageText')) { function displayLanguageText($k) { echo 'School name'; } }
// Variables onboard.php expects from the host app.
$FormTarget = $FormTarget ?? '/';
$school_record = $school_record ?? ['school_name' => ''];
+673
View File
@@ -0,0 +1,673 @@
<?php
ini_set('display_errors', 0);
$ValidAccess = 2010;
// require_once("../include/globals.php");
$cdir = "/m/data1/app.hotlunches.net";
$AppDir = $cdir;
ob_start();
// Square redirects the seller's browser back to the ONE Redirect URL registered
// for the app, whichever school started onboarding. The host app resolves the
// school from the hostname (and bounces hosts that aren't a school, e.g.
// app.hotlunches.net → invalid-access.html), so the callback must NOT load the
// school/session includes or it would never run. It needs no session: it is
// authenticated by the single-use `state` token we generated, and MerchantStore
// loads only the logging/config/database includes itself.
$isSquareCallback = isset($_GET['code']) || isset($_GET['error']);
$isSquareCallback = $isSquareCallback && isset($_GET['state']);
if ($isSquareCallback) {
if (file_exists(__DIR__ . "/../include/logging-functions.php")) {
require_once(__DIR__ . "/../include/logging-functions.php");
}
if (!function_exists('writeLog')) { function writeLog($m = '') { error_log('[square] ' . $m); } }
if (!function_exists('startLog')) { function startLog($n = '') {} }
} elseif (file_exists(__DIR__ . "/../include/logging-functions.php")) {
// Host-app integration: logging, the session, and the admin login gate.
require_once(__DIR__ . "/../include/logging-functions.php");
startLog("includes");
require_once(__DIR__ . "/../include/date-time.php");
require_once(__DIR__ . "/../include/config.php");
require_once(__DIR__ . "/../include/school-functions.php");
require_once(__DIR__ . "/../include/database.php");
require_once(__DIR__ . "/../include/session.php");
} elseif (file_exists(__DIR__ . "/local-dev-bootstrap.php")) {
// Local dev only (stubs the host app); never deployed.
require_once(__DIR__ . "/local-dev-bootstrap.php");
}
if (!$isSquareCallback) {
$sessionName = 'hotlunch';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_name($sessionName);
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');
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("square-integration");
// redirect_uri sent to Square. Empty (the default) sends none, and Square uses
// the Redirect URL registered in the Developer Dashboard — correct for every
// school host. Never derive it from the current host: each school has its own
// hostname, and anything that differs from the registered URL is rejected.
function squareRedirectUri(array $config): string {
return trim($config['redirect_uri'] ?? '');
}
// ── Returning the admin to their OWN school site ─────────────────────────────
// Square sends every school back to the single Redirect URL registered for the
// app, not to the school's site. So when an admin clicks Connect on
// https://<schoolurl>/square/onboard.php we carry that URL inside the OAuth
// `state` (which Square echoes back verbatim), signed with our server key so it
// cannot be swapped for another site:
// state = <32-hex nonce>.<base64url(school onboard URL)>.<HMAC>
// Only the nonce is stored server-side (square_pending_onboarding.state), so no
// schema change is needed.
function squareStateKey(array $config): string {
$secret = (string)(($config['token_encryption_key'] ?? '') ?: ($config['client_secret'] ?? ''));
return hash('sha256', 'square-oauth-state|' . $secret, true);
}
// The URL of the onboarding page the admin is on right now (on their school's host).
function squareCurrentOnboardUrl(): string {
$scheme = 'http';
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
$scheme = 'https';
} elseif (strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https') {
$scheme = 'https';
}
$host = (string)($_SERVER['HTTP_HOST'] ?? '');
$path = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: ($_SERVER['SCRIPT_NAME'] ?? ''));
if (!preg_match('/^[A-Za-z0-9.-]+(:\d{1,5})?$/', $host) || !preg_match('#^/[A-Za-z0-9._~/-]*$#', $path)) {
return '';
}
return $scheme . '://' . $host . $path;
}
function squareMakeState(array $config, string $onboardUrl): array {
$nonce = bin2hex(random_bytes(16));
if ($onboardUrl === '') return [$nonce, $nonce];
$payload = rtrim(strtr(base64_encode($onboardUrl), '+/', '-_'), '=');
$sig = substr(hash_hmac('sha256', $nonce . '.' . $payload, squareStateKey($config)), 0, 32);
return [$nonce, $nonce . '.' . $payload . '.' . $sig];
}
// Returns [nonce, verified school onboard URL or ''].
function squareParseState(array $config, string $state): array {
if (preg_match('/^([0-9a-f]{32})\.([A-Za-z0-9_-]{1,700})\.([0-9a-f]{32})$/', $state, $m)) {
$expected = substr(hash_hmac('sha256', $m[1] . '.' . $m[2], squareStateKey($config)), 0, 32);
if (!hash_equals($expected, $m[3])) return ['', ''];
$url = (string)base64_decode(strtr($m[2], '-_', '+/'), true);
return [$m[1], preg_match('#^https?://[A-Za-z0-9.-]+(:\d{1,5})?/[A-Za-z0-9._~/-]*$#', $url) ? $url : ''];
}
return [preg_match('/^[0-9a-f]{32}$/', $state) ? $state : '', ''];
}
// The school's home URL: the onboarding URL minus its "/square/onboard.php" tail.
function squareSchoolHomeUrl(string $onboardUrl): string {
$p = parse_url($onboardUrl);
$base = rtrim(str_replace('\\', '/', dirname(dirname($p['path'] ?? '/'))), '/');
return $p['scheme'] . '://' . $p['host'] . (isset($p['port']) ? ':' . $p['port'] : '') . $base . '/';
}
// Finish the Square callback. When we know the school's site (from the signed
// state), send the admin back there:
// connected + payments ready → the school's home URL (button is live);
// connected but Square not ready → the school's onboarding page, which explains why;
// failed → the school's onboarding page with the error.
// Otherwise (legacy/unsigned state) render a self-contained page: this host is
// usually not the school's site, so it must not link into gated pages.
function squareCallbackPage(bool $ok, string $title, string $bodyHtml, bool $ready = false): void {
$GLOBALS['sqCallbackPageSent'] = true; // tells the shutdown guard a page was rendered
$onboardUrl = (string)($GLOBALS['sqReturnOnboardUrl'] ?? '');
if ($onboardUrl !== '' && !headers_sent()) {
if ($ok && $ready) {
$to = squareSchoolHomeUrl($onboardUrl);
} elseif ($ok) {
$to = $onboardUrl;
} else {
$msg = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags(str_replace('</p>', ' ', $bodyHtml)), ENT_QUOTES)));
$to = $onboardUrl . '?status=error&msg=' . rawurlencode($title . ': ' . $msg);
}
header('Location: ' . $to, true, 302);
exit();
}
if (!headers_sent()) header('Content-Type: text/html; charset=utf-8');
$color = $ok ? '#28a745' : '#c53030';
echo "<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width, initial-scale=1'>"
. "<title>School Square Setup</title></head>"
. "<body style='margin:0;font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,sans-serif;background:linear-gradient(135deg,#006aff 0%,#003a8c 100%);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;box-sizing:border-box'>"
. "<div style='background:#fff;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.3);padding:36px;max-width:520px;width:100%;box-sizing:border-box;text-align:center;color:#444;line-height:1.6'>"
. "<div style='font-size:56px;color:$color'>" . ($ok ? '✓' : '!') . "</div>"
. "<h2 style='color:$color;margin:8px 0 14px'>" . htmlspecialchars($title) . "</h2>"
. $bodyHtml
. "<p style='margin-top:22px;font-size:14px;color:#666'>You can close this page and return to your school's HotLunches site.</p>"
. "</div></body></html>";
exit();
}
// The school this logged-in admin may connect/disconnect: ALWAYS the school of
// their own session ($school_record from the host app) — never a school id or
// name sent by the browser, or an admin of one school could attach their own
// Square account to another school and receive its payments. Platform
// SuperUsers working outside any school context may name the school by its
// unique record_num.
function squareAdminSchoolNum(): int {
global $school_record;
$num = (int)($school_record['record_num'] ?? 0);
if ($num > 0) return $num;
if (checkPermission(array('SuperUser')) == 1) return (int)($_POST['school_num'] ?? 0);
return 0;
}
// Handle AJAX requests
if (isset($_POST['action'])) writeLog("POST action: ".$_POST['action']);
// Admin actions only — never on a callback-shaped request, which skipped the login gate.
if (!$isSquareCallback && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
header('Content-Type: application/json');
// Guarantee a JSON body even on a FATAL error (PHP `Error`, parse/type error,
// a broken DB connection inside db_query, an OOM, etc.). Without this, such
// failures emit a blank 500 body because display_errors is off, and the
// browser's r.json() throws "Unexpected end of JSON input" with no clue why.
// We gate strictly on a fatal having occurred so a normal successful exit()
// (which also triggers this shutdown handler) does NOT append a second body.
register_shutdown_function(function () {
$err = error_get_last();
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
if (!$err || !in_array($err['type'], $fatalTypes, true)) {
return;
}
$msg = 'Server error: ' . $err['message'];
if (function_exists('writeLog')) {
writeLog('onboard POST fatal: ' . $msg);
}
if (!headers_sent()) {
header('Content-Type: application/json');
}
echo json_encode(['success' => false, 'error' => $msg]);
});
$config = require __DIR__ . '/config.php';
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
try {
$client = new SquareClient($config);
$store = new MerchantStore($config);
// ACTION: Create an OAuth authorization link and hand it to the browser.
$schoolNum = squareAdminSchoolNum();
if ($schoolNum <= 0) {
echo json_encode(['success' => false, 'error' => 'Could not determine your school. Please sign in to your school account and try again.']);
exit();
}
if ($_POST['action'] === 'connect') {
// Confirm the school_num actually exists before starting OAuth, so the
// callback is guaranteed a row to attach to.
$resolvedName = $store->schoolNameByNum($schoolNum);
if ($resolvedName === null) {
echo json_encode(['success' => false, 'error' => 'School not found.']);
exit();
}
writeLog("connect school_num: $schoolNum ($resolvedName)");
// Cryptographically-random anti-CSRF nonce, plus this school's own
// onboarding URL (signed) so the callback can send the admin back to
// their school's site. The nonce keys the pending record.
[$nonce, $state] = squareMakeState($config, squareCurrentOnboardUrl());
$authUrl = $client->authorizeUrl($state, squareRedirectUri($config));
writeLog("connect: authorize URL issued for school_num $schoolNum");
// Persist the pending attempt in the DB (prunes stale ones first).
$store->createPending($nonce, $schoolNum);
echo json_encode(['success' => true, 'redirect' => $authUrl]);
exit();
}
// ACTION: Re-check a connected seller's readiness (locations / card capability).
if ($_POST['action'] === 'check_status') {
$accessToken = $store->ensureFreshToken('', $client, ['school_num' => $schoolNum])['access_token'] ?? null;
if (!$accessToken) {
echo json_encode(['success' => false, 'error' => 'Your school is not connected to Square yet']);
exit();
}
$sellerStatus = $client->getSellerStatus($accessToken);
writeLog('Seller status [manual] school_num ' . $schoolNum . ': square_ready=' . ($sellerStatus['square_ready'] ? 'true' : 'false'));
$store->mergeMetaByNum($schoolNum, [
'square_ready' => $sellerStatus['square_ready'] && !empty($sellerStatus['active_location_id']),
'active_location_id' => $sellerStatus['active_location_id'],
'merchant_name' => $sellerStatus['merchant_name'],
'currency' => $sellerStatus['currency'],
'status_issues' => $sellerStatus['issues'],
'status_checked_at' => time(),
]);
echo json_encode([
'success' => true,
'square_ready' => $sellerStatus['square_ready'],
'active_location_id' => $sellerStatus['active_location_id'],
'currency' => $sellerStatus['currency'],
'issues' => $sellerStatus['issues'],
]);
exit();
}
// ACTION: Disconnect a seller — revoke their tokens at Square and clear ours.
if ($_POST['action'] === 'disconnect') {
$record = $store->getByNum($schoolNum);
if (!$record) {
echo json_encode(['success' => false, 'error' => 'Your school is not connected to Square']);
exit();
}
// Revoke the whole authorization at Square first (by merchant id). A
// revoke Square refused is logged; we still clear our copy so the
// button disappears, and the school can also remove the app in Square.
try {
$resp = $client->revokeToken('', (string)$record['merchant_id']);
if ($resp['status'] < 200 || $resp['status'] >= 300) {
writeLog('Revoke rejected for school_num ' . $schoolNum . ': HTTP ' . $resp['status'] . ' ' . ($resp['debug_id'] ?? ''));
}
} catch (\Throwable $e) {
writeLog('Revoke failed for school_num ' . $schoolNum . ': ' . $e->getMessage());
}
$store->removeConnectionByNum($schoolNum);
echo json_encode(['success' => true]);
exit();
}
} catch (\Throwable $e) {
// \Throwable (not just Exception) so PHP Errors — TypeError, a fatal from
// a broken db_query, etc. — are reported as JSON instead of a blank body.
if (function_exists('writeLog')) {
writeLog('onboard POST error: ' . $e->getMessage());
}
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
exit();
}
}
// Handle Square OAuth callback (top-level browser return from squareup.com).
if ($isSquareCallback) {
// Never log the query string: it carries the single-use code and state.
writeLog('Square callback received' . (isset($_GET['error']) ? ' (error=' . $_GET['error'] . ')' : ''));
// Never leave the seller on a blank page. Every normal path below ends in a
// squareCallbackPage(); if execution instead dies first (a fatal PHP Error, or
// a host db_query that exits the script on a query error), this shutdown
// guard renders a readable status page instead of an empty body.
register_shutdown_function(function () {
// A result page was rendered (headers_sent() can't tell when output is
// buffered), or real output already went out — nothing to rescue.
if (!empty($GLOBALS['sqCallbackPageSent']) || headers_sent()) {
return;
}
$err = error_get_last();
$detail = ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true))
? $err['message'] : '';
if (function_exists('writeLog')) {
writeLog('Square callback aborted before redirect' . ($detail !== '' ? (': ' . $detail) : ' (no PHP fatal captured — likely a DB query error)'));
}
$back = (string)($GLOBALS['sqReturnOnboardUrl'] ?? '');
if ($back !== '') {
while (ob_get_level() > 0) ob_end_clean();
header('Location: ' . $back . '?status=error&msg=' . rawurlencode('Square authorized the account, but saving the connection failed on our side. Please click Connect Square Account again.'), true, 302);
return;
}
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
echo "<!doctype html><meta charset='utf-8'>"
. "<div style='font-family:system-ui,Arial,sans-serif;max-width:560px;margin:60px auto;padding:24px;border:1px solid #e2e8f0;border-radius:12px'>"
. "<h2 style='color:#c53030;margin:0 0 8px'>Connection could not be completed</h2>"
. "<p style='color:#4a5568'>Square authorized the account, but saving the connection failed on our side. "
. "Please open Square setup on your school's HotLunches site and click <b>Connect Square Account</b> again.</p>"
. "</div>";
});
$config = require __DIR__ . '/config.php';
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
$store = new MerchantStore($config);
// $state = the server-side nonce; the verified school onboarding URL (if the
// signature checks out) is where the admin is sent back to.
[$state, $GLOBALS['sqReturnOnboardUrl']] = squareParseState($config, (string)($_GET['state'] ?? ''));
$code = $_GET['code'] ?? '';
$oauthError = $_GET['error'] ?? '';
// 1) Validate state against our server-side pending store (CSRF protection).
// Only an unused attempt from the last 15 minutes is accepted, and it is
// burned right away, so a state value can never be replayed later to swap
// another Square account onto the school.
$pendingRow = $store->getPending($state);
if (!$state || $pendingRow === null) {
writeLog('Callback rejected: unknown, expired or already-used state');
squareCallbackPage(false, 'Link expired', '<p>This authorization link has expired or was already used. Please open Square setup on your school&#39;s site and click Connect again.</p>');
}
$store->updatePending($state, 'processing', ['last_callback' => time()]);
// The pending record identifies the school by its unique school_num. Resolve
// the name for the name-keyed reads/embed/display below.
$schoolNum = (int)($pendingRow['school_num'] ?? 0);
$school = $schoolNum > 0 ? (string)($store->schoolNameByNum($schoolNum) ?? '') : '';
// 2) Seller declined or Square reported an error.
if ($oauthError !== '') {
$desc = $_GET['error_description'] ?? $oauthError;
writeLog('OAuth error for school_num ' . $schoolNum . ': ' . $oauthError . ' — ' . $desc);
$store->updatePending($state, 'declined', ['last_callback' => time()]);
squareCallbackPage(false, 'Not connected', '<p>Square authorization was not completed: ' . htmlspecialchars((string)$desc) . '</p>');
}
if ($code === '' || $schoolNum <= 0) {
squareCallbackPage(false, 'Not connected', '<p>Missing authorization code. Please open Square setup on your school&#39;s site and click Connect again.</p>');
}
try {
$client = new SquareClient($config);
// 3) Exchange the authorization code for tokens (codes expire in 5 minutes).
$resp = $client->obtainToken($code, squareRedirectUri($config));
if ($resp['status'] < 200 || $resp['status'] >= 300 || empty($resp['body']['access_token'])) {
$detail = $resp['body']['errors'][0]['detail'] ?? ('HTTP ' . $resp['status']);
writeLog('Token exchange failed for ' . $school . ': ' . $detail);
$store->updatePending($state, 'token_exchange_failed', ['last_callback' => time()]);
squareCallbackPage(false, 'Not connected', '<p>Could not complete the connection with Square: ' . htmlspecialchars((string)$detail) . '</p><p>Please open Square setup on your school&#39;s site and click Connect again.</p>');
}
$body = $resp['body'];
$accessToken = $body['access_token'];
$refreshToken = $body['refresh_token'] ?? '';
$merchantId = $body['merchant_id'] ?? '';
$expiresAt = $body['expires_at'] ?? null; // ISO 8601
// 4) Persist immediately (tokens encrypted at rest, in the database). The
// connection MUST be recorded even if the readiness check below fails.
// Keyed by school_num so duplicate school names can't misroute the write.
$saved = $store->saveConnectionByNum($schoolNum, $merchantId, $accessToken, $refreshToken, $expiresAt);
writeLog('Merchant saved [callback] school_num ' . $schoolNum . ' (' . $school . ') => ' . $merchantId . ' (' . ($saved ? 'ok' : 'WRITE FAILED') . ')');
if (!$saved) {
squareCallbackPage(false, 'Not saved', '<p>Square approved the connection, but it could not be saved for ' . htmlspecialchars($school !== '' ? $school : ('school #' . $schoolNum)) . '. Please open Square setup on your school&#39;s site and click Connect again.</p>');
}
// 5) Mark the pending attempt connected.
$store->updatePending($state, 'connected', [
'merchant_id' => $merchantId,
'connected_at' => time(),
'last_callback' => time(),
]);
// 6) Record whether Square says this account can take payments right now
// (an ACTIVE location). That — not merely being connected — is what
// makes the "Pay with Square" button appear. Guarded so a failure never
// discards the connection we just saved; if the check fails, the button
// re-checks Square on its own (MerchantStore::isOnboarded).
$ready = false;
try {
$sellerStatus = $client->getSellerStatus($accessToken);
$ready = $sellerStatus['square_ready'] && !empty($sellerStatus['active_location_id']);
writeLog('Seller status [callback] ' . $school . ': square_ready=' . ($ready ? 'true' : 'false'));
if (!empty($sellerStatus['issues'])) {
writeLog('Seller status issues [callback] ' . $school . ': ' . implode(' | ', $sellerStatus['issues']));
}
$store->mergeMetaByNum($schoolNum, [
'square_ready' => $ready,
'active_location_id' => $sellerStatus['active_location_id'],
'merchant_name' => $sellerStatus['merchant_name'],
'currency' => $sellerStatus['currency'],
'country' => $sellerStatus['country'],
'status_issues' => $sellerStatus['issues'],
'status_checked_at' => time(),
]);
} catch (\Throwable $e) {
writeLog('Seller status check failed [callback] ' . $school . ': ' . $e->getMessage());
// Not ready yet; a null checked-at makes the next page view re-check Square.
$store->mergeMetaByNum($schoolNum, ['square_ready' => false, 'status_checked_at' => null]);
}
$schoolHtml = '<p style="font-weight:bold;color:#006aff;font-size:18px">' . htmlspecialchars($school !== '' ? $school : ('School #' . $schoolNum)) . '</p>';
if ($ready) {
$acct = !empty($sellerStatus['merchant_name']) ? ' (' . htmlspecialchars((string)$sellerStatus['merchant_name']) . ')' : '';
squareCallbackPage(true, 'Square account connected', $schoolHtml
. '<p>The <b>Pay with Square</b> button is now live for parents. Payments go directly to this school\'s Square account' . $acct . '.</p>', true);
}
$issues = '';
foreach (($sellerStatus['issues'] ?? []) as $issue) {
$issues .= '<li>' . htmlspecialchars((string)$issue) . '</li>';
}
squareCallbackPage(true, 'Connected — almost there', $schoolHtml
. '<p>Your Square account is connected, but Square says it can\'t take payments yet, so parents don\'t see the button. '
. 'It will appear automatically once this is fixed in your Square account.</p>'
. ($issues !== '' ? '<ul style="text-align:left">' . $issues . '</ul>' : ''));
} catch (\Throwable $e) {
writeLog('Square callback error [' . $school . ']: ' . $e->getMessage());
squareCallbackPage(false, 'Not connected', '<p>Something went wrong while connecting to Square. Please open Square setup on your school&#39;s site and click Connect again.</p>');
}
}
// Current Square status of THIS admin's school, for the page below.
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
$pageConfig = require __DIR__ . '/config.php';
$pageStore = new MerchantStore($pageConfig);
$pageSchoolNum = (int)($school_record['record_num'] ?? 0);
$pageMeta = $pageSchoolNum > 0 ? $pageStore->getMetaByNum($pageSchoolNum) : [];
$pageConnected = !empty($pageMeta['merchant_id']) && empty($pageMeta['authorization_revoked']);
$pageLive = $pageConnected && $pageStore->isOnboarded($pageSchoolNum, new SquareClient($pageConfig));
if ($pageLive !== !empty($pageMeta['square_ready'])) {
$pageMeta = $pageStore->getMetaByNum($pageSchoolNum); // isOnboarded re-checked Square
}
$pageIssues = is_array($pageMeta['status_issues'] ?? null) ? $pageMeta['status_issues'] : [];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>School Square Account 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, #006aff 0%, #003a8c 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: #006aff; }
.btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #006aff 0%, #003a8c 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(0, 106, 255, 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: #006aff; font-size: 18px; }
.alert-warning { background: #fff8e1; color: #7a5b00; border: 1px solid #ffe08a; }
.alert ul { margin: 6px 0 0 18px; }
.btn.secondary { background: #fff; color: #c53030; border: 2px solid #f5c6cb; margin-top: 10px; }
input[readonly] { background: #f5f7fa; color: #555; }
</style>
</head>
<body>
<div class="container">
<!-- Main Form -->
<h1>🏫 School Square Setup</h1>
<p class="subtitle">Connect your school's Square account</p>
<?php
$message = '';
$messageType = '';
if (isset($_GET['status']) && $_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; ?>
<?php if ($pageConnected && $pageLive): ?>
<div class="alert alert-success">
Connected to Square<?php echo !empty($pageMeta['merchant_name']) ? ' as <b>' . htmlspecialchars((string)$pageMeta['merchant_name']) . '</b>' : ''; ?>.
The <b>Pay with Square</b> button is live for parents.
</div>
<?php elseif ($pageConnected): ?>
<div class="alert alert-warning">
Connected to Square, but the account can't take payments yet, so parents don't see the button.
<?php if ($pageIssues): ?><ul><?php foreach ($pageIssues as $issue): ?><li><?php echo htmlspecialchars((string)$issue); ?></li><?php endforeach; ?></ul><?php endif; ?>
</div>
<?php endif; ?>
<form id="connectForm">
<?php if ($pageSchoolNum > 0): ?>
<!-- The school is always the one from the admin's own session; the
server ignores any school sent by the browser. -->
<div class="form-group">
<label for="school_name"><?php displayLanguageText('TEXT-SCHOOL-NAME'); ?></label>
<input type="text" id="school_name" value="<?php echo htmlspecialchars((string)($school_record['school_name'] ?? '')); ?>" readonly>
</div>
<?php else: ?>
<!-- No school context (platform SuperUser): name the school by its unique number. -->
<div class="form-group">
<label for="school_num">School number (record_num)</label>
<input type="text" id="school_num" name="school_num" inputmode="numeric" placeholder="e.g. 251" required>
</div>
<?php endif; ?>
<button type="submit" class="btn" id="connectBtn"><?php echo $pageConnected ? 'Reconnect Square Account' : 'Connect Square Account'; ?></button>
</form>
<?php if ($pageConnected): ?>
<button type="button" class="btn secondary" id="disconnectBtn">Disconnect Square</button>
<?php endif; ?>
</div>
<script>
var connectForm = document.getElementById('connectForm');
if (connectForm) connectForm.addEventListener('submit', function(e) {
e.preventDefault();
const btn = document.getElementById('connectBtn');
const schoolNumEl = document.getElementById('school_num');
const schoolNum = schoolNumEl ? schoolNumEl.value.trim() : '';
btn.disabled = true;
btn.textContent = 'Connecting...';
fetch('onboard.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'action=connect&school_num=' + encodeURIComponent(schoolNum)
})
.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 Square Account';
}
})
.catch(err => {
alert('Error: ' + err.message);
btn.disabled = false;
btn.textContent = 'Connect Square Account';
});
});
var disconnectBtn = document.getElementById('disconnectBtn');
if (disconnectBtn) disconnectBtn.addEventListener('click', function() {
if (!confirm('Disconnect Square? Parents will no longer see the Pay with Square button.')) return;
disconnectBtn.disabled = true;
fetch('onboard.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'action=disconnect'
})
.then(r => r.json())
.then(data => {
if (data.success) { window.location.href = 'onboard.php'; }
else { alert('Error: ' + (data.error || 'Disconnect failed')); disconnectBtn.disabled = false; }
})
.catch(err => { alert('Error: ' + err.message); disconnectBtn.disabled = false; });
});
</script>
</body>
</html>
+405
View File
@@ -0,0 +1,405 @@
<?php
/**
* Square Order API (buyer checkout endpoint)
* ──────────────────────────────────────────
* Public AJAX endpoint called by square-checkout-widget.php. It receives the
* single-use payment token (source_id) produced by the Web Payments SDK, looks
* up the school's seller access token, and charges the card with Square's
* Payments API so the money lands in THAT school's account (minus an optional
* app_fee_money platform cut, which lands in ours).
*
* POST body (JSON):
* { action: "create_payment", source_id, amount, currency, school_name,
* buyer_email, idempotency_key, verification_token }
*
* Response:
* success charge → { success:true, charged:true, payment_id, status }
* validate-only → { success:true, charged:false, stage:"validated", ... } (payments_enabled=false)
* handled failure → { success:false, error, code? }
*
* No admin auth — buyers are not logged-in staff. The endpoint only acts on
* shared connection data and never exposes tokens or card data.
*
* The charge logic lives in sq_handle_create_payment() so it can be unit-tested
* with a stubbed client; the HTTP dispatch at the bottom is skipped when the
* constant SQ_TESTING is defined.
*/
if (!defined('SQ_TESTING')) {
ob_start();
if (file_exists(__DIR__ . '/../include/logging-functions.php')) {
require_once __DIR__ . '/../include/logging-functions.php';
if (function_exists('startLog')) startLog('square-order-api');
}
ob_end_clean();
header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
// Same session as the host app / checkout.php so first-party amounts cannot
// be changed from the browser. Harmless for embed callers with no session.
if (session_status() !== PHP_SESSION_ACTIVE) {
@session_name('hotlunch');
@session_start();
}
// ── CORS ─────────────────────────────────────────────────────────────────
// This endpoint is called cross-origin from each school's own website (via
// embed.js → pay-frame.php). We reflect the requesting Origin so the browser
// permits the fetch; the actual per-merchant origin allowlist (when enabled)
// is enforced in sq_handle_create_payment() against the resolved school.
$sqOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($sqOrigin !== '') {
header('Access-Control-Allow-Origin: ' . $sqOrigin);
header('Vary: Origin');
}
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
header('Access-Control-Max-Age: 86400');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204);
exit();
}
}
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
function sqOut(array $payload): void { echo json_encode($payload); exit(); }
function sqLog(string $msg): void { if (function_exists('writeLog')) writeLog($msg); }
/**
* Book a completed Square charge into user_account (same shape as PayPal).
* Idempotent on payment_id. Uses the host db_* layer.
*/
function sq_recordLedgerPayment(int $schoolNum, int $userNum, string $paymentId, float $amount): array
{
if ($schoolNum <= 0 || $userNum <= 0 || $paymentId === '' || $amount <= 0) {
return ['posted' => false, 'reason' => 'missing school/user/payment_id/amount'];
}
if (!function_exists('db_query')) {
return ['posted' => false, 'reason' => 'db_layer_unavailable'];
}
$pid = function_exists('db_escape_string') ? db_escape_string($paymentId) : $paymentId;
$sn = (int)$schoolNum;
$un = (int)$userNum;
$existing = db_query(
"SELECT record_num FROM user_account
WHERE school_num = $sn AND payment_reference = '$pid' AND transaction_type = 'Payment'
LIMIT 1"
);
if ($existing && db_num_rows($existing) > 0) {
return ['posted' => true, 'reason' => 'already_posted'];
}
$now = time();
$dt = date('Y-m-d H:i:s', $now);
$amt = -1 * abs($amount);
$note = function_exists('db_escape_string')
? db_escape_string('Square payment ' . $paymentId)
: ('Square payment ' . $paymentId);
$ok = db_query(
"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
($sn, $un, 'Payment', $amt, 'Closed',
'$dt', $now, 'Square', '$pid', '$note',
0, 0, $now, $un, 'N')"
);
if ($ok === false) {
return ['posted' => false, 'reason' => 'insert_failed'];
}
db_query(
"UPDATE user_online_payments SET status = 'Completed'
WHERE school_num = $sn AND user_num = $un AND status = 'Pending'
ORDER BY record_num DESC LIMIT 1"
);
$bal = 0.0;
if (function_exists('db_getVal')) {
$bal = (float)db_getVal(
"SELECT COALESCE(SUM(transaction_amount),0) AS bal FROM user_account
WHERE school_num = $sn AND user_num = $un AND (deleted != 'Y' OR deleted IS NULL)"
);
}
db_query(
"UPDATE users SET balance_owing = $bal, account_balance = $bal
WHERE school_num = $sn AND record_num = $un"
);
return ['posted' => true, 'reason' => 'posted'];
}
/**
* Validate and (when enabled) charge a payment. Returns a response array; does
* not echo or exit, so it is safe to call from tests.
*/
function sq_handle_create_payment(array $input, array $config, SquareClient $client, MerchantStore $store): array
{
$sourceId = trim($input['source_id'] ?? '');
$amount = trim((string)($input['amount'] ?? ''));
$currency = strtoupper(trim($input['currency'] ?? 'CAD'));
$schoolName = trim($input['school_name'] ?? '');
$schoolNum = (int)($input['school_num'] ?? 0);
$merchantKey = trim($input['merchant_key'] ?? '');
$idempotency = trim($input['idempotency_key'] ?? '');
$buyerEmail = trim($input['buyer_email'] ?? '');
$verifyToken = trim($input['verification_token'] ?? '');
$userNum = (int)($input['user_num'] ?? 0);
// Prefer the public embed key (external-site embeds send this); fall back to
// school_num (first-party checkout) then school name. The key is unique.
if ($merchantKey !== '') {
$found = $store->findByKey($merchantKey);
if ($found !== null) {
if ($found['school_name'] !== '') $schoolName = $found['school_name'];
if ($schoolNum <= 0 && $found['record_num'] > 0) $schoolNum = $found['record_num'];
}
}
if ($schoolNum > 0 && $schoolName === '') {
$schoolName = (string)($store->schoolNameByNum($schoolNum) ?? '');
}
// ── Validate the request shape ───────────────────────────────────────────
if ($sourceId === '' || ($schoolName === '' && $schoolNum <= 0)) {
return ['success' => false, 'error' => 'source_id and a valid merchant_key (or school) are required'];
}
if (!is_numeric($amount) || (float)$amount <= 0) {
return ['success' => false, 'error' => 'Invalid amount'];
}
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
return ['success' => false, 'error' => 'Invalid currency'];
}
// ── Validate the school can be charged ───────────────────────────────────
$meta = $schoolNum > 0 ? $store->getMetaByNum($schoolNum) : $store->getMeta($schoolName);
$merchantId = $meta['merchant_id'] ?? null;
if (!$merchantId || !empty($meta['authorization_revoked'])) {
return ['success' => false, 'error' => 'This school is not connected to Square'];
}
// ── Optional per-merchant embed-origin allowlist ─────────────────────────
// When enforce_embed_origins is on AND the school has registered origins,
// reject charges initiated from any other site (anti-impersonation). A
// same-origin/first-party call (no Origin header) is always allowed.
if (!empty($config['enforce_embed_origins'])) {
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed = $store->allowedOrigins($schoolName);
if ($origin !== '' && !empty($allowed) && !in_array($origin, $allowed, true)) {
sqLog('create_payment BLOCKED origin=' . $origin . ' school=' . $schoolName);
return ['success' => false, 'error' => 'This payment button is not authorized for this website.'];
}
}
if (!$store->isOnboarded($schoolNum > 0 ? $schoolNum : (int)($store->schoolNum($schoolName) ?? 0), $client)) {
return ['success' => false, 'error' => 'This school cannot accept card payments at this time'];
}
$locationId = $store->locationId($schoolName, $client, $schoolNum > 0 ? $schoolNum : null);
if (!$locationId) {
return ['success' => false, 'error' => 'This school has no active Square location'];
}
// Prefer the school's Square currency when the request didn't send a real one.
if ($currency === '' || $currency === 'CAD') {
$metaCur = strtoupper((string)($meta['currency'] ?? ''));
if (preg_match('/^[A-Z]{3}$/', $metaCur) && ($input['currency'] ?? '') === '') {
$currency = $metaCur;
}
}
// Square amounts are integer minor units (cents). CAD/USD = 2 decimals.
// (Zero-decimal currencies like JPY would need special handling.)
$amountCents = (int) round((float)$amount * 100);
if ($idempotency === '') $idempotency = bin2hex(random_bytes(16));
sqLog('create_payment [validated] school=' . $schoolName . ' amount_cents=' . $amountCents
. ' currency=' . $currency . ' location=' . $locationId . ' src=' . substr($sourceId, 0, 8) . '…');
// Master switch: validate-only mode when live charging is turned off.
if (empty($config['payments_enabled'])) {
return [
'success' => true,
'charged' => false,
'stage' => 'validated',
'message' => 'Payments are currently disabled on the server.',
'amount_cents' => $amountCents,
'currency' => $currency,
'location_id' => $locationId,
];
}
// ── Look up the school's access token (auto-refreshes near expiry). ──────
// This seller token is what routes the money into THAT school's account.
$accessToken = $store->ensureFreshToken($schoolName, $client, $schoolNum > 0 ? ['school_num' => $schoolNum] : [])['access_token'] ?? null;
if (!$accessToken) {
sqLog('create_payment: no usable access token for ' . $schoolName);
return ['success' => false, 'error' => 'This school is not connected to Square'];
}
// ── Build the charge payload. ────────────────────────────────────────────
$payload = [
'source_id' => $sourceId,
'idempotency_key' => substr($idempotency, 0, 45), // Square allows 1–45 chars
'amount_money' => ['amount' => $amountCents, 'currency' => $currency],
'location_id' => $locationId,
'autocomplete' => true, // capture immediately
'note' => 'Payment to ' . substr($schoolName, 0, 480),
];
if ($buyerEmail !== '' && filter_var($buyerEmail, FILTER_VALIDATE_EMAIL)) {
$payload['buyer_email_address'] = substr($buyerEmail, 0, 255);
}
if ($verifyToken !== '') {
$payload['verification_token'] = $verifyToken; // 3DS/SCA result (optional)
}
// ── Optional platform fee (app_fee_money → our account). ─────────────────
$feeCfg = $config['platform_fee'] ?? [];
$appFeeCents = 0;
if (!empty($feeCfg['enabled'])) {
$appFeeCents = (int) round($amountCents * ((float)($feeCfg['percentage'] ?? 0)) / 100)
+ (int)($feeCfg['fixed_cents'] ?? 0);
$cap = (int) floor($amountCents * 0.9); // Square caps app fee at 90% of the amount
if ($appFeeCents > $cap) {
sqLog('create_payment: app_fee capped from ' . $appFeeCents . ' to ' . $cap);
$appFeeCents = $cap;
}
if ($appFeeCents > 0) {
$payload['app_fee_money'] = ['amount' => $appFeeCents, 'currency' => $currency];
}
}
// ── Charge. ──────────────────────────────────────────────────────────────
$resp = $client->createPayment($accessToken, $payload);
$payment = $resp['body']['payment'] ?? [];
$status = $payment['status'] ?? '';
if ($resp['status'] >= 200 && $resp['status'] < 300 && in_array($status, ['COMPLETED', 'APPROVED'], true)) {
$paymentId = $payment['id'] ?? null;
$store->recordPayment([
'payment_id' => $paymentId,
'school' => $schoolName,
'school_num' => $schoolNum > 0 ? $schoolNum : null,
'merchant_id' => $merchantId,
'amount_cents' => $amountCents,
'currency' => $currency,
'app_fee_cents' => $appFeeCents,
'status' => $status,
'source_id_suffix' => substr($sourceId, -4),
]);
$ledger = ['posted' => false, 'reason' => 'skipped'];
if ($userNum > 0) {
$sn = $schoolNum > 0 ? $schoolNum : (int)($store->schoolNum($schoolName) ?? 0);
$ledger = sq_recordLedgerPayment($sn, $userNum, (string)$paymentId, $amountCents / 100);
sqLog('create_payment ledger school=' . $schoolName . ' user=' . $userNum
. ' posted=' . ($ledger['posted'] ? 'yes' : 'no') . ' reason=' . $ledger['reason']);
}
sqLog('create_payment SUCCESS school=' . $schoolName . ' payment_id=' . $paymentId
. ' status=' . $status . ' amount_cents=' . $amountCents . ' app_fee_cents=' . $appFeeCents);
return [
'success' => true,
'charged' => true,
'payment_id' => $paymentId,
'status' => $status,
'ledger' => $ledger['posted'],
];
}
// ── Failure — log the real reason, return a parent-friendly message. ─────
$err = $resp['body']['errors'][0] ?? [];
$code = $err['code'] ?? 'PAYMENT_FAILED';
$detail = $err['detail'] ?? 'Payment could not be completed.';
sqLog('create_payment FAILED school=' . $schoolName . ' http=' . $resp['status']
. ' code=' . $code . ' detail=' . $detail);
// Card/method errors carry buyer-safe detail; everything else stays generic.
$friendly = (($err['category'] ?? '') === 'PAYMENT_METHOD_ERROR')
? $detail
: 'Your payment could not be completed. Please try another card.';
// A 4xx is Square's definitive "no payment was made"; anything else (5xx,
// or an idempotent replay) keeps the same key so a retry cannot double-charge.
$rotateKey = $resp['status'] >= 400 && $resp['status'] < 500 && $code !== 'IDEMPOTENCY_KEY_REUSED';
return ['success' => false, 'error' => $friendly, 'code' => $code, 'rotate_key' => $rotateKey];
}
// ── HTTP dispatch (skipped under test) ───────────────────────────────────────
if (!defined('SQ_TESTING')) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
sqOut(['success' => false, 'error' => 'Method not allowed']);
}
// Accept JSON body or form-encoded.
$input = json_decode(file_get_contents('php://input'), true);
if (!is_array($input)) $input = $_POST;
$action = trim($input['action'] ?? '');
if ($action === '') {
sqOut(['success' => false, 'error' => 'action required']);
}
// First-party checkout: amount/school/user come from the session written by
// square_button.php — never from the browser. Embed callers have no session.
$ctx = $_SESSION['sq_checkout'] ?? null;
$ctxValid = is_array($ctx)
&& (float)($ctx['total_amount'] ?? 0) > 0
&& (int)($ctx['school_num'] ?? 0) > 0
&& (time() - (int)($ctx['created_at'] ?? 0)) < 3600;
if ($ctxValid) {
$input['amount'] = $ctx['total_amount'];
$input['currency'] = $ctx['currency'] ?? ($input['currency'] ?? 'CAD');
$input['school_num'] = (int)$ctx['school_num'];
$input['school_name'] = $ctx['school_name'] ?? ($input['school_name'] ?? '');
$input['user_num'] = (int)($ctx['user_num'] ?? 0);
$input['merchant_key'] = '';
if (!empty($ctx['idempotency_key'])) {
$input['idempotency_key'] = $ctx['idempotency_key'];
}
if (trim((string)($input['buyer_email'] ?? '')) === '' && !empty($ctx['buyer_email'])) {
$input['buyer_email'] = $ctx['buyer_email'];
}
} else {
// No first-party checkout in this session: only an external-site embed
// (identified by its public merchant_key) may charge, and the browser can
// never choose the school or book the payment to a parent's account.
if (trim((string)($input['merchant_key'] ?? '')) === '') {
sqOut(['success' => false, 'error' => 'This checkout has expired or was already paid. Please go back to your account and try again.']);
}
$input['school_num'] = 0;
$input['school_name'] = '';
$input['user_num'] = 0;
}
$config = require __DIR__ . '/config.php';
try {
$store = new MerchantStore($config);
$client = new SquareClient($config);
if ($action === 'create_payment') {
$result = sq_handle_create_payment($input, $config, $client, $store);
if ($ctxValid) {
if (!empty($result['charged'])) {
// Paid: close this checkout so Back / refresh / a second tab
// cannot charge the same balance again.
unset($_SESSION['sq_checkout']);
} elseif (!empty($result['rotate_key'])) {
// Square definitively declined (no money moved): allow a retry,
// e.g. with another card, under a fresh idempotency key.
$_SESSION['sq_checkout']['idempotency_key'] = 'hl-' . bin2hex(random_bytes(16));
}
}
unset($result['rotate_key']);
sqOut($result);
}
sqOut(['success' => false, 'error' => 'Unknown action: ' . htmlspecialchars($action)]);
} catch (Throwable $e) {
sqLog('square-order-api exception: ' . $e->getMessage());
sqOut(['success' => false, 'error' => 'Server error processing payment']);
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
/**
* Embeddable payment frame (the cross-site "Pay with Square" surface).
* ────────────────────────────────────────────────────────────────────
* This page is loaded INSIDE an <iframe> on a school's own external website by
* embed.js. It resolves the school from its public embed key, renders the
* existing Web Payments widget, and auto-reports its height to the parent page
* so the iframe can size itself with no scrollbars.
*
* Query params:
* key (required) the school's public merchant_key (sqpk_…)
* amount (required) major-units amount, e.g. 25.00
* currency (optional) ISO-4217, default the school's onboarding currency/CAD
* email (optional) buyer email for the receipt
* success (optional) URL to redirect to after a successful charge
*
* Because this is meant to be framed, it deliberately does NOT send
* X-Frame-Options. When enforce_embed_origins is on and the school has
* registered origins, we additionally restrict framing via CSP frame-ancestors.
*/
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
$config = require __DIR__ . '/config.php';
$store = new MerchantStore($config);
$key = isset($_GET['key']) ? trim((string)$_GET['key']) : '';
$amount = isset($_GET['amount']) && is_numeric($_GET['amount']) ? number_format((float)$_GET['amount'], 2, '.', '') : '';
$currency = isset($_GET['currency']) ? strtoupper(preg_replace('/[^A-Za-z]/', '', (string)$_GET['currency'])) : '';
$email = isset($_GET['email']) ? trim((string)$_GET['email']) : '';
$success = isset($_GET['success']) ? trim((string)$_GET['success']) : '';
if (!preg_match('#^(https?://|/)#i', $success)) {
$success = ''; // only real web URLs (blocks javascript:/data: redirects)
}
$school = $store->findSchoolByKey($key);
// Restrict who may frame us when origin enforcement is enabled.
if (!empty($config['enforce_embed_origins']) && $school !== null) {
$allowed = $store->allowedOrigins($school);
if (!empty($allowed)) {
header("Content-Security-Policy: frame-ancestors 'self' " . implode(' ', $allowed));
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pay</title>
<style>
html, body { margin:0; padding:0; background:transparent; }
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
.sq-frame-pad { padding:2px; }
</style>
</head>
<body>
<div class="sq-frame-pad">
<?php
if ($school === null) {
echo '<div style="background:#fff5f5;border:1px solid #feb2b2;color:#c53030;'
. 'border-radius:10px;padding:14px;font-size:13px;">'
. 'This payment button is not configured. (Unknown or removed key.)</div>';
} elseif ($amount === '') {
echo '<div style="background:#fff5f5;border:1px solid #feb2b2;color:#c53030;'
. 'border-radius:10px;padding:14px;font-size:13px;">Invalid payment amount.</div>';
} else {
$sqCheckout = [
'school_name' => $school,
'merchant_key' => $key,
'amount' => $amount,
'currency' => $currency ?: '', // widget falls back to onboarding currency/CAD
'order_api_url' => './order-api.php',
'success_url' => $success,
'buyer_email' => $email,
];
require __DIR__ . '/square-checkout-widget.php';
}
?>
</div>
<script>
// Report our content height to the embedding page so embed.js can size the
// iframe. Posts on load, on resize, and whenever the widget mutates (e.g. a
// status message appears). The parent matches on the {sqPayFrame:true} marker.
(function () {
function postHeight() {
var h = Math.max(
document.body.scrollHeight,
document.documentElement.scrollHeight,
document.body.offsetHeight
);
try {
window.parent.postMessage({ sqPayFrame: true, type: 'height', height: h }, '*');
} catch (e) {}
}
window.addEventListener('load', postHeight);
window.addEventListener('resize', postHeight);
if (window.MutationObserver) {
new MutationObserver(postHeight).observe(document.body, {
childList: true, subtree: true, attributes: true, characterData: true
});
}
// A couple of delayed posts catch the async SDK card-form mount.
setTimeout(postHeight, 600);
setTimeout(postHeight, 1500);
}());
</script>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
<?php
/**
* Square token-refresh cron job (Phase 2)
* ───────────────────────────────────────
* Square access tokens expire after 30 days; refresh tokens (code flow) do not
* expire. This sweep refreshes every connected school's access token while it
* still has plenty of runway, so a token is never allowed to lapse and break
* payments.
*
* It is also self-healing for the web side: helper.php refreshes lazily on use,
* but a school that hasn't transacted in a month would otherwise drift toward
* expiry — this job covers them.
*
* USAGE (CLI only — refuses to run over HTTP):
* php refresh_tokens.php # refresh tokens expiring within 5 days
* php refresh_tokens.php --days=10 # widen the window
* php refresh_tokens.php --force # refresh ALL connected schools now
* php refresh_tokens.php --dry-run # report what WOULD happen, change nothing
* php refresh_tokens.php --school="Name"# limit to one school
*
* CRON — Square recommends renewing access tokens every 7 days or less, so the
* RECOMMENDED schedule is a weekly --force sweep (rotates every token weekly):
* 30 3 * * 0 /usr/bin/php /path/to/square/refresh_tokens.php --force >> /var/log/square_refresh.log 2>&1
* Optionally add a daily threshold sweep as a safety net against any lapse:
* 15 3 * * * /usr/bin/php /path/to/square/refresh_tokens.php --days=7 >> /var/log/square_refresh.log 2>&1
*
* Exit codes: 0 = all good (or dry-run), 1 = one or more refreshes failed.
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
header('Content-Type: text/plain');
echo "refresh_tokens.php is a CLI script and must not be run over HTTP.\n";
exit(1);
}
// Optional app logging, if these helpers are on the include path. Wrapped so the
// script also runs standalone (e.g. from a bare cron environment).
if (file_exists(__DIR__ . '/../include/logging-functions.php')) {
require_once __DIR__ . '/../include/logging-functions.php';
if (function_exists('startLog')) startLog('square-refresh-cron');
}
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
$config = require __DIR__ . '/config.php';
// ── Parse flags ──────────────────────────────────────────────────────────────
$opts = getopt('', ['days::', 'force', 'dry-run', 'school::', 'help']);
if (isset($opts['help'])) {
fwrite(STDOUT, "Usage: php refresh_tokens.php [--days=N] [--force] [--dry-run] [--school=\"Name\"]\n");
exit(0);
}
$thresholdDays = isset($opts['days']) ? max(0, (int)$opts['days']) : 5;
$force = isset($opts['force']);
$dryRun = isset($opts['dry-run']);
$onlySchool = isset($opts['school']) ? (string)$opts['school'] : null;
$ts = date('c');
fwrite(STDOUT, "[$ts] Square token refresh starting"
. " (days=$thresholdDays" . ($force ? ", force" : "") . ($dryRun ? ", dry-run" : "") . ($onlySchool ? ", school=\"$onlySchool\"" : "") . ")\n");
try {
$client = new SquareClient($config);
$store = new MerchantStore($config);
} catch (Throwable $e) {
fwrite(STDERR, "FATAL: " . $e->getMessage() . "\n");
exit(1);
}
// record_num => name. Keyed by the unique record_num so same-named schools are
// each refreshed (a name-keyed sweep would refresh only the first of them).
$schools = $store->connectedSchoolNums();
if ($onlySchool !== null) {
$schools = array_filter($schools, fn($name) => $name === $onlySchool);
}
if (empty($schools)) {
fwrite(STDOUT, "No connected schools to process.\n");
exit(0);
}
$counts = ['refreshed' => 0, 'skipped' => 0, 'would_refresh' => 0, 'failed' => 0, 'no_refresh_token' => 0, 'not_connected' => 0];
foreach ($schools as $schoolNum => $school) {
$result = $store->ensureFreshToken($school, $client, [
'threshold_days' => $thresholdDays,
'force' => $force,
'dry_run' => $dryRun,
'school_num' => $schoolNum,
]);
$action = $result['action'] ?? 'failed';
$counts[$action] = ($counts[$action] ?? 0) + 1;
$line = sprintf(" %-16s %s (#%d)", $action, $school, $schoolNum);
if (!empty($result['expires_at'])) $line .= " (expires {$result['expires_at']})";
if (!empty($result['detail'])) $line .= " — {$result['detail']}";
fwrite(STDOUT, $line . "\n");
}
$ts = date('c');
fwrite(STDOUT, "[$ts] Done. "
. "refreshed={$counts['refreshed']} skipped={$counts['skipped']} "
. "would_refresh={$counts['would_refresh']} failed={$counts['failed']} "
. "no_refresh_token={$counts['no_refresh_token']} not_connected={$counts['not_connected']}\n");
// Non-zero exit if anything failed, so cron / monitoring can alert.
exit($counts['failed'] > 0 ? 1 : 0);
+84
View File
@@ -0,0 +1,84 @@
-- Square onboarding — database schema.
--
-- ALL Square state now lives in the database (no JSON files), persisted through
-- the host application's procedural db_* layer (../include/database.php) — the
-- same data layer the saving/ API uses. This folder's db_config.php is NO LONGER
-- used; the real credentials live in the host app.
--
-- Run once against the hotlunch database. Safe to re-run: ADD COLUMN IF NOT
-- EXISTS / CREATE TABLE IF NOT EXISTS are guarded (MySQL 8 / MariaDB 10.5+).
-- ─────────────────────────────────────────────────────────────────────────────
-- 1) Per-school Square connection + metadata, all on the existing `schools` row.
-- Previously the tokens were here but the metadata (merchant_key, location,
-- currency, readiness, …) lived only in data/square_merchants.json. It is now
-- fully columnar so nothing is stored outside the database.
-- ─────────────────────────────────────────────────────────────────────────────
ALTER TABLE schools
ADD COLUMN IF NOT EXISTS square_merchant_id VARCHAR(64) NULL,
ADD COLUMN IF NOT EXISTS square_access_token TEXT NULL, -- AES-256-GCM ciphertext (enc:v1:...)
ADD COLUMN IF NOT EXISTS square_refresh_token TEXT NULL, -- AES-256-GCM ciphertext (enc:v1:...)
ADD COLUMN IF NOT EXISTS square_token_expires_at DATETIME NULL, -- access token expiry (ISO 8601 from Square)
ADD COLUMN IF NOT EXISTS square_connected_at DATETIME NULL,
ADD COLUMN IF NOT EXISTS square_merchant_key VARCHAR(64) NULL, -- public embed key (sqpk_…)
ADD COLUMN IF NOT EXISTS square_location_id VARCHAR(64) NULL, -- active location for Web Payments SDK
ADD COLUMN IF NOT EXISTS square_currency VARCHAR(3) NULL,
ADD COLUMN IF NOT EXISTS square_country VARCHAR(2) NULL,
ADD COLUMN IF NOT EXISTS square_merchant_name VARCHAR(255) NULL,
ADD COLUMN IF NOT EXISTS square_ready TINYINT(1) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS square_status_issues TEXT NULL, -- readiness issues, one per line
ADD COLUMN IF NOT EXISTS square_allowed_origins TEXT NULL, -- embed origin allowlist, one per line
ADD COLUMN IF NOT EXISTS square_status_checked_at BIGINT NULL, -- unix time of last readiness check
ADD COLUMN IF NOT EXISTS square_refreshed_at BIGINT NULL, -- unix time of last token refresh
ADD COLUMN IF NOT EXISTS square_refresh_failed_at BIGINT NULL,
ADD COLUMN IF NOT EXISTS square_refresh_error TEXT NULL,
ADD COLUMN IF NOT EXISTS square_authorization_revoked TINYINT(1) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS square_revoked_at BIGINT NULL,
ADD COLUMN IF NOT EXISTS square_seeded_for_testing TINYINT(1) NOT NULL DEFAULT 0;
-- Speed up lookups by merchant id (webhooks) and by public embed key (checkout).
CREATE INDEX idx_schools_square_merchant ON schools (square_merchant_id);
CREATE INDEX idx_schools_square_key ON schools (square_merchant_key);
-- ─────────────────────────────────────────────────────────────────────────────
-- 2) Payment records (replaces data/square_payments.json). Keyed by the Square
-- payment id so a retried idempotent charge updates its row instead of
-- duplicating it. The school is referenced by its numeric id schools.record_num,
-- NOT by name — indexed for fast per-school lookups. (The column is named
-- `school_num` for historical reasons but stores schools.record_num.)
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS square_payments (
record_num INT NOT NULL AUTO_INCREMENT,
payment_id VARCHAR(64) NOT NULL,
school_num BIGINT NULL, -- stores schools.record_num
merchant_id VARCHAR(64) NULL,
amount_cents INT NOT NULL DEFAULT 0,
currency VARCHAR(3) NOT NULL DEFAULT 'CAD',
app_fee_cents INT NOT NULL DEFAULT 0,
status VARCHAR(32) NULL,
source_id_suffix VARCHAR(8) NULL,
recorded_at BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (record_num),
UNIQUE KEY uq_square_payment_id (payment_id),
KEY idx_square_payments_school_num (school_num)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─────────────────────────────────────────────────────────────────────────────
-- 3) Pending OAuth onboarding attempts (replaces data/pending_onboarding.json).
-- Keyed by the anti-CSRF `state` token echoed back by Square on the callback.
-- The school is referenced by its UNIQUE numeric id schools.record_num (the
-- schools table's primary key — there is no `school_num` column on it), NOT by
-- name: school names are not unique across clients, so the callback must reattach
-- the connection to the exact school that began onboarding. The column below is
-- named `school_num` for historical reasons but stores schools.record_num.
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS square_pending_onboarding (
state VARCHAR(64) NOT NULL,
school_num BIGINT NOT NULL, -- stores schools.record_num
status VARCHAR(32) NOT NULL DEFAULT 'pending', -- pending|connected|declined|token_exchange_failed
merchant_id VARCHAR(64) NULL,
created_at BIGINT NOT NULL DEFAULT 0,
connected_at BIGINT NULL,
last_callback BIGINT NULL,
PRIMARY KEY (state)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+122
View File
@@ -0,0 +1,122 @@
<?php
/**
* Seed a Square connection for LOCAL TESTING — bypasses OAuth onboarding so you
* can test the "Pay with Square" widget + charge (Phase 3/4) without a public
* URL or the host app.
*
* It VALIDATES the access token against Square (GET /v2/locations) and
* auto-resolves the location + currency + merchant id, so you only need the
* token. Pass --location to override the auto-pick.
*
* Get the token from: Developer Dashboard → open your app → toggle to SANDBOX →
* Credentials → "Sandbox Access Token" (the EAAA… value). Use the SAME
* application whose Sandbox Application ID is your client_id in config.php.
*
* USAGE (CLI only):
* php seed_test_connection.php --school="Test School" --token=EAAA...
* php seed_test_connection.php --school="Test School" --token=EAAA... --location=L123 --currency=CAD
*
* Then: php -S 127.0.0.1:8000 and open the URL it prints.
*
* ⚠️ Sandbox only. Stored ENCRYPTED with your config token_encryption_key (set
* one first, or the token is stored in plaintext).
*/
if (PHP_SAPI !== 'cli') { http_response_code(403); echo "CLI only.\n"; exit(1); }
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
$config = require __DIR__ . '/config.php';
$opts = getopt('', ['school:', 'token:', 'location::', 'merchant::', 'currency::', 'help']);
if (isset($opts['help']) || empty($opts['school']) || empty($opts['token'])) {
fwrite(STDOUT, "Usage: php seed_test_connection.php --school=\"Name\" --token=EAAA... [--location=L123] [--currency=CAD]\n");
exit(isset($opts['help']) ? 0 : 1);
}
if (($config['env'] ?? 'sandbox') !== 'sandbox') {
fwrite(STDERR, "Refusing to run: config env is not 'sandbox'. This helper is for sandbox testing only.\n");
exit(1);
}
if (empty($config['token_encryption_key'])) {
fwrite(STDERR, "WARNING: token_encryption_key is not set in config.php — the token will be stored in PLAINTEXT.\n");
}
$school = (string)$opts['school'];
$token = trim((string)$opts['token']);
$client = new SquareClient($config);
// ── Validate the token + discover locations. ────────────────────────────────
fwrite(STDOUT, "Checking token against " . $client->getConnectBase() . " …\n");
$resp = $client->api('GET', '/v2/locations', $token);
if ($resp['status'] === 401) {
fwrite(STDERR, "\n❌ HTTP 401 Unauthorized — Square rejected this access token.\n"
. " Fix: Developer Dashboard → open your app → toggle to SANDBOX → Credentials →\n"
. " copy the \"Sandbox Access Token\" (starts with EAAA…). Make sure:\n"
. " • you copied the SANDBOX token (not Production), and the whole string,\n"
. " • it's from the SAME app as your client_id (" . $config['client_id'] . "),\n"
. " • the token hasn't been rotated since you copied it.\n");
exit(1);
}
if ($resp['status'] < 200 || $resp['status'] >= 300) {
fwrite(STDERR, "\n❌ GET /v2/locations failed (HTTP " . $resp['status'] . "): "
. json_encode($resp['body']['errors'] ?? $resp['body']) . "\n");
exit(1);
}
$locations = $resp['body']['locations'] ?? [];
if (empty($locations)) {
fwrite(STDERR, "\n❌ Token is valid but the seller has no locations. Create one in the sandbox seller dashboard.\n");
exit(1);
}
// ── Pick a location. ─────────────────────────────────────────────────────────
$wanted = isset($opts['location']) ? (string)$opts['location'] : '';
$chosen = null;
foreach ($locations as $l) {
if ($wanted !== '') { if (($l['id'] ?? '') === $wanted) { $chosen = $l; break; } continue; }
$active = ($l['status'] ?? '') === 'ACTIVE';
$card = in_array('CREDIT_CARD_PROCESSING', $l['capabilities'] ?? [], true);
if ($active && $card) { $chosen = $l; break; } // best: active + can take cards
if ($active && $chosen === null) { $chosen = $l; } // fallback: any active
}
if ($chosen === null) $chosen = $locations[0];
if ($wanted !== '' && ($chosen['id'] ?? '') !== $wanted) {
fwrite(STDERR, "\n❌ Location '$wanted' not found for this token. Available: "
. implode(', ', array_map(fn($l) => $l['id'] ?? '?', $locations)) . "\n");
exit(1);
}
$locationId = $chosen['id'];
$currency = strtoupper((string)($opts['currency'] ?? ($chosen['currency'] ?? 'CAD')));
$canCards = in_array('CREDIT_CARD_PROCESSING', $chosen['capabilities'] ?? [], true);
// merchant id: --merchant, else GET /v2/merchants, else a placeholder.
$merchantId = (string)($opts['merchant'] ?? '');
if ($merchantId === '') {
$m = $client->api('GET', '/v2/merchants', $token);
$merchantId = $m['body']['merchant'][0]['id'] ?? 'SANDBOX_TEST_MERCHANT';
}
// ── Store it. ────────────────────────────────────────────────────────────────
$store = new MerchantStore($config);
$store->saveConnection($school, $merchantId, $token, '', gmdate('Y-m-d\TH:i:s\Z', time() + 365 * 86400));
$store->mergeMeta($school, [
'active_location_id' => $locationId,
'square_ready' => true,
'currency' => $currency,
'seeded_for_testing' => true,
'status_checked_at' => time(),
]);
fwrite(STDOUT, "\n✅ Seeded connection for \"$school\"\n");
fwrite(STDOUT, " merchant_id : $merchantId\n");
fwrite(STDOUT, " location_id : $locationId (" . ($chosen['status'] ?? '?') . ($canCards ? ", card-capable" : ", NO card capability") . ")\n");
fwrite(STDOUT, " currency : $currency\n");
fwrite(STDOUT, " token : stored " . (empty($config['token_encryption_key']) ? "(PLAINTEXT)" : "encrypted") . "\n");
if (!$canCards) {
fwrite(STDOUT, "\n⚠️ This location has no CREDIT_CARD_PROCESSING capability — card charges may fail.\n");
}
fwrite(STDOUT, "\nNext: php -S 127.0.0.1:8000 then open\n");
fwrite(STDOUT, " http://127.0.0.1:8000/demo-checkout.php?school=" . rawurlencode($school) . "\n");
exit(0);
+313
View File
@@ -0,0 +1,313 @@
<?php
/**
* Square "Pay with Square" Checkout Widget (Phase 3)
* ──────────────────────────────────────────────────
* Drop-in include for any school payment page. Loads the Web Payments SDK with
* the platform appId + that school's location_id, renders a card form, tokenizes
* the card in the browser (card data NEVER touches our server), then POSTs
* { sourceId, amount, school_name } to order-api.php.
*
* Usage — set $sqCheckout before including this file:
*
* $sqCheckout = [
* 'school_name' => 'Springfield Elementary', // required
* 'amount' => '25.00', // required (string, major units)
* 'currency' => 'CAD', // optional, default from onboarding/CAD
* 'order_api_url' => '/square/order-api.php', // URL to order-api.php
* 'success_url' => '/payment-success', // redirect after a successful charge (Phase 4)
* 'buyer_email' => 'parent@school.com', // optional, for receipts (Phase 4)
* ];
* require_once '/path/to/square/square-checkout-widget.php';
*
* The widget resolves the school's Square connection server-side; if the school
* is not connected / not ready, it renders a small muted notice and stops.
*/
if (!isset($sqCheckout) || !is_array($sqCheckout)) {
throw new RuntimeException('$sqCheckout array must be set before including square-checkout-widget.php');
}
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
$_sqConfig = require __DIR__ . '/config.php';
$_sqSchool = (string)($sqCheckout['school_name'] ?? '');
$_sqAmount = (string)($sqCheckout['amount'] ?? '');
$_sqSchoolNum = (int)($sqCheckout['school_num'] ?? 0);
// ── Resolve the school's Square connection server-side ────────────────────────
$_sqAppId = $_sqConfig['web_payments_app_id'] ?: $_sqConfig['client_id'];
$_sqEnv = $_sqConfig['env'] ?? 'sandbox';
$_sqStore = new MerchantStore($_sqConfig);
$_sqClient = new SquareClient($_sqConfig);
$_sqMeta = $_sqSchoolNum > 0
? $_sqStore->getMetaByNum($_sqSchoolNum)
: ($_sqSchool !== '' ? $_sqStore->getMeta($_sqSchool) : []);
$_sqMerchantId = $_sqMeta['merchant_id'] ?? null;
$_sqLocationId = ($_sqSchool !== '' || $_sqSchoolNum > 0)
? $_sqStore->locationId($_sqSchool, $_sqClient, $_sqSchoolNum > 0 ? $_sqSchoolNum : null)
: null;
$_sqReady = !isset($_sqMeta['square_ready']) || (bool)$_sqMeta['square_ready'];
$_sqRevoked = !empty($_sqMeta['authorization_revoked']);
$_sqCurrency = strtoupper((string)($sqCheckout['currency'] ?? ($_sqMeta['currency'] ?? 'CAD')));
// Helper to render a muted, non-blocking notice and bail out.
$_sqNotice = function (string $msg): void {
echo '<div class="sq-notice" style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;'
. 'max-width:500px;background:#f5f7fa;border:1px solid #e2e8f0;border-radius:8px;'
. 'padding:12px 14px;color:#555;font-size:13px;">'
. htmlspecialchars($msg) . '</div>';
};
if (!$_sqAppId || strpos($_sqAppId, 'REPLACE') === 0) {
$_sqNotice('Card payment is temporarily unavailable.');
return;
}
if (!$_sqMerchantId || $_sqRevoked) {
$_sqNotice('This school is not set up to accept card payments yet.');
return;
}
if (!$_sqLocationId) {
$_sqNotice('Card payment for this school is being set up and is not available yet.');
return;
}
if (!is_numeric($_sqAmount) || (float)$_sqAmount <= 0) {
$_sqNotice('Invalid payment amount.');
return;
}
$_sqAmountFmt = number_format((float)$_sqAmount, 2, '.', '');
$_sqApiUrl = (string)($sqCheckout['order_api_url'] ?? './order-api.php');
$_sqSuccessUrl = (string)($sqCheckout['success_url'] ?? '');
$_sqBuyerEmail = (string)($sqCheckout['buyer_email'] ?? '');
$_sqUserNum = (int)($sqCheckout['user_num'] ?? 0);
// Public embed key (preferred over school_name when this widget is loaded from
// an external site via pay-frame.php). Resolved server-side; safe to expose.
$_sqMerchantKey = (string)($sqCheckout['merchant_key'] ?? ($_sqMeta['merchant_key'] ?? ''));
// Unique id so multiple widgets can coexist on one page. Alphanumeric only.
$_sqWidgetId = 'sqwidget' . substr(md5($_sqSchool . $_sqAmountFmt . microtime()), 0, 8);
// SDK URL by environment.
$_sqSdkUrl = $_sqEnv === 'production'
? 'https://web.squarecdn.com/v1/square.js'
: 'https://sandbox.web.squarecdn.com/v1/square.js';
// Load the SDK once per page.
$_sqSdkAlreadyLoaded = $GLOBALS['_sq_sdk_loaded'] ?? false;
if (!$_sqSdkAlreadyLoaded) { $GLOBALS['_sq_sdk_loaded'] = true; }
?>
<div id="<?= $_sqWidgetId ?>" class="sq-checkout-wrap">
<div class="sq-card-header">
<span class="sq-card-title">Pay with card</span>
<span class="sq-card-amount"><?= htmlspecialchars($_sqCurrency) ?> <?= htmlspecialchars($_sqAmountFmt) ?></span>
</div>
<div id="<?= $_sqWidgetId ?>-card" class="sq-card-container"></div>
<button id="<?= $_sqWidgetId ?>-pay" class="sq-pay-btn" type="button">
Pay <?= htmlspecialchars($_sqCurrency) ?> <?= htmlspecialchars($_sqAmountFmt) ?>
</button>
<div id="<?= $_sqWidgetId ?>-status" class="sq-status" style="display:none;"></div>
<div class="sq-secure-note">
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M12 1 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-4zm0 10.99h6c-.45 3.6-2.97 6.84-6 7.93V12H6V6.3l6-3 6 3V12h-6V11.99z"/></svg>
Card details are encrypted by Square and never touch our servers.
</div>
</div>
<style>
#<?= $_sqWidgetId ?> {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 500px;
background: #fff;
border: 1.5px solid #e2e8f0;
border-radius: 12px;
padding: 20px;
}
#<?= $_sqWidgetId ?> .sq-card-header {
display: flex; justify-content: space-between; align-items: baseline;
margin-bottom: 14px;
}
#<?= $_sqWidgetId ?> .sq-card-title { font-weight: 600; color: #1a202c; font-size: 15px; }
#<?= $_sqWidgetId ?> .sq-card-amount { font-weight: 700; color: #006aff; font-size: 16px; }
#<?= $_sqWidgetId ?> .sq-card-container {
min-height: 48px; margin-bottom: 14px;
}
#<?= $_sqWidgetId ?> .sq-pay-btn {
width: 100%; padding: 14px;
background: linear-gradient(135deg, #006aff 0%, #003a8c 100%);
color: #fff; border: none; border-radius: 8px;
font-size: 16px; font-weight: 600; cursor: pointer;
}
#<?= $_sqWidgetId ?> .sq-pay-btn:hover { box-shadow: 0 8px 18px rgba(0,106,255,.28); }
#<?= $_sqWidgetId ?> .sq-pay-btn:disabled { background: #cbd5e0; cursor: not-allowed; box-shadow: none; }
#<?= $_sqWidgetId ?> .sq-status {
margin-top: 12px; padding: 10px 12px; border-radius: 8px; font-size: 13px;
}
#<?= $_sqWidgetId ?> .sq-status.err { background: #fff5f5; border: 1px solid #feb2b2; color: #c53030; }
#<?= $_sqWidgetId ?> .sq-status.ok { background: #f0fff4; border: 1px solid #9ae6b4; color: #276749; }
#<?= $_sqWidgetId ?> .sq-status.info{ background: #ebf8ff; border: 1px solid #90cdf4; color: #2c5282; }
#<?= $_sqWidgetId ?> .sq-secure-note {
display: flex; align-items: center; gap: 6px;
margin-top: 12px; color: #718096; font-size: 12px;
}
</style>
<?php if (!$_sqSdkAlreadyLoaded): ?>
<script src="<?= htmlspecialchars($_sqSdkUrl) ?>" onload="window.__sqSdkReady && window.__sqSdkReady()"></script>
<?php endif; ?>
<script>
(function () {
var CFG = {
id: <?= json_encode($_sqWidgetId) ?>,
appId: <?= json_encode($_sqAppId) ?>,
locationId:<?= json_encode($_sqLocationId) ?>,
school: <?= json_encode($_sqSchool) ?>,
schoolNum: <?= (int)$_sqSchoolNum ?>,
userNum: <?= (int)$_sqUserNum ?>,
merchantKey:<?= json_encode($_sqMerchantKey) ?>,
amount: <?= json_encode($_sqAmountFmt) ?>,
currency: <?= json_encode($_sqCurrency) ?>,
apiUrl: <?= json_encode($_sqApiUrl) ?>,
successUrl:<?= json_encode($_sqSuccessUrl) ?>,
buyerEmail:<?= json_encode($_sqBuyerEmail) ?>
};
// A simple per-page init queue so the SDK is loaded once but every widget on
// the page gets initialized when it's ready.
window.__sqInitQueue = window.__sqInitQueue || [];
window.__sqInitQueue.push(CFG);
window.__sqDrainQueue = window.__sqDrainQueue || function () {
if (!window.Square) return;
var q = window.__sqInitQueue || [];
window.__sqInitQueue = [];
q.forEach(function (cfg) { initWidget(cfg); });
};
window.__sqSdkReady = window.__sqDrainQueue;
function setStatus(cfg, cls, msg) {
var el = document.getElementById(cfg.id + '-status');
if (!el) return;
el.style.display = 'block';
el.className = 'sq-status ' + cls;
el.textContent = msg;
}
function newIdempotencyKey() {
if (window.crypto && crypto.randomUUID) return crypto.randomUUID();
return 'idem-' + Date.now() + '-' + Math.random().toString(16).slice(2);
}
async function initWidget(cfg) {
var payBtn = document.getElementById(cfg.id + '-pay');
var payments, card;
try {
payments = window.Square.payments(cfg.appId, cfg.locationId);
card = await payments.card();
await card.attach('#' + cfg.id + '-card');
} catch (e) {
// Surface the real SDK error (helpful while integrating). The most common
// cause is the appId and the location belonging to different Square apps
// or environments.
console.error('Square init error:', e, 'appId=', cfg.appId, 'locationId=', cfg.locationId);
var detail = (e && e.message) ? e.message : String(e);
setStatus(cfg, 'err', 'Could not load the card form: ' + detail);
if (payBtn) payBtn.disabled = true;
return;
}
payBtn.addEventListener('click', async function () {
payBtn.disabled = true;
setStatus(cfg, 'info', 'Processing…');
var result;
try {
result = await card.tokenize();
} catch (e) {
setStatus(cfg, 'err', 'Could not read the card. Please check the details and try again.');
payBtn.disabled = false;
return;
}
if (!result || result.status !== 'OK') {
var msg = 'Please check your card details.';
if (result && result.errors && result.errors.length) {
msg = result.errors.map(function (er) { return er.message; }).join(' ');
}
setStatus(cfg, 'err', msg);
payBtn.disabled = false;
return;
}
var verifyToken = '';
try {
if (payments.verifyBuyer) {
var verification = await payments.verifyBuyer(result.token, {
amount: cfg.amount,
currencyCode: cfg.currency,
intent: 'CHARGE',
billingContact: cfg.buyerEmail ? { email: cfg.buyerEmail } : {}
});
if (verification && verification.token) verifyToken = verification.token;
}
} catch (e) {
var cancelled = e && (/cancel/i.test(e.message || '') || e.name === 'VerifyBuyerError');
if (cancelled) {
setStatus(cfg, 'err', 'Card verification was cancelled. Please try again.');
payBtn.disabled = false;
return;
}
// SCA not required / SDK could not verify — charge without the token.
}
// result.token is the single-use sourceId. Send it to our backend.
try {
var resp = await fetch(cfg.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'create_payment',
source_id: result.token,
amount: cfg.amount,
currency: cfg.currency,
school_name: cfg.school,
school_num: cfg.schoolNum || undefined,
user_num: cfg.userNum || undefined,
merchant_key: cfg.merchantKey,
buyer_email: cfg.buyerEmail,
verification_token: verifyToken,
idempotency_key: newIdempotencyKey()
})
});
var d = await resp.json();
if (d.success && d.charged) {
// Phase 4: charge completed.
setStatus(cfg, 'ok', 'Payment successful!');
if (cfg.successUrl) {
var sep = cfg.successUrl.indexOf('?') === -1 ? '?' : '&';
window.location.href = cfg.successUrl + sep + 'payment_id=' + encodeURIComponent(d.payment_id || '');
}
} else if (d.success && d.stage === 'validated') {
// Phase 3: backend received + validated the token; charging is Phase 4.
setStatus(cfg, 'ok', 'Card accepted and validated by the server. (Charging is enabled in Phase 4.)');
} else {
setStatus(cfg, 'err', d.error || 'Payment could not be completed.');
payBtn.disabled = false;
}
} catch (e) {
setStatus(cfg, 'err', 'Network error. Please try again.');
payBtn.disabled = false;
}
});
}
// If the SDK is already on the page (a previous widget loaded it), drain now.
if (window.Square) { window.__sqDrainQueue(); }
}());
</script>
+120
View File
@@ -0,0 +1,120 @@
<?php
/**
* "Pay with Square" button — production bridge to Square checkout
* ────────────────────────────────────────────────────────────────
* Include from payment_form.php AFTER $balance is known. Same contract as
* paypal_ppcp_button.php: stores the amount in the hotlunch session, then
* links to /square/checkout.php so the parent cannot change the total.
*
* The button renders ONLY when this school is onboarded with Square
* (MerchantStore::isOnboarded: connected, not revoked, token present, active
* location). Otherwise nothing is shown. The charge is made with THIS school's
* own Square token, so the money settles in this school's Square account.
* Hidden-return leaves an HTML comment explaining why (View Source).
*/
$sqWhy = function (string $reason): void {
echo "\n<!-- sq-button hidden: " . htmlspecialchars($reason, ENT_QUOTES) . " -->\n";
};
if (!defined('SQ_WEB_BASE')) {
// Production layout: payment_form.php at /, this file at /square/square_button.php
define('SQ_WEB_BASE', '/square');
}
// The unique school id (names are not unique, so never resolve by name here).
$sqSchoolNum = (int)($school_num ?? ($school_record['record_num'] ?? 0));
if ($sqSchoolNum <= 0) {
$sqWhy('no school on this page');
return;
}
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
$sqCfg = require __DIR__ . '/config.php';
$sqStore = new MerchantStore($sqCfg);
// Always read the live state from the DB (a cached $school_record could predate
// a connect / disconnect / revoke).
if (!$sqStore->isOnboarded($sqSchoolNum, new SquareClient($sqCfg))) {
$sqWhy('school is not onboarded with Square');
return;
}
$sqMeta = $sqStore->getMetaByNum($sqSchoolNum);
$sqSchoolName = trim((string)($school_record['school_name'] ?? ''));
if ($sqSchoolName === '') {
$sqSchoolName = (string)($sqStore->schoolNameByNum($sqSchoolNum) ?? '');
}
$sqBase = 0.0;
if (function_exists('db_getVal') && isset($user_num)) {
$sqBase = (float)db_getVal(
"select sum(transaction_amount) as balance from user_account
where school_num = " . $sqSchoolNum . " and user_num = " . (int)$user_num . "
AND (deleted != 'Y' OR deleted IS NULL)"
);
}
if ($sqBase <= 0 && isset($balance)) {
$sqBase = (float)$balance;
}
if (isset($selected_amount) && (float)$selected_amount > 0) {
$sqBase = (float)$selected_amount;
}
$sqBase = round($sqBase, 2);
if ($sqBase <= 0) {
$sqWhy('nothing to charge (total ' . number_format($sqBase, 2) . ')');
return;
}
// Charge in the currency of the school's Square location (Square rejects a
// currency that doesn't match the location).
$sqCurrency = strtoupper(trim((string)($sqMeta['currency'] ?? '')));
if (!preg_match('/^[A-Z]{3}$/', $sqCurrency)) {
$sqCurrency = 'CAD';
}
$sqIsDeposit = isset($selected_amount) && (float)$selected_amount > 0;
if (session_status() !== PHP_SESSION_ACTIVE) {
@session_name('hotlunch');
@session_start();
}
$_SESSION['sq_checkout'] = [
'school_num' => $sqSchoolNum,
'school_name' => $sqSchoolName,
'user_num' => (int)($user_num ?? ($_SESSION['UserInfo']['record_num'] ?? 0)),
'buyer_email' => (string)($_SESSION['UserInfo']['email'] ?? ''),
'base_amount' => number_format($sqBase, 2, '.', ''),
'fee_amount' => '0.00',
'total_amount' => number_format($sqBase, 2, '.', ''),
'base_label' => $sqIsDeposit ? 'Deposit amount' : 'Account balance',
'fee_label' => '',
'currency' => $sqCurrency,
'currency_symbol' => (string)($currencySymbol ?? '$'),
'return_url' => (string)($FormTarget ?? '/'),
// One Square idempotency key per checkout: a repeated click, retry or second
// tab for the same checkout can never charge the parent twice.
'idempotency_key' => 'hl-' . bin2hex(random_bytes(16)),
'created_at' => time(),
];
$sqCheckoutUrl = SQ_WEB_BASE . '/checkout.php';
?>
<div class="sq-pay-wrap">
<a href="<?php echo htmlspecialchars($sqCheckoutUrl); ?>" class="sq-pay-btn">
Pay with Square
</a>
</div>
<style>
.sq-pay-wrap { max-width: 340px; margin: 0 auto 8px; text-align: center; }
.sq-pay-btn {
display: flex; align-items: center; justify-content: center;
width: 100%; min-height: 48px; padding: 12px 24px; box-sizing: border-box;
background: linear-gradient(135deg, #006aff 0%, #003a8c 100%);
border: none; border-radius: 8px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 16px; font-weight: 600; color: #fff; text-decoration: none; cursor: pointer;
}
.sq-pay-btn:hover, .sq-pay-btn:focus { color: #fff; text-decoration: none; box-shadow: 0 8px 18px rgba(0,106,255,.28); }
</style>
+116
View File
@@ -0,0 +1,116 @@
<?php
/**
* square_check.php — TEMPORARY diagnostic: one school's Square row + the latest
* onboarding attempts, shown whether or not the school is connected.
*
* Read-only. NEVER displays access/refresh tokens — only whether one is present.
*
* ⚠️ DELETE THIS FILE FROM THE SERVER when you're done. It is gated by a key
* below to avoid casual exposure, but it is not part of the real app.
*
* Usage: https://dev.hotlunches.net/square/square_check.php?key=sq-check-4b9e2d
* (optional) &school=Other%20School%20Name
*/
ini_set('display_errors', 0);
// ── Simple access gate ───────────────────────────────────────────────────────
$ACCESS_KEY = 'sq-check-4b9e2d';
if (($_GET['key'] ?? '') !== $ACCESS_KEY) {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo "Forbidden. Append ?key=YOUR_KEY (see the top of square_check.php).";
exit;
}
// ── Bootstrap the DB layer (MerchantStore loads the host include/database.php) ─
require_once __DIR__ . '/lib/MerchantStore.php';
$config = require __DIR__ . '/config.php';
$store = new MerchantStore($config); // constructor bootstraps the db_* layer
header('Content-Type: text/html; charset=utf-8');
function h($v) { return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); }
/** Run a query and print its rows as a table (NULL shown as NULL). */
function showQuery(string $title, string $sql, array $truncate = []): void {
echo '<h2>' . h($title) . '</h2><pre class="sql">' . h($sql) . '</pre>';
$r = db_query($sql);
if (!$r) {
echo '<p class="err">Query failed' . (function_exists('db_error') ? ': ' . h(db_error()) : '') . '</p>';
return;
}
$rows = [];
while ($row = db_fetch_assoc($r)) {
$rows[] = $row;
}
if (!$rows) {
echo '<p class="err">No rows.</p>';
return;
}
echo '<table><thead><tr>';
foreach (array_keys($rows[0]) as $col) {
echo '<th>' . h($col) . '</th>';
}
echo '</tr></thead><tbody>';
foreach ($rows as $row) {
echo '<tr>';
foreach ($row as $col => $val) {
if ($val === null) {
echo '<td class="muted">NULL</td>';
} elseif (in_array($col, $truncate, true)) {
echo '<td>' . h(substr((string)$val, 0, 12)) . '…</td>';
} else {
echo '<td>' . nl2br(h($val)) . '</td>';
}
}
echo '</tr>';
}
echo '</tbody></table>';
}
$school = trim((string)($_GET['school'] ?? 'Development School'));
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Square — Check</title>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Arial, sans-serif; margin: 0; background: #f7fafc; color: #1a202c; }
.wrap { max-width: 1200px; margin: 0 auto; padding: 28px 20px 60px; }
h1 { font-size: 22px; margin: 0 0 20px; }
h2 { font-size: 16px; margin: 28px 0 8px; }
.warn { background: #fffaf0; border: 1px solid #fbd38d; color: #7b341e; padding: 10px 14px; border-radius: 8px; font-size: 13px; }
.err { color: #c53030; font-size: 13px; }
pre.sql { background: #f1f5f9; padding: 10px 12px; border-radius: 6px; font-size: 12px; white-space: pre-wrap; margin: 0 0 10px; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #e2e8f0; font-size: 13px; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #edf2f7; vertical-align: top; }
th { background: #f1f5f9; font-weight: 600; color: #475569; white-space: nowrap; }
.muted { color: #a0aec0; }
</style>
</head>
<body>
<div class="wrap">
<h1>Square — Check</h1>
<div class="warn">⚠️ Temporary diagnostic page — delete <code>square_check.php</code> from the server when finished.</div>
<?php
if (!function_exists('db_query')) {
echo '<p class="err">Host database layer (db_query) is not available — cannot read.</p>';
} else {
$s = db_escape_string($school);
showQuery('School row', "SELECT record_num, school_name, square_merchant_id,
square_access_token IS NOT NULL AS has_token,
square_ready, square_location_id,
square_authorization_revoked, square_status_issues
FROM schools WHERE school_name = '$s'");
showQuery('Latest onboarding attempts', "SELECT state, school_num, status, merchant_id,
FROM_UNIXTIME(created_at) AS created, FROM_UNIXTIME(last_callback) AS callback
FROM square_pending_onboarding ORDER BY created_at DESC LIMIT 5", ['state']);
}
?>
</div>
</body>
</html>
+211
View File
@@ -0,0 +1,211 @@
<?php
/**
* square_connected.php — TEMPORARY admin view of Square-connected schools.
*
* Read-only. Lists every school that has a Square connection (and recent pending
* onboarding attempts) so you can confirm onboarding worked without DB access.
* NEVER displays access/refresh tokens — only whether they are present.
*
* ⚠️ DELETE THIS FILE FROM PRODUCTION when you're done. It is gated by a key
* below to avoid casual exposure, but it is not part of the real app.
*
* Usage: https://dev.hotlunches.net/square/square_connected.php?key=sq-admin-7f3a
*/
ini_set('display_errors', 0);
// ── Simple access gate ───────────────────────────────────────────────────────
// Change this value (or delete the file) before leaving it on a public server.
$ACCESS_KEY = 'sq-admin-7f3a';
if (($_GET['key'] ?? '') !== $ACCESS_KEY) {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo "Forbidden. Append ?key=YOUR_KEY (see the top of square_connected.php).";
exit;
}
// ── Bootstrap the DB layer (MerchantStore loads the host include/database.php) ─
require_once __DIR__ . '/lib/MerchantStore.php';
$config = require __DIR__ . '/config.php';
$store = new MerchantStore($config); // constructor bootstraps the db_* layer
header('Content-Type: text/html; charset=utf-8');
$dbReady = function_exists('db_query');
/** Format a unix timestamp (or '-' when empty). */
function ts($v) {
$v = (int)$v;
return $v > 0 ? date('Y-m-d H:i', $v) : '-';
}
function h($v) { return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); }
// ── Connected schools (tokens are NOT selected) ──────────────────────────────
$connected = [];
$pending = [];
$error = '';
if ($dbReady) {
$sql = "SELECT record_num, school_name,
square_merchant_id, square_merchant_name,
square_location_id, square_currency, square_country,
square_ready, square_connected_at, square_token_expires_at,
square_merchant_key, square_status_issues, square_status_checked_at,
square_authorization_revoked,
(square_access_token IS NOT NULL AND square_access_token <> '') AS has_access,
(square_refresh_token IS NOT NULL AND square_refresh_token <> '') AS has_refresh
FROM schools
WHERE square_merchant_id IS NOT NULL AND square_merchant_id <> ''
ORDER BY square_connected_at DESC";
$r = db_query($sql);
if ($r) {
while ($row = db_fetch_assoc($r)) {
$connected[] = $row;
}
}
$pr = db_query(
"SELECT state, school_num, status, merchant_id, created_at, connected_at, last_callback
FROM square_pending_onboarding
ORDER BY created_at DESC
LIMIT 50"
);
if ($pr) {
while ($row = db_fetch_assoc($pr)) {
$pending[] = $row;
}
}
} else {
$error = 'Host database layer (db_query) is not available — cannot read.';
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Square — Connected Schools</title>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Arial, sans-serif; margin: 0; background: #f7fafc; color: #1a202c; }
.wrap { max-width: 1200px; margin: 0 auto; padding: 28px 20px 60px; }
h1 { font-size: 22px; margin: 0 0 4px; }
.sub { color: #718096; font-size: 13px; margin: 0 0 24px; }
.warn { background: #fffaf0; border: 1px solid #fbd38d; color: #7b341e; padding: 10px 14px; border-radius: 8px; font-size: 13px; margin-bottom: 20px; }
.err { background: #fff5f5; border: 1px solid #feb2b2; color: #c53030; padding: 10px 14px; border-radius: 8px; font-size: 13px; margin-bottom: 20px; }
h2 { font-size: 16px; margin: 28px 0 10px; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; overflow: hidden; font-size: 13px; }
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid #edf2f7; vertical-align: top; }
th { background: #f1f5f9; font-weight: 600; color: #475569; white-space: nowrap; }
tr:last-child td { border-bottom: none; }
code { background: #f1f5f9; padding: 1px 5px; border-radius: 4px; font-size: 12px; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; }
.ok { background: #c6f6d5; color: #22543d; }
.no { background: #fed7d7; color: #742a2a; }
.muted{ color: #a0aec0; }
.count { color: #718096; font-weight: 400; font-size: 13px; }
</style>
</head>
<body>
<div class="wrap">
<h1>Square — Connected Schools</h1>
<p class="sub">Read-only snapshot from the live database. Tokens are never shown.</p>
<div class="warn">⚠️ Temporary diagnostic page — delete <code>square_connected.php</code> from production when finished.</div>
<?php if ($error !== ''): ?>
<div class="err"><?php echo h($error); ?></div>
<?php endif; ?>
<h2>Connected schools <span class="count">(<?php echo count($connected); ?>)</span></h2>
<?php if (empty($connected)): ?>
<p class="muted">No schools have a Square connection yet.</p>
<?php else: ?>
<table>
<thead>
<tr>
<th>record_num</th>
<th>School</th>
<th>Merchant</th>
<th>Location</th>
<th>Currency</th>
<th>Ready</th>
<th>Tokens</th>
<th>Connected</th>
<th>Token expires</th>
<th>Embed key</th>
<th>Issues</th>
</tr>
</thead>
<tbody>
<?php foreach ($connected as $c): ?>
<tr>
<td><code><?php echo h($c['record_num']); ?></code></td>
<td><?php echo h($c['school_name']); ?></td>
<td>
<?php echo h($c['square_merchant_name'] ?: '—'); ?><br>
<code><?php echo h($c['square_merchant_id']); ?></code>
</td>
<td><?php echo h($c['square_location_id'] ?: '—'); ?></td>
<td><?php echo h($c['square_currency'] ?: '—'); ?><?php echo $c['square_country'] ? ' / ' . h($c['square_country']) : ''; ?></td>
<td>
<?php if (!empty($c['square_authorization_revoked'])): ?>
<span class="pill no">revoked</span>
<?php elseif (!empty($c['square_ready'])): ?>
<span class="pill ok">ready</span>
<?php else: ?>
<span class="pill no">not ready</span>
<?php endif; ?>
</td>
<td>
<span class="pill <?php echo !empty($c['has_access']) ? 'ok' : 'no'; ?>">access</span>
<span class="pill <?php echo !empty($c['has_refresh']) ? 'ok' : 'no'; ?>">refresh</span>
</td>
<td><?php echo h($c['square_connected_at'] ?: '—'); ?></td>
<td><?php echo h($c['square_token_expires_at'] ?: '—'); ?></td>
<td><?php echo $c['square_merchant_key'] ? '<code>' . h($c['square_merchant_key']) . '</code>' : '<span class="muted">—</span>'; ?></td>
<td><?php echo $c['square_status_issues'] ? nl2br(h($c['square_status_issues'])) : '<span class="muted">none</span>'; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<h2>Recent onboarding attempts <span class="count">(<?php echo count($pending); ?>)</span></h2>
<?php if (empty($pending)): ?>
<p class="muted">No onboarding attempts recorded.</p>
<?php else: ?>
<table>
<thead>
<tr>
<th>school_num<br>(record_num)</th>
<th>Status</th>
<th>Merchant id</th>
<th>Created</th>
<th>Connected</th>
<th>Last callback</th>
<th>State</th>
</tr>
</thead>
<tbody>
<?php foreach ($pending as $p): ?>
<tr>
<td><code><?php echo h($p['school_num']); ?></code></td>
<td>
<?php
$st = (string)$p['status'];
$cls = $st === 'connected' ? 'ok' : ($st === 'pending' ? '' : 'no');
echo '<span class="pill ' . $cls . '">' . h($st) . '</span>';
?>
</td>
<td><?php echo $p['merchant_id'] ? '<code>' . h($p['merchant_id']) . '</code>' : '<span class="muted">—</span>'; ?></td>
<td><?php echo ts($p['created_at']); ?></td>
<td><?php echo ts($p['connected_at']); ?></td>
<td><?php echo ts($p['last_callback']); ?></td>
<td><span class="muted"><?php echo h(substr((string)$p['state'], 0, 12)); ?>…</span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</body>
</html>
+130
View File
@@ -0,0 +1,130 @@
<?php
/**
* Square Webhook Handler (Phase 2)
* ────────────────────────────────
* Setup (do once):
* 1. Deploy this file and note its public URL, e.g. https://app.hotlunches.net/square/webhook.php
* 2. Square Developer Dashboard → your App → Webhooks → Add Subscription:
* • Notification URL: the exact URL above (must match config 'webhook_url')
* • Events: oauth.authorization.revoked (Phase 5 will add payment.updated, refund.updated)
* 3. Copy the subscription's Signature Key → set 'webhook_signature_key' in config.php
* 4. Set 'webhook_url' in config.php to the exact Notification URL you entered.
*
* Square retries on any non-2xx for ~72h, so on transient problems we respond
* 200 (work is logged); on a FAILED signature we respond 403 (never process an
* unverified body). The raw body is read first, before anything can corrupt it.
*/
$rawBody = file_get_contents('php://input');
// Optional app logging.
ob_start();
if (file_exists(__DIR__ . '/../include/logging-functions.php')) {
require_once __DIR__ . '/../include/logging-functions.php';
if (function_exists('startLog')) startLog('square-webhook');
}
ob_end_clean();
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
require_once __DIR__ . '/lib/MerchantStore.php';
function sqLog(string $msg): void {
if (function_exists('writeLog')) writeLog($msg);
}
// ── Header helper (works on Apache + Nginx + PHP-FPM) ────────────────────────
function sqWebhookHeader(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] ?? '';
}
// ── Resolve the notification URL exactly as registered (signature depends on it).
function sqNotificationUrl(array $config): string {
$u = trim($config['webhook_url'] ?? '');
if ($u !== '') return $u;
// Fallback: reconstruct from the request (works when not behind a rewriting proxy).
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https'
: ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? 'https');
$host = $_SERVER['HTTP_HOST'] ?? '';
$uri = $_SERVER['REQUEST_URI'] ?? '';
return $scheme . '://' . $host . $uri;
}
// ── Guard: signature key must be configured ──────────────────────────────────
$signatureKey = $config['webhook_signature_key'] ?? '';
if ($signatureKey === '') {
sqLog('square-webhook: webhook_signature_key not set — refusing to process unverified events');
http_response_code(500);
echo json_encode(['status' => 'signature_key_not_configured']);
exit();
}
// ── Verify HMAC-SHA256 signature ─────────────────────────────────────────────
// signature = base64( HMAC_SHA256( key, notificationUrl + rawBody ) )
$providedSig = sqWebhookHeader('x-square-hmacsha256-signature');
if ($providedSig === '') {
sqLog('square-webhook: missing x-square-hmacsha256-signature header — ignored');
http_response_code(403);
echo json_encode(['status' => 'missing_signature']);
exit();
}
$notificationUrl = sqNotificationUrl($config);
$expectedSig = base64_encode(hash_hmac('sha256', $notificationUrl . $rawBody, $signatureKey, true));
if (!hash_equals($expectedSig, $providedSig)) {
sqLog('square-webhook: signature MISMATCH (notification_url=' . $notificationUrl . ') — rejected');
http_response_code(403);
echo json_encode(['status' => 'signature_verification_failed']);
exit();
}
// ── Parse the verified event ─────────────────────────────────────────────────
$event = json_decode($rawBody, true) ?: [];
$eventType = $event['type'] ?? '';
$merchantId = $event['merchant_id'] ?? ($event['data']['object']['merchant_id'] ?? '');
sqLog('square-webhook: verified type=' . $eventType . ' event_id=' . ($event['event_id'] ?? '?') . ' merchant_id=' . ($merchantId ?: '?'));
http_response_code(200);
try {
$store = new MerchantStore($config);
// ════════════════════════════════════════════════════════════════════════
// EVENT: oauth.authorization.revoked
// The seller removed our application from THEIR Square account. The tokens
// are already dead at Square — clear ours and flag the school so payments
// stop and staff know a reconnect is required.
// ════════════════════════════════════════════════════════════════════════
if ($eventType === 'oauth.authorization.revoked') {
if ($merchantId === '') {
sqLog('square-webhook: revoked event without merchant_id — nothing to do');
echo json_encode(['status' => 'ok', 'note' => 'no merchant_id']);
exit();
}
$school = $store->markAuthorizationRevoked($merchantId);
sqLog('square-webhook: authorization revoked for merchant_id=' . $merchantId
. ($school ? (' (school=' . $school . ')') : ' (no matching school)'));
echo json_encode(['status' => 'ok', 'school' => $school]);
exit();
}
// ── Unhandled (but verified) event — 200 so Square stops retrying ─────────
sqLog('square-webhook: unhandled event type=' . $eventType);
echo json_encode(['status' => 'ok', 'note' => 'event type not handled']);
exit();
} catch (Throwable $e) {
// Respond 200 so Square doesn't hammer us; the failure is logged for review.
sqLog('square-webhook: handler error — ' . $e->getMessage());
echo json_encode(['status' => 'ok', 'note' => 'handler error logged']);
exit();
}