Registration and Payments
Main routes
| Step | Route |
|---|---|
| Register | conreg_register (members/register/{eid}) |
| Checkout | conreg_checkout (members/checkout/{payid}/{key}) |
| Thank you | conreg_thanks (members/thanks/{eid}) |
Variants exist for fan table and portal flows using a return context.
Sequence
sequenceDiagram
participant U as User
participant R as Registration form
participant M as Member save
participant C as Checkout form
participant S as Stripe
participant T as Thank-you route
U->>R: Submit members
R->>M: Save members and payment rows
U->>C: Open checkout link
C->>S: retrieveSession(latest known session ID)
alt no session yet, or existing one expired/wrong amount
C->>S: createCheckoutSession()
C-->>U: Hand off to Stripe (or reuse an existing, still-open session)
U->>S: Pay
else existing session already paid
C->>M: Mark payment/member/upgrades complete
C->>T: Redirect
else existing session still open, same amount
C-->>U: Reuse it (no second session created)
end
Every visit to the checkout route re-checks that specific payment's own
most recently created session directly via StripeService::retrieveSession()
- there is no Stripe webhook, and nothing scans Stripe's global events
list. This was a deliberate choice to keep Stripe configuration simple;
see "Reconciliation fallback" below for what covers the gap that leaves.
Implementation notes
- Zero-balance checkout completes without a Stripe redirect.
- Auto-approval behavior is config driven (
payments.auto_approve). - Before building Stripe line items, checkout recomputes each member's
price from their current persisted data (not the original registration
submission), so an admin edit to a member's pricing-relevant details
after registration isn't charged at a stale price. See
docs/architecture/pricing.md. - A payment can have more than one Stripe session recorded against it in
conreg_payment_sessions(e.g. an earlier one expired, or its amount no longer matches after a pricing recompute) -PaymentStorage::loadSessionIds()returns them most-recent-first, and only the latest is ever checked or reused. Checkout deliberately reuses a still-open, same-amount session rather than always creating a new one, so a double-click, a page reload during the "Transferring to Stripe" wait, or reopening the payment link in a second tab can't produce two live sessions - and so two separate successful payments - for the same payment record.
Reconciliation fallback
Checking a session only happens when someone visits the checkout route -
if a member pays on Stripe's hosted page but their browser never returns
(closed tab, network drop), nothing above would ever revisit that payment.
PaymentReconciliationCronHooks (#[Hook('cron')]) calls
PaymentCompletionService::reconcilePendingPayments() on every cron run,
which sweeps unpaid payments between 10 minutes and 7 days old, checks
each one's latest session directly, and marks it complete if Stripe now
reports it paid. PaymentCompletionService is also what Checkout itself
calls to mark a payment complete, so both paths share the same completion
logic (payment/member/upgrade state, confirmation email, portal role
grant). Marking is idempotent either way - is_paid/paidDate are only
ever set once, so a member returning to checkout normally after cron has
already caught their payment just sees the thank-you page as usual.