Skip to main content

Checkout

The checkout module lives in the standalone @saasframe/checkout package and adds Phase A pay links to Open Saasframe. It is intentionally decoupled from sales: merchants can collect one-off payments through shareable URLs without creating quotes or orders.

For day-to-day operator workflow, see the Checkout Pay Links user guide.

What Phase A delivers

  • Link templates for reusable defaults
  • Pay links with fixed, custom_amount, and price_list pricing modes
  • Public pay pages with password protection, usage limits, markdown content, and legal-consent gating
  • Checkout transactions correlated to gateway transactions through the existing paymentGatewayService
  • Admin transaction tracking, emails, and notifications

Enable the module

Add the package and register it in the app module list:

// apps/saasframe/src/modules.ts
{ id: 'checkout', from: '@saasframe/checkout' }

After enabling the module, run migrations and yarn generate when you add or modify auto-discovered module files.

Admin workflow

Templates

Use templates when you repeat the same payment setup:

  • branding
  • pricing mode
  • customer-field collection
  • legal documents
  • success/cancel/error messages
  • transactional email defaults

Create a pay link directly or from a template. The link form supports:

  • fixed pricing with optional strikethrough original amount
  • custom amount ranges
  • price lists with server-authoritative price selection
  • password protection
  • max completion limits
  • gateway provider selection

On the create-link form itself, users can also search and apply a template as a starting point without leaving the page.

Links start in draft, can be previewed, and become public only after they are published.

Transactions

Transactions are read-only in admin. Users with checkout.view can inspect status and gateway correlation. Users also need checkout.viewPii to see decrypted customer fields.

Public flow

The public flow uses four endpoints:

  • GET /api/checkout/pay/:slug
  • POST /api/checkout/pay/:slug/verify-password
  • POST /api/checkout/pay/:slug/submit
  • GET /api/checkout/pay/:slug/status/:transactionId

Password-protected pay links sign a short-lived cookie-backed session. Set AUTH_SECRET or NEXTAUTH_SECRET in the app env when possible. If those are not configured, checkout falls back to JWT_SECRET, then TENANT_DATA_ENCRYPTION_FALLBACK_KEY.

The server remains authoritative for:

  • amount validation
  • selected price-list item validation
  • password-session enforcement
  • required legal-consent acceptance
  • usage-limit reservation
  • status reconciliation

POST /submit requires Idempotency-Key to prevent duplicate transactions during client retries.

Protections and security boundaries

Checkout is intentionally customizable at the UI layer, but strict at the integrity layer. The module includes the following protections out of the box.

Publication boundary

  • Only links in active status are publicly payable.
  • draft and inactive links are not accepted on the public payment route.
  • Preview mode is a separate internal flow that requires authenticated admin access plus checkout.view.
  • Preview pages are rendered for review only and are not treated as publicly payable links.

Admin access control

  • Admin pages and write routes require authenticated backend access.
  • Template and pay-link routes are feature-gated with checkout.view, checkout.create, checkout.edit, and checkout.delete.
  • Transaction visibility is split from PII visibility.
  • Users need checkout.view to inspect transactions.
  • Users need checkout.viewPii to see decrypted customer identity fields, submitted customer data, IP address, user agent, and stored legal-consent proof.
  • Pay links can require a password before the full payment payload is returned.
  • Passwords are stored as bcrypt hashes, never plaintext.
  • Successful password verification creates a short-lived signed access cookie.
  • That cookie is HttpOnly, Secure, SameSite=Strict, and expires after one hour.
  • Access tokens are bound to the pay-link slug and link id.
  • Access tokens are also bound to the current password-hash version, so changing the password invalidates existing access sessions automatically.
  • The public status endpoint enforces the same password session, so status polling cannot bypass password protection.

Secret handling

  • Checkout password-session signing uses the first configured secret from:
    1. AUTH_SECRET
    2. NEXTAUTH_SECRET
    3. JWT_SECRET
    4. TENANT_DATA_ENCRYPTION_FALLBACK_KEY
  • This lets checkout reuse an existing application secret instead of introducing a separate session-signing mechanism.

Public endpoint hardening

  • Public page view, password verification, payment submission, and status polling are all wired to dedicated rate-limit hooks.
  • Payment submission validates browser origin against the current request origin, configured allow-list entries, app URL env values, and forwarded host/protocol headers.
  • This reduces the risk of untrusted cross-origin browser submissions while still supporting proxies and ephemeral environments.

Duplicate-submit protection

  • POST /api/checkout/pay/:slug/submit requires an Idempotency-Key header.
  • Keys must be between 16 and 128 characters.
  • Checkout stores transactions with a uniqueness boundary on organization + tenant + link + idempotency key.
  • Reusing the same idempotency key returns the original transaction response instead of creating a duplicate payment attempt.

Server-authoritative amount validation

  • Fixed-price links reject mismatched client-submitted amounts.
  • Custom-amount links enforce configured minimum and maximum boundaries server-side.
  • Price-list links require a valid server-known price item id and verify that the submitted amount matches that item.
  • The selected currency is validated against the payment-gateway descriptor before the payment session is created.
  • Required legal documents are enforced server-side during submit.
  • Submission is rejected until every required acceptance is present.
  • Accepted legal documents are stored as structured proof with acceptance timestamp and a hash of the markdown shown to the customer.
  • That gives operators an auditable record of what was accepted without relying on browser state alone.

