Files
2026-10-19 15:22:52 -07:00
..
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00
2026-10-19 15:22:52 -07:00

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):

$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):

'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):

<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.