235 lines
13 KiB
Markdown
235 lines
13 KiB
Markdown
# 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.
|