Payments — Improvement & Growth Plan
Package: capell-app/payments · Kind: package · Tier: premium · Product group: Capell Commerce · Bundle: commerce · Status: Draft
1. Snapshot
Section titled “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 PaymentFulfillmentHandlers (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
Section titled “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)
Section titled “2. Improvements (existing functionality)”-
Done/Shipped: Stripe webhook processing is queued after intake.
HandleStripeWebhookActionverifies and persists thePaymentWebhookEvent, returns the stored event to the controller, then dispatchesProcessStripeWebhookEventJobafter commit on the configured payments queue. The job is unique per stored event id and delegates mutation/fulfilment toProcessStripeWebhookEventAction, 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 -
Done/Shipped: Lock the webhook event row before processing to close the duplicate race.
ProcessStripeWebhookEventActionnow performs the status read/guard inside thelockForUpdate()transaction and refreshes the row after rollback before recording failures. Coverage proves terminalprocessed/ignoredevents do not replay checkout recording or fulfillment side effects. —src/Actions/ProcessStripeWebhookEventAction.php,tests/Unit/HandleStripeWebhookActionTest.php— S -
Promote refund issuance and billing-portal creation into the
PaymentGatewaycontract. — The contract only declarescreateCheckoutSession.CreateBillingPortalSessionActioncallsHttp::…->post('/v1/billing_portal/sessions')directly, bypassing the gateway abstraction the package is built around. Anything provider-specific that isn’t checkout leaks rawHttp::calls into Actions, undermining the “provider-neutral” promise. AddcreateBillingPortalSession()(and futureissueRefund(),capturePaymentIntent()) to the interface and route through the bound gateway. —src/Contracts/PaymentGateway.php,src/Actions/CreateBillingPortalSessionAction.php— M -
Done/Shipped: Cast
amounttointegeron every money-bearing model.PaymentRefund,PaymentIntent, andPaymentDisputecastamounttointeger;CheckoutSessioncastsamount_subtotalandamount_totaltointeger; subscriptions have no money columns, and paid-download entitlements only track non-moneydownload_count. Evidence:MoneyModelAmountCastTestproves 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 -
Validate
success_url/cancel_urlagainst an allow-list before redirect. —CreateFormPaymentCheckoutUrlActioncarries caller-suppliedsuccess_url/cancel_urlquery 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 -
Done/Shipped: Make paid-download fulfillment fully re-delivery-safe (don’t refresh expiry on replays). Evidence:
GrantPaidDownloadAccessActiononly fillsexpires_atandfulfilled_atwhen 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 -
Done/Shipped: Record fulfillment results. —
FulfillCompletedCheckoutSessionActionpersists handler result summaries onto checkout-session metadata, including success/failure, message, metadata, andrecorded_at;BuildPaymentsHealthReportActioncounts 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 -
Done/Shipped: webhook replay / reconcile console commands. —
capell:payments:webhooks:reprocessre-runs failed stored webhook events or a specific event id through the existing row-locked processor, andcapell:payments:webhooks:reconcilereports 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)
Section titled “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).
IssuePaymentRefundActionposts to Stripe refunds with a deterministic idempotency key, records the returned refund viaRecordPaymentRefundAction, andPaymentIntentResourceexposes 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.
CheckoutModesupportspayment|subscription|setupbut there is nocapture_method=manualpath and nocapturePaymentIntentaction, despitePaymentIntentStatus::RequiresCapturebeing a modelled state. - Done/Shipped: idempotency keys on outbound calls.
CreateCheckoutSessionActionnow fills a deterministic checkout idempotency key when callers do not supply one, andCreateBillingPortalSessionActionsends 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/Openrows). The health check counts events but cannot self-heal. - 3DS / SCA surfacing. Stripe Checkout handles SCA, but
PaymentIntentStatus::RequiresActionis 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
currencycolumn (correct), but there is no money-formatting/zero-decimal-currency helper; Filament rendersamountas a raw->numeric()integer (e.g.1099not£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
PaymentProviderenum are explicitly designed for this, but onlyStripeexists. 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_subtotalvsamount_totalare stored but tax breakdown is not modelled. - Dunning / subscription recovery.
PastDueis modelled but there is no retry/notify workflow.
4. Issues / Risks
Section titled “4. Issues / Risks”- Done/Shipped: Webhook duplicate-processing guard now runs under a row lock.
ProcessStripeWebhookEventActionlocks the webhook row before checking terminal status, so duplicate deliveries cannot both pass theReceivedguard before processing. SQLite package tests cover the terminal-state contract; true blocking semantics still depend on the production database engine’slockForUpdate()support. —src/Actions/ProcessStripeWebhookEventAction.php,tests/Unit/HandleStripeWebhookActionTest.php - Failed queued webhook processing records
Failedfor operator recovery.ProcessStripeWebhookEventActionmarks 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.
PaidDownloadRouteTestcovers signed paid-download success, unsigned rejection, expired-entitlement 410 responses, and missing-file 404 responses;PaymentsPackageBoundaryTestcovers 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, andCheckoutSessionexplicitly cast amount fields tointeger;MoneyModelAmountCastTesthydrates 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), Stripeunit_amountis sent as integer, no float arithmetic observed. Stripeamount_subtotal/amount_totalmapped viais_int/integerValueguards. No rounding logic to audit (no division). Good. - Secrets & PII handling is strong. Webhook signature uses
hash_hmac+hash_equalswith a 300s timestamp tolerance (VerifyStripeWebhookSignatureAction); secrets resolved via settings/env, never hard-coded;provider_payload/payloadareencrypted:arrayon all models andPaymentCustomer.email/PaymentDownloadEntitlement.emailareencrypted;signature_header_hashstores a SHA-256 of the header, not the header. Tables are registered viaCapellCore::registerProtectedTable. - Public-output safety holds. All Filament resources expose only an
indexList page (no Create/Edit/View pages); policies extendAbstractPaymentsResourcePolicy; manifest grants onlyView:*permissions. Customer-portal providers return hydratedPortalDashboardItemData/self-service items and resolve via Actions; controllers redirect to provider URLs rather than rendering internals. No public Blade in the package. Consistent withcacheSafety.cacheable: false. - Performance budget caveat. Manifest sets
frontendRenderBudgetMs: 0(no frontend render) andadminQueryBudget: 40. The customer-portal dashboard provider issues 2subscriptions()aggregate queries per account (description()+activeSubscriptionCount()) — fine for one account, but if a portal renders many providers this should be consolidated. Citesrc/Support/CustomerPortal/PaymentsPortalDashboardItemProvider.php. - i18n. Enums and Filament labels are translated (
capell-payments::generic.*),resources/lang/en/{generic,settings}.phppresent. Health-report$issues[]strings ('Stripe secret key is not configured.') are hard-coded English —src/Actions/BuildPaymentsHealthReportAction.php.
5. Marketplace & Selling
Section titled “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
Section titled “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 |