# 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 ``` **Flow:** `embed.js` reads its own `data-*` + base URL → injects an `