# Marketing Checkout Payment Architecture (2025 Refactor) ## Goals - Replace the legacy marketing checkout flow with a single `CheckoutController` that owns auth, payment, and confirmation steps. - Support Stripe card payments and Paddle orders with a consistent state machine that can be extended to other providers. - Keep package activation logic idempotent and traceable while respecting GDPR (no new PII logging, no leaked tokens). - Prepare the frontend wizard to drive the flow as an SPA without relying on server-side redirects. ## Core Building Blocks - **CheckoutSession model/table** keeps one purchase attempt per user + package. It stores provider choice, status, pricing snapshot, and external ids (Stripe intent, Paddle order, etc.). - **CheckoutPaymentService** orchestrates provider-specific actions (create intent/order, capture, sync metadata) and normalises responses for the wizard. - **CheckoutAssignmentService** performs the idempotent write workflow (create/update TenantPackage, PackagePurchase, tenant subscription fields, welcome mail) once payment succeeds. - **Wizard API surface** (JSON routes under `/checkout/*`) is session-authenticated, CSRF-protected, and returns structured payloads consumed by the PWA. - **Webhooks** (Stripe, Paddle) map incoming provider events back to `CheckoutSession` rows to guarantee reconciliation and support 3DS / async capture paths. - **Feature Flag**: `config/checkout.php` exposes `CHECKOUT_WIZARD_ENABLED` and `CHECKOUT_WIZARD_FLAG` so the SPA flow can be toggled or gradual-rolled out during launch. - **Operational**: Track Sanctum PAT issuance via `personal_access_tokens` and document forced logout procedures (no OAuth key rotation required anymore). ## Payment State Machine State constants live on `CheckoutSession` (`status` column, enum): | State | When it is used | Transitions | | --- | --- | --- | | `draft` | Session created, package locked in, no provider chosen. | `awaiting_payment_method`, `completed` (free), `cancelled` | | `awaiting_payment_method` | Paid package; provider picked, waiting for client to initialise SDK. | `requires_customer_action`, `processing`, `cancelled` | | `requires_customer_action` | Stripe 3DS, Paddle approval window open, or additional customer steps needed. | `processing`, `failed`, `cancelled` | | `processing` | Provider reported success, backend validating / capturing / assigning. | `completed`, `failed` | | `completed` | Checkout finished, package assigned, confirmation step unblocked. | none | | `failed` | Provider declined or capture check failed; retain reason. | `awaiting_payment_method` (retry), `cancelled` | | `cancelled` | User backed out or session expired (TTL 30 minutes). | none | Each transition is recorded with a `status_history` JSON column (array of `{status, reason, at}`) for debugging. ## Backend Architecture ### Data Model Add `checkout_sessions` table + model: - `id` UUID primary key. - `user_id` (FK users) and optional `tenant_id` (set after registration creates tenant). - `package_id` (FK packages) and `package_snapshot` JSON (price, currency, package name, type, feature hash). - `status` enum (states above), `provider` enum (`stripe`, `paypal`, `free`, `none`). - `currency`, `amount_subtotal`, `amount_total` (DECIMAL(10,2)). - Provider fields: `stripe_payment_intent_id`, `stripe_customer_id`, `paypal_order_id`, `paypal_subscription_id`, `provider_metadata` JSON. - `locale`, `expires_at` (default now()+30 minutes), `completed_at`. - Timestamps + soft deletes (retain audit trail). Create Eloquent model `App\Models\CheckoutSession` with casts for JSON columns and helper scopes (`active`, `byProviderId`). ### Routes & Controller Methods Group under `web.php` with `middleware(['auth', 'verified', 'locale', 'throttle:checkout'])`: - `POST /checkout/session` (`CheckoutController@storeSession`): create or resume active session for selected package, return `{id, status, amount, package_snapshot}`. - `PATCH /checkout/session/{session}/package` (`updatePackage`): allow switching package before payment; resets provider-specific fields and status to `draft`. - `POST /checkout/session/{session}/provider` (`selectProvider`): set provider (`stripe` or `paypal`), transitions to `awaiting_payment_method` and returns provider configuration (publishable key, Paddle client id, feature flags). - `POST /checkout/session/{session}/stripe-intent` (`createStripeIntent`): idempotently create/update PaymentIntent with metadata (user, tenant, package, session id) and deliver `{client_secret, intent_id}`. - `POST /checkout/session/{session}/stripe/confirm` (`confirmStripeIntent`): server-side verify PaymentIntent status (retrieve from Stripe) and transition to `processing` when `succeeded` or `requires_action`. - `POST /checkout/session/{session}/paypal/order` (`createPaddleOrder`): create order with `custom_id` payload (session, tenant, package) and return `{order_id, approve_url}`. - `POST /checkout/session/{session}/paypal/capture` (`capturePaddleOrder`): capture order server-side, transition to `processing` if status `COMPLETED`. - `POST /checkout/session/{session}/free` (`activateFreePackage`): bypass providers, run assignment service, mark `completed`. - `POST /checkout/session/{session}/complete` (`finalise`): provider-agnostic finishing hook used after `processing` to run `CheckoutAssignmentService`, persist `PackagePurchase`, queue mails, and respond with summary. - `GET /checkout/session/{session}` (`show`): used by wizard polling to keep state in sync (status, provider display data, failure reasons). - `DELETE /checkout/session/{session}` (`cancel`): expire session, clean provider artefacts (cancel intent/order if applicable). Paddle routes remain under `routes/web.php` but call into new service classes; legacy marketing payment methods are removed once parity is verified. ### Services & Jobs - `CheckoutSessionService`: create/resume session, guard transitions, enforce TTL, and wrap DB transactions. - `CheckoutPaymentService`: entry point with methods `initialiseStripe`, `confirmStripe`, `initialisePaddle`, `capturePaddle`, `finaliseFree`. Delegates to provider-specific helpers (Stripe SDK, Paddle SDK) and persists external ids. - `CheckoutAssignmentService`: generates or reuses tenant, writes `TenantPackage`, `PackagePurchase`, updates user role/status, dispatches `Welcome` + purchase receipts, and emits domain events (`CheckoutCompleted`). - `SyncCheckoutFromWebhook` job: invoked by webhook controllers with provider payload, looks up `CheckoutSession` via provider id, runs assignment if needed, records failure states. ### Webhook Alignment - Update `StripeWebhookController` to resolve `CheckoutSession::where('stripe_payment_intent_id', intentId)`; when event indicates success, transition to `processing` (if not already), enqueue `SyncCheckoutFromWebhook` to finish assignment, and mark `completed` once done. - Update `PaddleWebhookController` similarly using `paypal_order_id` or `paypal_subscription_id`. - Webhooks become source-of-truth for delayed confirmations; wizard polls `GET /checkout/session/{id}` until `completed`. ### Validation & Security - All mutating routes use CSRF tokens and `auth` guard (session-based). Add `EnsureCheckoutSessionOwner` middleware enforcing that the session belongs to `request->user()`. - Input validation via dedicated Form Request classes (e.g., `StoreCheckoutSessionRequest`, `StripeIntentRequest`). - Provider responses are never logged raw; only store ids + safe metadata. - Abandon expired sessions via scheduler (`checkout:expire-sessions` artisan command). Command cancels open PaymentIntents and Paddle orders. ## Frontend Touchpoints ### Wizard Context Enhancements - Extend `CheckoutWizardState` to include `checkoutSessionId`, `paymentStatus`, `paymentError`, `isProcessing`, `provider`. - Add actions `initialiseSession(packageId)`, `selectProvider(provider)`, `updatePaymentStatus(status, payload)`. - Persist `checkoutSessionId` + status in `sessionStorage` for reload resilience (respect TTL). ### PaymentStep Implementation Plan - On mount (and whenever package changes), call `/checkout/session` to create/resume session. Reset state if API reports new id. - Provider tabs call `selectProvider`. Stripe tab loads Stripe.js dynamically (import from `@stripe/stripe-js`) and mounts Elements once `client_secret` arrives. - Stripe flow: submit button triggers `stripe.confirmCardPayment(clientSecret)`, handle `requires_action`, then POST `/checkout/session/{id}/stripe/confirm`. On success, call `/checkout/session/{id}/complete` and advance to confirmation step. - Paddle flow: render Paddle Buttons with `createOrder` -> call `/checkout/session/{id}/paypal/order`; `onApprove` -> POST `/checkout/session/{id}/paypal/capture`, then `/checkout/session/{id}/complete`. - Free packages skip provider selection; call `/checkout/session/{id}/free` and immediately advance. - Display status toasts based on `paymentStatus`; show inline error block when `failed` with `failure_reason` from API. ### Confirmation Step & Profile Link - Confirmation fetches `/checkout/session/{id}` summary (package, next steps, admin URL) and surfaces the dashboard link. Update `resources/js/pages/Profile/Index.tsx` to show "Checkout history" link pointing to marketing success page (from TODO item 4/5). ## Provider Flows ### Stripe (one-off and subscription) 1. Session created (`draft`). 2. Provider selected `stripe` -> `awaiting_payment_method`. 3. `createStripeIntent` builds PaymentIntent with amount from package snapshot, metadata: session_id, package_id, user_id, tenant_id (if known), package_type. 4. Frontend confirms card payment. If Stripe returns `requires_action`, wizard stores `requires_customer_action` and surfaces modal. 5. Once Stripe marks intent `succeeded`, backend transitions to `processing`, calls `CheckoutAssignmentService`, and marks `completed`. 6. For reseller packages, Stripe subscription is created after assignment using configured price ids; resulting subscription id stored on session + tenant record. ### Paddle (one-off and subscription) 1. Session `draft` -> provider `paypal`. 2. `createPaddleOrder` returns order id + approval link. Metadata includes session, tenant, package, package_type. 3. After approval, `capturePaddleOrder` verifies capture status; on `COMPLETED`, transitions to `processing`. 4. Assignment service runs, storing order id as `provider_id`. For subscriptions, capture handler stores subscription id and updates tenant subscription status. 5. Webhooks handle late captures or cancellations (updates session -> `failed` or `cancelled`). ### Free Packages - `activateFreePackage` bypasses payment providers, writes TenantPackage + PackagePurchase with `provider_id = 'free'`, marks `completed`, and pushes the user to confirmation immediately. ## Migration Strategy 1. **Phase 1** (current): land schema, services, new API routes; keep legacy MarketingController flow for fallback. 2. **Phase 2**: wire the new wizard PaymentStep behind feature flag `checkout_v2` (in `.env` / config). Run internal QA with Paddle sandbox. 3. **Phase 3**: enable feature flag for production tenants, monitor Paddle events, then delete legacy marketing payment paths and routes. 4. **Phase 4**: tighten webhook logic and remove `MarketingController::checkout`, `::paypalCheckout`, `::stripeSubscription` once new flow is stable. ## Testing & QA - **Feature tests**: JSON endpoints for session lifecycle (create, provider select, intent creation, capture success/failure, free activation). Include multi-locale assertions. - **Payment integration tests**: use Stripe + Paddle SDK test doubles to simulate success, requires_action, cancellation, and ensure state machine behaves. - **Playwright**: wizard flow covering Stripe happy path, Stripe 3DS stub, Paddle approval, failure retry, free package shortcut, session resume after refresh. - **Webhooks**: unit tests for mapping provider ids to sessions, plus job tests for idempotent assignment. - **Scheduler**: test `checkout:expire-sessions` to confirm PaymentIntents are cancelled and sessions flagged `cancelled`. ## Open Questions / Follow-Ups - Map package records to Stripe price ids and Paddle plan ids (store on `packages` table or config?). - Confirm legal copy updates for new checkout experience before GA. - Align email templates (welcome, receipt) with new assignment service outputs.