First Commit
This commit is contained in:
1 parent
5eb9dd037d
commit
2e62c0e60b
11877 files changed
+2035312
No files matched your search
@@ -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>
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Whitespace-only changes.
@@ -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 & 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) ?>">← 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>
|
||||
@@ -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',
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
// Database configuration
|
||||
// Update these with your MySQL credentials
|
||||
|
||||
return [
|
||||
'host' => 'localhost',
|
||||
'database' => 'hotlunch', // UPDATE THIS
|
||||
'username' => 'ezssoftwaredb', // UPDATE THIS
|
||||
'password' => 'EZsoftware7785422700@@', // UPDATE THIS
|
||||
'charset' => 'utf8mb4'
|
||||
];
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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>
|
||||
@@ -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 += '¤cy=' + 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);
|
||||
})();
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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'); ?>: </td>
|
||||
<td align='right'><?php echo('-' . $currencySymbol . number_format($payment_discount,2)) ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align='right'><?php displayLanguageText('TEXT-BALANCE'); ?>: </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>
|
||||
@@ -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'); ?>: </td>
|
||||
<td align='right'><?php echo('-' . $currencySymbol . number_format($payment_discount,2)) ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align='right'><?php displayLanguageText('TEXT-BALANCE'); ?>: </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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
class Storage
|
||||
{
|
||||
private string $dir;
|
||||
|
||||
public function __construct(string $dir)
|
||||
{
|
||||
$this->dir = rtrim($dir, DIRECTORY_SEPARATOR);
|
||||
if (!is_dir($this->dir)) {
|
||||
mkdir($this->dir, 0777, true);
|
||||
}
|
||||
if (!is_writable($this->dir)) {
|
||||
throw new RuntimeException('Storage path not writable: ' . $this->dir);
|
||||
}
|
||||
}
|
||||
|
||||
private function path(string $name): string
|
||||
{
|
||||
return $this->dir . DIRECTORY_SEPARATOR . $name . '.json';
|
||||
}
|
||||
|
||||
public function get(string $name, $default = [])
|
||||
{
|
||||
$p = $this->path($name);
|
||||
if (!file_exists($p)) return $default;
|
||||
$data = json_decode((string)file_get_contents($p), true);
|
||||
return $data ?? $default;
|
||||
}
|
||||
|
||||
public function set(string $name, $value): void
|
||||
{
|
||||
$p = $this->path($name);
|
||||
file_put_contents($p, json_encode($value, JSON_PRETTY_PRINT));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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' => ''];
|
||||
@@ -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'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'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'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'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'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>
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user