Payments
Stripe Connect direct charges, invoices, and refunds.
Mosaic processes online payments through Stripe Connect direct
charges. Each event settles on a connected Stripe account owned by its
organizer (events.stripeAccountId, validated against the organizer's
registered accounts), so the organizer is the merchant of record:
the charge, the payout, and the Stripe fees all live on the organizer's
account. Mosaic takes no application fee.
Dynamic payment methods. PaymentIntents use Stripe's automatic payment
methods, so each connected account controls its enabled methods and Stripe
filters them for the transaction. Mosaic extends expiry to a 21-day failsafe
after Stripe accepts a delayed payment into processing; instruction-based requires_action
methods receive a bounded seven-day completion window.
Account snapshotting. The connected account a PaymentIntent is created on is recorded on the payment row and used for every later action on it (refund execution, webhook account matching, best-effort cancellation) — never re-derived from the event's live settlement account. If staff rotate an event's connected account, payments taken under the old one keep working correctly against it.
Payment flow
- Checkout confirms the order. The order is
pendingPaymentand its ticket capacity is already held (no availability window opens while the buyer pays). - The payment step requests a PaymentIntent. The amount is re-derived server-side from the order's immutable line snapshot — the client never supplies money values. The buyer completes payment via Stripe Elements, scoped to the event's connected account.
- Stripe notifies
POST /stripe/webhook(signature-verified, idempotent by event id). Onpayment_intent.succeeded, the payment row becomessucceeded, the order becomespaid, the invoice is issued, and theorder.paidwebhook is fanned out to subscribed endpoints.
Unpaid online-payment orders ordinarily expire after one hour:
capacity is released, tickets are cancelled, and the PaymentIntent is
cancelled best-effort. A payment_intent.processing event replaces that
deadline with a 21-day failsafe because Stripe has accepted an asynchronous attempt. A later
failure opens a fresh one-hour retry window and emails the buyer; a later
success settles normally. Money that still lands after an order has expired
is automatically refunded because the released seats may already be
resold, so the order is never revived.
Nonzero bank-transfer orders require configured event bank instructions. Finance
sets the receiving account and instructions.paymentWindowDays in Bank transfer
settings. Confirmation snapshots those instructions and fixes the order's
absolute payment deadline; the order remains pendingPayment until staff record
the received transfer or its stored paymentExpiresAt triggers expiry.
Changing the bank instructions or payment window affects new orders only.
Accepted orders retain their original destination and deadline, including the
bankTransfer.paymentDueAt snapshot after settlement clears the operational
expiry. The event's unpaidOrderExpiryDays field is retained for legacy
configuration compatibility; it does not control new bank-transfer deadlines.
Older unpaid orders continue to use their existing stored paymentExpiresAt.
Missing historical bank instructions or deadline snapshots are not reconstructed
from current settings.
Invoices
- Numbers are gapless per event (
INV-00001, …), assigned in the same transaction that creates the invoice. - The invoice stores an immutable snapshot of buyer, lines, totals, and tax breakdown at issue time. Later order edits never touch it.
- Zero-gross orders (free tickets, complimentary tickets) receive no invoice.
- Refunds issue correction invoices (credit notes): negative
amounts, numbered from the same sequence, referencing the original
invoice (
correctionOf) and the refund that triggered them. Tax is reversed proportionally across the order's tax-rate groups when the refund amount does not map 1:1 to ticket prices. - The stored artifact is a print-ready HTML document (label links "Invoice (HTML)"). A true PDF pipeline is a tracked follow-up.
Refunds
Refunds follow a strict split: domain state moves atomically, money moves asynchronously.
- Staff create a refund: tickets are cancelled, capacity released, and
credentials revoked in one transaction, with a
refundsrow tracking the money. - Stripe-paid orders: the refund executes against Stripe asynchronously
(
pending → succeeded/failed). Stripe's initial API response to a refund CREATE call is not always terminal (some methods settle refunds asynchronously) — settlement confirmation arrives via thecharge.refundedorrefund.updatedwebhook, whichever lands first. - Bank-transfer orders: the refund is marked
manual(settled by hand) and the correction invoice is issued immediately. - Refunds initiated directly in the Stripe dashboard are reconciled into Mosaic as money-only refund rows; ticket unwinding remains an explicit staff command.
- Disputes (
charge.dispute.created) are recorded in the audit trail.
Configuration
| Variable | Where | Purpose |
|---|---|---|
STRIPE_SECRET_KEY | Convex deployment | Platform secret key; connected accounts are addressed per call |
STRIPE_WEBHOOK_SECRET | Convex deployment | Signing secret of the /stripe/webhook endpoint |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | Next.js | Stripe.js on the payment step |
Point the Stripe webhook endpoint at
https://<your-deployment>.convex.site/stripe/webhook and subscribe it
to payment_intent.succeeded, payment_intent.processing,
payment_intent.requires_action,
payment_intent.payment_failed, payment_intent.canceled,
charge.refunded, refund.updated, and
charge.dispute.created. When using connected accounts, register it as
a Connect endpoint so events from direct charges on connected
accounts are delivered.