# 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 `