# Payments — Improvement & Growth Plan

> Package: capell-app/payments · Kind: package · Tier: premium · Product group: Capell Commerce · Bundle: commerce · Status: Draft

## 1. Snapshot

Payments is a provider-neutral payment record layer with a native Stripe Checkout foundation, surfacing on `admin`, `frontend`, and `console`. It ships eight Eloquent models / tables (`payment_customers`, `payment_checkout_sessions`, `payment_intents`, `payment_subscriptions`, `payment_webhook_events`, `payment_refunds`, `payment_disputes`, `payment_download_entitlements`) and seven read-only Filament resources, all driven through `lorisleiva/laravel-actions` and `spatie/laravel-data` boundary objects. Core flows: `CreateCheckoutSessionAction` → `StripePaymentGateway` (`src/Support/Gateways/StripePaymentGateway.php`), `HandleStripeWebhookAction` → `VerifyStripeWebhookSignatureAction` → record actions → `FulfillCompletedCheckoutSessionAction` dispatching tagged `PaymentFulfillmentHandler`s (`src/Actions/`, `src/Support/Fulfillment/PaidDownloadFulfillmentHandler.php`). Manifest declares `requires: [admin, core]`, `supports: [access-gate, customer-portal, form-builder]`, one critical health check (`Capell\Payments\Health\PaymentsHealthCheck`), `cacheSafety.cacheable: false / sensitiveOutput: true`, and `adminQueryBudget: 40`. Marketplace summary now leads with the buyer outcome: taking one-off payments, donations, subscriptions, paid downloads, and gated access through Stripe Checkout while keeping a provider-neutral record layer. Screenshots: **5** committed Capell runner captures covering checkout sessions, webhook events, settings, customer portal billing, and Form Builder checkout.

## Completed Improvement Slices

- **2026-06-05:** Reconciled marketplace/composer copy around native Stripe Checkout, provider-neutral payment records, customer portal billing, and Form Builder payment fields after webhook locking, paid-download replay, money-cast, and return URL fixes.
- **2026-06-06:** Added the Payments screenshot-runner contract, captured the five minimum marketplace views, and promoted the committed PNGs into `capell.json`.

## 2. Improvements (existing functionality)

1. **Done/Shipped: Stripe webhook processing is queued after intake.** `HandleStripeWebhookAction` verifies and persists the `PaymentWebhookEvent`, returns the stored event to the controller, then dispatches `ProcessStripeWebhookEventJob` after commit on the configured payments queue. The job is unique per stored event id and delegates mutation/fulfilment to `ProcessStripeWebhookEventAction`, keeping the HTTP webhook response path short while preserving the row-lock duplicate guard. — `src/Actions/HandleStripeWebhookAction.php`, `src/Jobs/ProcessStripeWebhookEventJob.php`, `src/Http/Controllers/StripeWebhookController.php` — **M**

2. **Done/Shipped: Lock the webhook event row before processing to close the duplicate race.** `ProcessStripeWebhookEventAction` now performs the status read/guard inside the `lockForUpdate()` transaction and refreshes the row after rollback before recording failures. Coverage proves terminal `processed`/`ignored` events do not replay checkout recording or fulfillment side effects. — `src/Actions/ProcessStripeWebhookEventAction.php`, `tests/Unit/HandleStripeWebhookActionTest.php` — **S**

3. **Promote refund issuance and billing-portal creation into the `PaymentGateway` contract.** — The contract only declares `createCheckoutSession`. `CreateBillingPortalSessionAction` calls `Http::…->post('/v1/billing_portal/sessions')` directly, bypassing the gateway abstraction the package is built around. Anything provider-specific that isn't checkout leaks raw `Http::` calls into Actions, undermining the "provider-neutral" promise. Add `createBillingPortalSession()` (and future `issueRefund()`, `capturePaymentIntent()`) to the interface and route through the bound gateway. — `src/Contracts/PaymentGateway.php`, `src/Actions/CreateBillingPortalSessionAction.php` — **M**

