# PayFast Integration (Phase 2b) How payment flows work, why each verification step exists, and how to go from sandbox to live. ## The two payment flows ### 1. One-off: branding cart ``` member/cart.php | | click "Pay with PayFast" v member/checkout-cart.php | | creates/finds invoice | builds PayFast fields + signature v [redirect to PayFast] (user at PayFast) | | user pays v +-- PayFast ------------+ | 1. redirects user to | user lands at member/payment-return.php | return_url | (purely cosmetic — "Thanks!") | 2. POSTs ITN to our | | notify_url | +-----------------------+ | v payfast-itn.php <-- authoritative; updates DB + fires Mailchimp ``` ### 2. Recurring: annual membership Same flow, but the checkout page includes `subscription_type=1` plus a `billing_date`, `recurring_amount`, `frequency=6` (annual) and `cycles=0` (forever). PayFast then charges the saved card annually and sends an ITN for each charge. First charge = payment + stored token. Every subsequent charge = PayFast auto-charges + sends us an ITN. Cancellation = we flip token status locally; production code should also call PayFast's subscription-cancel API. ## The ITN verification chain Every ITN runs through four checks in `payfast-itn.php`: 1. **Signature** (`pf_verify_signature`) — MD5 of all fields in order with our passphrase appended. If PayFast's signature doesn't match what we compute, it's not really them. 2. **Source IP** (`pf_verify_source_ip`) — the ITN must come from a resolved IP of `*.payfast.co.za`. Sandbox skips this because dev servers see varied IPs. 3. **Amount match** — we compare the gross amount in the ITN with the invoice amount in our database. A tampered ITN that changes the amount gets rejected. 4. **Postback validation** (`pf_validate_via_postback`) — we POST the ITN body back to PayFast's validate endpoint. They respond "VALID" if it's genuinely theirs. Any single failure = we log and ignore. Four checks is belt-and-braces on purpose: a single layer could be spoofed if a secret leaks. ## Idempotency The `payments` table has a UNIQUE KEY on `pf_payment_id`. If PayFast retries an ITN (they sometimes do), the second INSERT fails cleanly, we detect the existing row, and return 200 without re-applying. ## Sandbox → live 1. Register a live merchant account at payfast.co.za 2. In the PayFast dashboard, grab: merchant_id, merchant_key, passphrase 3. In `includes/config.php`: ```php define('PF_SANDBOX', false); define('PF_MERCHANT_ID', 'your-live-id'); define('PF_MERCHANT_KEY', 'your-live-key'); define('PF_PASSPHRASE', 'your-live-passphrase'); ``` 4. Test a small real payment (R10) before enabling for members. The `pf_is_sandbox()` function switches between sandbox and live URLs automatically — no other code changes needed. ## What PayFast's sandbox won't do - **IP verification is skipped** — sandbox IPs are unpredictable. - **No real subscription-cancel API** — the public sandbox credentials don't have API access, only the Process endpoint. When you switch to your own sandbox credentials, you can test the subscription API. - **Credit-card test cards** — PayFast provides test cards like `4000 0000 0000 0002`. See their docs. ## Dev testing PayFast can't POST ITNs to `localhost`. During local development either: - Use ngrok or a similar tunnel so PayFast can reach your dev box, or - Call `payfast-itn.php` manually with crafted POST data (see a captured real ITN as template) for unit-style testing. ## Troubleshooting | Symptom | Likely cause | |---------|--------------| | ITN never arrives | `notify_url` wrong or unreachable from PayFast | | Signature fails | passphrase doesn't match; wrong char encoding | | Amount mismatch | client tampered with the form, or currency/decimals wrong | | Duplicate payments | `pf_payment_id` unique index prevents double-apply | | Subscription doesn't auto-charge | wrong `frequency`/`cycles`, sandbox expires subscriptions | Look at `payments` table rows for raw payloads — `signature_ok`, `ip_ok`, `processed` columns tell the story for any given ITN.