switched to paddle inline checkout, removed paypal and most of stripe. added product sync between app and paddle.
This commit is contained in:
@@ -2,16 +2,16 @@
|
||||
|
||||
## Goals
|
||||
- Replace the legacy marketing checkout flow with a single `CheckoutController` that owns auth, payment, and confirmation steps.
|
||||
- Support Stripe card payments and PayPal orders with a consistent state machine that can be extended to other providers.
|
||||
- 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, PayPal order, etc.).
|
||||
- **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, PayPal) map incoming provider events back to `CheckoutSession` rows to guarantee reconciliation and support 3DS / async capture paths.
|
||||
- **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`).
|
||||
|
||||
@@ -22,7 +22,7 @@ State constants live on `CheckoutSession` (`status` column, enum):
|
||||
| --- | --- | --- |
|
||||
| `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, PayPal approval window open, or additional customer steps needed. | `processing`, `failed`, `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` |
|
||||
@@ -49,34 +49,34 @@ Create Eloquent model `App\Models\CheckoutSession` with casts for JSON columns a
|
||||
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, PayPal client id, feature flags).
|
||||
- `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` (`createPayPalOrder`): create order with `custom_id` payload (session, tenant, package) and return `{order_id, approve_url}`.
|
||||
- `POST /checkout/session/{session}/paypal/capture` (`capturePayPalOrder`): capture order server-side, transition to `processing` if status `COMPLETED`.
|
||||
- `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).
|
||||
|
||||
Stripe/PayPal routes remain under `routes/web.php` but call into new service classes; legacy marketing payment methods are removed once parity is verified.
|
||||
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`, `initialisePayPal`, `capturePayPal`, `finaliseFree`. Delegates to provider-specific helpers (Stripe SDK, PayPal SDK) and persists external ids.
|
||||
- `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 `PayPalWebhookController` similarly using `paypal_order_id` or `paypal_subscription_id`.
|
||||
- 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 PayPal orders.
|
||||
- Abandon expired sessions via scheduler (`checkout:expire-sessions` artisan command). Command cancels open PaymentIntents and Paddle orders.
|
||||
|
||||
## Frontend Touchpoints
|
||||
|
||||
@@ -89,7 +89,7 @@ Stripe/PayPal routes remain under `routes/web.php` but call into new service cla
|
||||
- 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.
|
||||
- PayPal flow: render PayPal Buttons with `createOrder` -> call `/checkout/session/{id}/paypal/order`; `onApprove` -> POST `/checkout/session/{id}/paypal/capture`, then `/checkout/session/{id}/complete`.
|
||||
- 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.
|
||||
|
||||
@@ -106,10 +106,10 @@ Stripe/PayPal routes remain under `routes/web.php` but call into new service cla
|
||||
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.
|
||||
|
||||
### PayPal (one-off and subscription)
|
||||
### Paddle (one-off and subscription)
|
||||
1. Session `draft` -> provider `paypal`.
|
||||
2. `createPayPalOrder` returns order id + approval link. Metadata includes session, tenant, package, package_type.
|
||||
3. After approval, `capturePayPalOrder` verifies capture status; on `COMPLETED`, transitions to `processing`.
|
||||
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`).
|
||||
|
||||
@@ -118,18 +118,18 @@ Stripe/PayPal routes remain under `routes/web.php` but call into new service cla
|
||||
|
||||
## 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 Stripe/PayPal sandbox.
|
||||
3. **Phase 3**: enable feature flag for production tenants, monitor Stripe/PayPal events, then delete legacy marketing payment paths and routes.
|
||||
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 + PayPal SDK test doubles to simulate success, requires_action, cancellation, and ensure state machine behaves.
|
||||
- **Playwright**: wizard flow covering Stripe happy path, Stripe 3DS stub, PayPal approval, failure retry, free package shortcut, session resume after refresh.
|
||||
- **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 PayPal plan ids (store on `packages` table or config?).
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user