Availability and oversell protection

  • Checkout reserves capacity before creating the provider-side session.
  • Each new payment attempt increments activeReservationCount transactionally.
  • A link stops accepting new attempts when completionCount + activeReservationCount reaches maxCompletions.
  • This prevents race conditions where multiple customers could oversubscribe a limited-use link.
  • When a terminal state is reached, the reservation is released and successful completions increment completionCount.

Status reconciliation

  • Public status polling is scoped to the current link and transaction pair.
  • When a transaction is still pending or processing, checkout can refresh provider status through the payment gateway service before returning the latest result.
  • This keeps checkout status aligned with the gateway rather than trusting stale client-side state.

Data minimization

  • Password hashes are not exposed by the normal checkout serializers.
  • PII is stripped from transaction API responses unless checkout.viewPii is granted.
  • Sensitive operational fields such as passwordHash and gatewaySettings are excluded from checkout search indexing.

Customization boundary

  • Extensions can wrap or replace page sections.
  • Extensions cannot replace the server-side enforcement for pricing validation, password sessions, legal-consent checks, idempotency, reservation locking, or transaction reconciliation.
  • This boundary is deliberate: checkout is flexible in presentation, not in payment integrity.

Emails and notifications

Checkout can send:

  • payment start emails
  • payment success emails
  • payment error emails

Checkout email sender resolution follows the same precedence as the notifications system:

  1. NOTIFICATIONS_EMAIL_FROM
  2. EMAIL_FROM
  3. ADMIN_EMAIL

If only ADMIN_EMAIL is configured, it is used as the sender fallback, so it must be a valid address accepted by your mail provider.

It also emits in-app notifications for:

  • completed transactions
  • failed transactions
  • links that reach their usage limit

Extensibility

Checkout exposes stable UMES surfaces for extension without forking the module.

Injection spots

  • data-table:payment_gateways.transactions.list:toolbar
  • admin.page:payment-gateways/transactions:after
  • checkout.pay-page:header:before
  • checkout.pay-page:header:after
  • checkout.pay-page:description:after
  • checkout.pay-page:customer-fields:before
  • checkout.pay-page:customer-fields:after
  • checkout.pay-page:pricing:before
  • checkout.pay-page:pricing:after
  • checkout.pay-page:summary:before
  • checkout.pay-page:summary:after
  • checkout.pay-page:legal-consent:before
  • checkout.pay-page:legal-consent:after
  • checkout.pay-page:submit:before
  • checkout.pay-page:submit:after
  • checkout.pay-page:payment:before
  • checkout.pay-page:payment:after
  • checkout.pay-page:help:before
  • checkout.pay-page:help:after
  • checkout.pay-page:footer:before
  • checkout.pay-page:footer:after
  • checkout.pay-page:gateway-widget:before
  • checkout.pay-page:gateway-widget:renderer:before
  • checkout.pay-page:gateway-widget:renderer:after
  • checkout.pay-page:gateway-widget:actions:before
  • checkout.pay-page:gateway-widget:actions:after
  • checkout.pay-page:gateway-widget:after
  • checkout.pay-page:form (behavior spot for onFieldChange, transformValidation, transformFormData, onBeforeSave, onSave, onAfterSave)

Replacement handles

  • page:checkout.pay-page
  • page:checkout.success-page
  • page:checkout.error-page
  • section:checkout.pay-page.header
  • section:checkout.pay-page.description
  • section:checkout.pay-page.summary
  • section:checkout.pay-page.pricing
  • section:checkout.pay-page.payment
  • section:checkout.pay-page.customer-form
  • section:checkout.pay-page.legal-consent
  • section:checkout.pay-page.gateway-form
  • section:checkout.pay-page.help
  • section:checkout.pay-page.footer
  • section:checkout.success-page.content
  • section:checkout.error-page.content
  • crud-form:checkout:link
  • crud-form:checkout:template
  • data-table:checkout-links
  • data-table:checkout-templates
  • data-table:checkout-transactions

Customization stops at payment-critical boundaries. Extensions can change layout and presentation, but they must not replace the server-side pricing, consent, password, or transaction-reconciliation rules.

Gateway-provider integration

Checkout relies on the additive provider-descriptor surface from payment_gateways, not on provider-specific code. Gateway packages must publish safe descriptors that expose:

  • settings fields for the admin form
  • supported currencies
  • supported payment types
  • presentation capabilities (embedded, redirect, either)

Checkout reads these descriptors through the descriptor service and the safe provider endpoints in payment_gateways. Credentials stay owned by the gateway package and its integration records.

Provider-owned browser payment UI is auto-discovered from widgets/payments/client.tsx in each gateway package. Checkout never imports Stripe, PayU, or other provider UI directly; it resolves provider widgets through the shared payment renderer registry using the clientSession returned by the payment gateway layer.