4. **Done/Shipped: Cast `amount` to `integer` on every money-bearing model.** `PaymentRefund`, `PaymentIntent`, and `PaymentDispute` cast `amount` to `integer`; `CheckoutSession` casts `amount_subtotal` and `amount_total` to `integer`; subscriptions have no money columns, and paid-download entitlements only track non-money `download_count`. Evidence: `MoneyModelAmountCastTest` proves numeric database values hydrate as ints and nullable checkout amounts remain null. — `src/Models/PaymentRefund.php`, `src/Models/PaymentIntent.php`, `src/Models/PaymentDispute.php`, `src/Models/CheckoutSession.php`, `tests/Unit/MoneyModelAmountCastTest.php` — **S**

5. **Validate `success_url` / `cancel_url` against an allow-list before redirect.** — `CreateFormPaymentCheckoutUrlAction` carries caller-supplied `success_url`/`cancel_url` query params into a signed route; they become the Stripe redirect targets. Even behind a signed URL, an operator-generated link could be crafted to redirect post-payment to an attacker host (open-redirect / phishing). Constrain to the site's own host(s) or a configured allow-list. — `src/Actions/CreateFormPaymentCheckoutUrlAction.php`, `src/Http/Controllers/CreateFormPaymentCheckoutController.php` — **S**

