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

235 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.