Payment processing with Stripe, Square, and SumUp.
A provider-agnostic payment interface with adapters for all three providers. Handles checkout sessions, webhook verification, refunds, and idempotent payment processing.
Checkout uses one tax assessment for the full booking. Added VAT raises the total. Included VAT keeps the listed total. Booking fees stay outside VAT. Percentage deposits use the total with tax. Fixed deposits keep their stated amount. The unpaid assessment remains owed. Signed metadata retains the charged tax policy. Later settings changes do not change a checkout in progress. Old sessions without that field use No tax. Balance payments settle saved debt without another tax assessment. An attendee save applies current settings to the original price basis. It adds correction entries and a new assessment revision. It never charges or refunds money. Refunds reverse all physical events in the assessment chain.
Provider Interface
PaymentProvider defines the common contract:
- Create single and multi-listing checkout sessions
- Validate incoming webhooks using each provider's checks
- Retrieve session details and process refunds
Error subclass for user-facing payment validation errors (e.g. invalid phone number). These propagate to the user through the guarded runners.
Build a checkout's signed metadata the way the given provider needs it, with the caps read from the provider registry: build the logical shape within the per-value and entry caps, pack the small fields into one entry when the provider needs that to fit, and enforce the caps on the shape that reaches the wire. SumUp's caps are unbounded (its metadata is stored locally, never sent to the provider), which makes the enforcement a no-op for it.
Build checkout session metadata from booking data (items already compact).
Render ticket and extra charges in the same order for every provider.
Cache a provider API client keyed on its config. Reuses the cached client while the config is unchanged and recreates it when the config changes; returns null when the provider is unconfigured.
Build a CheckoutItem for one listing — the one constructor every
real-listing checkout line is built from. purchase carries the purchase
context that decides the unit (a renewal prices the same tier by its
months per unit); absent means an ordinary purchase. Package provenance
is not a listing fact, so a member line spreads its packageGroupId over
the result.
The notification entries for the booking rows a create just wrote: one per real booking line, each paired with its listing. A quantity-0 ghost line books nothing, so it never notifies.
Create a withClient helper that runs an operation with a lazily-resolved client. Returns null if the client is not available or the operation fails.
Only the fields that grow with what the buyer selected can realistically exceed the per-value limit. Form validation already holds every other field well below the smallest provider limit of 255.
Normalize validated session metadata into the canonical SessionMetadata shape.
Resolve the provider for callbacks and completion of payments that already exist. New sales use getActivePaymentProvider.
Validate that every metadata value is text and required fields are present.
True when the listing still has spots for this quantity on this date (no date for a date-less listing, whose capacity is one running total).
Load one explicitly named provider implementation.
Collapse the packable small fields into one JSON b entry, dropping them
from the top level. Falsy values are omitted (the "" = absent convention), so
the b entry only appears when at least one packed field is actually present.
The one door every webhook body comes through. A body that is not JSON is refused the same way for every provider. What the body then means differs — a signed provider posts the event itself, SumUp posts two fields we build one from — so each provider passes its own reading, run outside the catch so a bug in it never reads as bad JSON. A reading that answers nothing means the body is JSON this provider cannot read, which earns the same refusal.
Process a single-listing booking.
Convert single-listing answerIds to the per-listing format used in metadata
Convert registration line items to compact, edge-tagged booking items (v2).
Each package member line carries ITS OWN package edge (k:"p", r=its group
id) so the webhook can revalidate each line's nodeKey — an order can book
several packages, so the edge is per line, never order-wide; folded children
(in allocations) and standalone lines stay untagged. See signed-metadata.ts.
Normalise a provider timestamp to the ledger's canonical ISO 8601 form
(YYYY-MM-DDTHH:mm:ss.sssZ), or undefined when it's absent or unparseable.
Compact the resolved modifier specs to id/quantity references for metadata.
Wrap a checkout operation, converting PaymentUserError to { error } and letting unexpected failures propagate. Used by both provider adapters.
Operations every configured payment provider supplies.
Booking result — callers map this to their response format
Registration intent for checkout (one or more listings)
Single item within a checkout — one bookable PATH. A listing booked through two overlapping packages (or a package plus its own standalone row) in one order is one item per path, each with its own quantity and price.
Result of creating a checkout session: success carries the session id and
checkout URL, a user-facing error carries error, and null means the
provider is not configured. Unexpected failures throw.
Run an operation with the lazily-resolved client. Returns null when the client is unconfigured or the operation fails (unless the error should propagate). The named type keeps the contract visible to callers of the widely-used provider runners so a signature drift fails at the definition instead of leaking to callers.
Shared shape for a provider credential check in connection-test results.
Provider implementation for work on an existing payment.
A modifier resolved for a specific checkout — the input the pricing pipeline
applies. Eligibility (scope, stock, codes) is decided upstream; by the time a
spec reaches pricing it is known to apply. value is the signed calc value
(see modifierDelta); listingIds scopes which items it is charged on
(null = the whole order); quantity is how many the buyer took (1 for an
automatic or code modifier, more for an opt-in add-on).
Supported payment provider identifiers
Valid payment status value
Everything retrieving a checkout session by id can come back as.
Validated logical metadata. Missing wire values are represented by "";
provider-specific packing is removed at extraction.
Set up a webhook endpoint for a provider. Some providers (e.g. Stripe) support programmatic creation; recreating any existing endpoint returns a fresh signing secret. Shared by the provider interface and each provider's own implementation so the signature can't drift.
A validated payment session returned after checkout completion
Everything resolving a webhook's session can come back as: the session; "skip" to acknowledge without processing; "retry" to answer with the fixed retryable refusal; a rejection carrying a paid charge the boundary could not read; or null for an event that is provably not ours.
Result of webhook endpoint setup
Result of webhook signature verification
Stubbable API for internal calls (testable via spyOn, like stripeApi/squareApi)
Schema for valid payment status values. "failed" is a terminal non-payment (declined or expired checkout) — distinct from "unpaid", which may still complete.
Usage
import * as mod from "docs/payments.ts";