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

12 KiB

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 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.