6. **Done/Shipped: Make paid-download fulfillment fully re-delivery-safe (don't refresh expiry on replays).** Evidence: `GrantPaidDownloadAccessAction` only fills `expires_at` and `fulfilled_at` when creating a new `(checkout_session_id, download_key)` entitlement, while the webhook regression test proves repeated checkout fulfillment updates descriptors without extending access or resetting first fulfillment time. — `src/Actions/GrantPaidDownloadAccessAction.php`, `tests/Unit/HandleStripeWebhookActionTest.php` — **S**

7. **Done/Shipped: Record fulfillment results.** — `FulfillCompletedCheckoutSessionAction` persists handler result summaries onto checkout-session metadata, including success/failure, message, metadata, and `recorded_at`; `BuildPaymentsHealthReportAction` counts failed fulfilment summaries and reports them as a failed health state. — `src/Actions/FulfillCompletedCheckoutSessionAction.php`, `src/Actions/BuildPaymentsHealthReportAction.php`, `src/Data/PaymentsHealthReportData.php`, `tests/Unit/PaymentsHealthReportTest.php` — **M**

8. **Done/Shipped: webhook replay / reconcile console commands.** — `capell:payments:webhooks:reprocess` re-runs failed stored webhook events or a specific event id through the existing row-locked processor, and `capell:payments:webhooks:reconcile` reports received/processed/ignored/failed/stale-received counts for operator recovery. — `src/Console/Commands/ReprocessPaymentWebhookEventsCommand.php`, `src/Console/Commands/ReconcilePaymentWebhooksCommand.php`, `src/Actions/ReprocessPaymentWebhookEventsAction.php`, `src/Actions/ReconcilePaymentWebhooksAction.php` — **M**

## 3. Missing Features (gaps)

Mapped against declared `capabilities[]` and payment-platform norms. Capabilities present: `stripe-checkout`, `one-off-payments`, `subscriptions`, `donations`, `paid-downloads`, `paid-gated-access`, `form-payment-fields`, `stripe-webhooks`, `refund-records`, `dispute-records`, `payments-admin`, `payment-fulfillment-handlers`, `stripe-billing-portal`, `subscription-entitlements`, `customer-portal-billing-dashboard`, `customer-portal-payments-feed`.

**Table-stakes gaps:**

- **Done/Shipped: Refund issuance (not just records).** `IssuePaymentRefundAction` posts to Stripe refunds with a deterministic idempotency key, records the returned refund via `RecordPaymentRefundAction`, and `PaymentIntentResource` exposes a guarded admin row action for succeeded intents. `src/Actions/IssuePaymentRefundAction.php`, `src/Filament/Resources/PaymentIntents/PaymentIntentResource.php`, `tests/Unit/IssuePaymentRefundActionTest.php`.
- **Partial captures / authorize-then-capture.** `CheckoutMode` supports `payment|subscription|setup` but there is no `capture_method=manual` path and no `capturePaymentIntent` action, despite `PaymentIntentStatus::RequiresCapture` being a modelled state.
- **Done/Shipped: idempotency keys on outbound calls.** `CreateCheckoutSessionAction` now fills a deterministic checkout idempotency key when callers do not supply one, and `CreateBillingPortalSessionAction` sends a deterministic billing-portal idempotency key on Stripe requests. Caller-supplied checkout keys remain preserved. — `src/Actions/GeneratePaymentGatewayIdempotencyKeyAction.php`, `src/Actions/CreateCheckoutSessionAction.php`, `src/Actions/CreateBillingPortalSessionAction.php` — **S**
- **Reconciliation.** No mechanism to pull authoritative state from Stripe and detect drift (missed webhooks, stuck `Received`/`Open` rows). The health check counts events but cannot self-heal.
- **3DS / SCA surfacing.** Stripe Checkout handles SCA, but `PaymentIntentStatus::RequiresAction` is recorded with no follow-up signalling to the customer portal — a stalled payment is invisible to the buyer.
- **Multi-currency presentation.** Amounts are stored as integer minor units with a `currency` column (correct), but there is no money-formatting/zero-decimal-currency helper; Filament renders `amount` as a raw `->numeric()` integer (e.g. `1099` not `£10.99`), and JPY/zero-decimal currencies will display wrong.

**Differentiators (beyond table-stakes):**

- **Second gateway** (PayPal / Mollie / GoCardless direct debit). The provider-neutral records and `PaymentProvider` enum are explicitly designed for this, but only `Stripe` exists. A second driver is the single biggest proof of the "provider-neutral" claim.
- **Invoices / receipts.** No PDF receipt or invoice generation on completed payment — high-value for donations and paid downloads.
- **Tax (Stripe Tax / VAT).** No tax collection or recording; `amount_subtotal` vs `amount_total` are stored but tax breakdown is not modelled.
- **Dunning / subscription recovery.** `PastDue` is modelled but there is no retry/notify workflow.

## 4. Issues / Risks

- **Done/Shipped: Webhook duplicate-processing guard now runs under a row lock.** `ProcessStripeWebhookEventAction` locks the webhook row before checking terminal status, so duplicate deliveries cannot both pass the `Received` guard before processing. SQLite package tests cover the terminal-state contract; true blocking semantics still depend on the production database engine's `lockForUpdate()` support. — `src/Actions/ProcessStripeWebhookEventAction.php`, `tests/Unit/HandleStripeWebhookActionTest.php`
- **Failed queued webhook processing records `Failed` for operator recovery.** `ProcessStripeWebhookEventAction` marks the stored event failed before rethrowing to the queue worker, so retries happen through queue policy instead of extending the Stripe HTTP request. A reprocess/reconcile command remains the operational recovery gap. — `src/Actions/ProcessStripeWebhookEventAction.php`, `src/Jobs/ProcessStripeWebhookEventJob.php`
- **Done/Shipped: Feature and Arch test suites now cover the public-route and package-boundary invariants.** `PaidDownloadRouteTest` covers signed paid-download success, unsigned rejection, expired-entitlement 410 responses, and missing-file 404 responses; `PaymentsPackageBoundaryTest` covers thin controllers, sensitive/non-cacheable frontend manifest settings, public-output authoring-marker absence, and strict-equality usage. The idempotency test (`HandleStripeWebhookActionTest.php:69`) still asserts _sequential_ redelivery only, not the concurrent race. — `tests/Feature/PaidDownloadRouteTest.php`, `tests/Arch/PaymentsPackageBoundaryTest.php`
- **Done/Shipped: Money amount casts match runtime arithmetic expectations.** `PaymentRefund`, `PaymentIntent`, `PaymentDispute`, and `CheckoutSession` explicitly cast amount fields to `integer`; `MoneyModelAmountCastTest` hydrates string-persisted numeric values and asserts strict integer values. — `src/Models/PaymentRefund.php`, `src/Models/PaymentIntent.php`, `src/Models/PaymentDispute.php`, `src/Models/CheckoutSession.php`, `tests/Unit/MoneyModelAmountCastTest.php`
- **Open-redirect via form-checkout return URLs** — see §2.5. — `src/Actions/CreateFormPaymentCheckoutUrlAction.php`
- **Money handling is otherwise correct.** Amounts are integer minor units end-to-end (`unsignedBigInteger`, `int`), Stripe `unit_amount` is sent as integer, no float arithmetic observed. Stripe `amount_subtotal`/`amount_total` mapped via `is_int`/`integerValue` guards. No rounding logic to audit (no division). Good.
- **Secrets & PII handling is strong.** Webhook signature uses `hash_hmac` + `hash_equals` with a 300s timestamp tolerance (`VerifyStripeWebhookSignatureAction`); secrets resolved via settings/env, never hard-coded; `provider_payload`/`payload` are `encrypted:array` on all models and `PaymentCustomer.email` / `PaymentDownloadEntitlement.email` are `encrypted`; `signature_header_hash` stores a SHA-256 of the header, not the header. Tables are registered via `CapellCore::registerProtectedTable`.
- **Public-output safety holds.** All Filament resources expose only an `index` List page (no Create/Edit/View pages); policies extend `AbstractPaymentsResourcePolicy`; manifest grants only `View:*` permissions. Customer-portal providers return hydrated `PortalDashboardItemData`/self-service items and resolve via Actions; controllers redirect to provider URLs rather than rendering internals. No public Blade in the package. Consistent with `cacheSafety.cacheable: false`.
- **Performance budget caveat.** Manifest sets `frontendRenderBudgetMs: 0` (no frontend render) and `adminQueryBudget: 40`. The customer-portal dashboard provider issues 2 `subscriptions()` aggregate queries per account (`description()` + `activeSubscriptionCount()`) — fine for one account, but if a portal renders many providers this should be consolidated. Cite `src/Support/CustomerPortal/PaymentsPortalDashboardItemProvider.php`.
- **i18n.** Enums and Filament labels are translated (`capell-payments::generic.*`), `resources/lang/en/{generic,settings}.php` present. Health-report `$issues[]` strings (`'Stripe secret key is not configured.'`) are hard-coded English — `src/Actions/BuildPaymentsHealthReportAction.php`.

## 5. Marketplace & Selling

**Done/Shipped: marketplace and Composer copy are buyer-facing.** `capell.json` now uses the improved 3–4 sentence description, `marketplace.summary` leads with the charge-through-Capell outcome, and `composer.json` mirrors the concise provider-neutral Stripe Checkout positioning. Static marketplace media now shows the five core payment workflows.

**Improved summary (1 sentence):**

> Take one-off payments, donations, subscriptions, paid downloads, and gated access on any Capell site through Stripe Checkout — with a provider-neutral record layer built to add more gateways later.

**Improved description (3–4 sentences):**

> Payments gives Capell a first-party way to charge customers without depending on an external store. It ships native Stripe Checkout for one-off purchases, recurring subscriptions, donations, paid file downloads, and paid gated access, plus signed webhook intake that keeps payments, subscriptions, refunds, and disputes in sync automatically. Every record is stored behind a provider-neutral model layer, so admins get read-only audit resources, customers get a self-service billing portal, and Form Builder fields can collect payment inline. Designed for revenue-generating sites that want checkout, fulfilment, and reconciliation handled inside the CMS.

**Screenshot / media gaps:** the minimum static gallery is now committed: checkout sessions, webhook events, Payments settings, customer portal billing, and Form Builder checkout handoff. A short GIF of the checkout-to-fulfilment flow would still carry the listing better than static media alone.

**Pricing / tier / bundle positioning.** `tier: premium`, `bundle: commerce`, license `paid`, certification `first-party`, support `priority` — appropriate for a money-touching package. Position as the entry point to **Capell Commerce** for sites that don't need full Shopify: cheaper/simpler than the `shopify-commerce` path, upgradeable to it.

**Cross-sell (via `supports[]` + Extension Suites):**

- **form-builder** — payment fields turn any form into a paid form/donation (already wired via `CreateFormPaymentCheckoutUrlAction`); headline this in the form-builder listing.
- **access-gate** — "pay to unlock" gated content via the gated-access fulfilment handler.
- **customer-portal** — billing/subscription/payment-history self-service (dashboard + self-service item providers already registered).
- **shopify-commerce** — position as the lightweight alternative and the natural upgrade target; cross-link both ways.

**Differentiators / value props / target buyer.** Value: native checkout + automatic webhook reconciliation + read-only audit trail, all inside Capell, no separate storefront. Differentiator vs `shopify-commerce`: no external platform, no product-catalog overhead, donations and paid-downloads first-class. Target buyer: charities/non-profits (donations), creators/publishers (paid downloads, gated access), and SMB sites taking occasional payments or subscriptions.

**Keywords/tags:** `stripe`, `checkout`, `payments`, `subscriptions`, `donations`, `paid-downloads`, `gated-access`, `webhooks`, `billing-portal`, `recurring-billing`, `form-payments`, `commerce`.

## 6. Prioritized Roadmap

| Item                                                                                                                                                                                                                                                                                                                  | Bucket | Effort | Impact | Section ref                                                                                                   |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------ | ------ | ------------------------------------------------------------------------------------------------------------- |
| Lock webhook event row before processing (close duplicate race)                                                                                                                                                                                                                                                       | Done   | S      | High   | §2.2, §4 — closed by locked processing guard and terminal-event no-replay coverage                            |
| Done/Shipped: Add Feature + Arch test suites (signed download and public-output safety). Evidence: `PaidDownloadRouteTest` covers signed download paths and `PaymentsPackageBoundaryTest` covers controller/package-boundary guardrails.                                                                              | Done   | M      | High   | §4                                                                                                            |
| Add marketplace screenshots/GIF                                                                                                                                                                                                                                                                                       | Done   | S      | High   | §5                                                                                                            |
| Shipped 2026-06-05: Rewrite marketplace summary + composer description                                                                                                                                                                                                                                                | Done   | S      | Medium | §5                                                                                                            |
| Done/Shipped: Cast `amount` to integer across money models. Evidence: model casts cover refunds, intents, disputes, and checkout subtotal/total fields; `MoneyModelAmountCastTest` proves numeric DB values hydrate as ints.                                                                                          | Done   | S      | Medium | §2.4, §4                                                                                                      |
| Done/Shipped: Validate form-checkout `success_url`/`cancel_url` (open-redirect). Evidence: `ValidateFormPaymentReturnUrlAction` allows local paths, requires absolute URLs to use trusted hosts, and rejects foreign/private/internal redirects before signed checkout URLs reach Stripe.                             | Done   | S      | High   | §2.5, §4                                                                                                      |
| Done/Shipped: Make paid-download fulfilment expiry replay-safe. Evidence: `HandleStripeWebhookActionTest` replays paid-download checkout fulfilment for the same provider session and proves the existing entitlement keeps its original `expires_at` and `fulfilled_at` while new entitlements still receive expiry. | Done   | S      | Medium | §2.6                                                                                                          |
| Move webhook processing to a queued job                                                                                                                                                                                                                                                                               | Done   | M      | High   | §2.1 — shipped: webhook intake persists the event and dispatches `ProcessStripeWebhookEventJob` after commit. |
| Done/Shipped: Implement refund issuance (action + admin button)                                                                                                                                                                                                                                                       | Done   | M      | High   | §3                                                                                                            |
| Done/Shipped: Persist fulfilment results + surface failures in health report                                                                                                                                                                                                                                          | Done   | M      | Medium | §2.7, §3                                                                                                      |
| Done/Shipped: Generate idempotency keys for all outbound gateway calls                                                                                                                                                                                                                                                | Done   | S      | Medium | §3                                                                                                            |
| Done/Shipped: Money-formatting helper (zero-decimal currencies) for admin/portal                                                                                                                                                                                                                                      | Done   | S      | Medium | §3, §4                                                                                                        |
| Done/Shipped: Webhook reprocess / reconcile console commands                                                                                                                                                                                                                                                          | Done   | M      | Medium | §2.8, §3                                                                                                      |
| Promote billing-portal/refund/capture into `PaymentGateway` contract                                                                                                                                                                                                                                                  | Later  | M      | Medium | §2.3, §3                                                                                                      |
| Add a second gateway driver (proves provider-neutral claim)                                                                                                                                                                                                                                                           | Later  | L      | High   | §3, §5                                                                                                        |
| Invoices/receipts + Stripe Tax recording                                                                                                                                                                                                                                                                              | Later  | L      | Medium | §3                                                                                                            |