12 KiB
12 KiB
Marketing Checkout Payment Architecture (2025 Refactor)
Goals
- Replace the legacy marketing checkout flow with a single
CheckoutControllerthat 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
CheckoutSessionrows to guarantee reconciliation and support 3DS / async capture paths. - Feature Flag:
config/checkout.phpexposesCHECKOUT_WIZARD_ENABLEDandCHECKOUT_WIZARD_FLAGso the SPA flow can be toggled or gradual-rolled out during launch. - Operational: Track Sanctum PAT issuance via
personal_access_tokensand 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:
idUUID primary key.user_id(FK users) and optionaltenant_id(set after registration creates tenant).package_id(FK packages) andpackage_snapshotJSON (price, currency, package name, type, feature hash).statusenum (states above),providerenum (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_metadataJSON. 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 todraft.POST /checkout/session/{session}/provider(selectProvider): set provider (stripeorpaypal), transitions toawaiting_payment_methodand 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 toprocessingwhensucceededorrequires_action.POST /checkout/session/{session}/paypal/order(createPaddleOrder): create order withcustom_idpayload (session, tenant, package) and return{order_id, approve_url}.POST /checkout/session/{session}/paypal/capture(capturePaddleOrder): capture order server-side, transition toprocessingif statusCOMPLETED.POST /checkout/session/{session}/free(activateFreePackage): bypass providers, run assignment service, markcompleted.POST /checkout/session/{session}/complete(finalise): provider-agnostic finishing hook used afterprocessingto runCheckoutAssignmentService, persistPackagePurchase, 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 methodsinitialiseStripe,confirmStripe,initialisePaddle,capturePaddle,finaliseFree. Delegates to provider-specific helpers (Stripe SDK, Paddle SDK) and persists external ids.CheckoutAssignmentService: generates or reuses tenant, writesTenantPackage,PackagePurchase, updates user role/status, dispatchesWelcome+ purchase receipts, and emits domain events (CheckoutCompleted).SyncCheckoutFromWebhookjob: invoked by webhook controllers with provider payload, looks upCheckoutSessionvia provider id, runs assignment if needed, records failure states.
Webhook Alignment
- Update
StripeWebhookControllerto resolveCheckoutSession::where('stripe_payment_intent_id', intentId); when event indicates success, transition toprocessing(if not already), enqueueSyncCheckoutFromWebhookto finish assignment, and markcompletedonce done. - Update
PaddleWebhookControllersimilarly usingpaypal_order_idorpaypal_subscription_id. - Webhooks become source-of-truth for delayed confirmations; wizard polls
GET /checkout/session/{id}untilcompleted.
Validation & Security
- All mutating routes use CSRF tokens and
authguard (session-based). AddEnsureCheckoutSessionOwnermiddleware enforcing that the session belongs torequest->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-sessionsartisan command). Command cancels open PaymentIntents and Paddle orders.
Frontend Touchpoints
Wizard Context Enhancements
- Extend
CheckoutWizardStateto includecheckoutSessionId,paymentStatus,paymentError,isProcessing,provider. - Add actions
initialiseSession(packageId),selectProvider(provider),updatePaymentStatus(status, payload). - Persist
checkoutSessionId+ status insessionStoragefor reload resilience (respect TTL).
PaymentStep Implementation Plan
- On mount (and whenever package changes), call
/checkout/sessionto 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 onceclient_secretarrives. - Stripe flow: submit button triggers
stripe.confirmCardPayment(clientSecret), handlerequires_action, then POST/checkout/session/{id}/stripe/confirm. On success, call/checkout/session/{id}/completeand 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}/freeand immediately advance. - Display status toasts based on
paymentStatus; show inline error block whenfailedwithfailure_reasonfrom API.
Confirmation Step & Profile Link
- Confirmation fetches
/checkout/session/{id}summary (package, next steps, admin URL) and surfaces the dashboard link. Updateresources/js/pages/Profile/Index.tsxto show "Checkout history" link pointing to marketing success page (from TODO item 4/5).
Provider Flows
Stripe (one-off and subscription)
- Session created (
draft). - Provider selected
stripe->awaiting_payment_method. createStripeIntentbuilds PaymentIntent with amount from package snapshot, metadata: session_id, package_id, user_id, tenant_id (if known), package_type.- Frontend confirms card payment. If Stripe returns
requires_action, wizard storesrequires_customer_actionand surfaces modal. - Once Stripe marks intent
succeeded, backend transitions toprocessing, callsCheckoutAssignmentService, and markscompleted. - 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)
- Session
draft-> providerpaypal. createPaddleOrderreturns order id + approval link. Metadata includes session, tenant, package, package_type.- After approval,
capturePaddleOrderverifies capture status; onCOMPLETED, transitions toprocessing. - Assignment service runs, storing order id as
provider_id. For subscriptions, capture handler stores subscription id and updates tenant subscription status. - Webhooks handle late captures or cancellations (updates session ->
failedorcancelled).
Free Packages
activateFreePackagebypasses payment providers, writes TenantPackage + PackagePurchase withprovider_id = 'free', markscompleted, and pushes the user to confirmation immediately.
Migration Strategy
- Phase 1 (current): land schema, services, new API routes; keep legacy MarketingController flow for fallback.
- Phase 2: wire the new wizard PaymentStep behind feature flag
checkout_v2(in.env/ config). Run internal QA with Paddle sandbox. - Phase 3: enable feature flag for production tenants, monitor Paddle events, then delete legacy marketing payment paths and routes.
- Phase 4: tighten webhook logic and remove
MarketingController::checkout,::paypalCheckout,::stripeSubscriptiononce 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-sessionsto confirm PaymentIntents are cancelled and sessions flaggedcancelled.
Open Questions / Follow-Ups
- Map package records to Stripe price ids and Paddle plan ids (store on
packagestable or config?). - Confirm legal copy updates for new checkout experience before GA.
- Align email templates (welcome, receipt) with new assignment service outputs.