Files
fotospiel-app/docs/prp/marketing-checkout-payment-architecture.md

136 lines
12 KiB
Markdown

# 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**: Rotate JWT signing keys with `php artisan oauth:rotate-keys` (updates key folder per KID; remember to bump `OAUTH_JWT_KID`).
## 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.