V1 Web PWA · Surface walkthrough

Billing · Pay with Crypto

A per-surface walkthrough of the V1 Web PWA customer surface: layout, states, interactions, data, and cross-references.

walked
9sections5 minread

On this page

Context. surface customer · domain account · route /billing/crypto · auth signed-in · source apps/oshun/web/src/app/billing/crypto/page.tsx

Last walked. 2026-05-29 automated runtime walk (Playwright headless) — render, /v1 data (2xx), console/page-errors, expected content, screenshot verified; manual screen-reader and real telemetry-delivery checks still need an AT pass. Evidence: WALKTHROUGH/results/runtime-sweep-2026-05-29.md; content re-verified 2026-06-03 against current source; 2026-06-28 billing-crypto-paywall Playwright pass covered anonymous redirect, sanitization pending-notice variants, pending return/support actions with 44 px targets, real BFF-issued BTC and USDC/Base invoices, visible disclosure version, trust-tier disclosure ack, copy/QR/address, signed settlement webhook, entitlement readback, confirmed-invoice no-stale-address render, idempotent webhook re-delivery, and failed settlement receiver paths with no ledger/plan mutation, plus issued-paywall billing-support actions in both disclosure-gated and address-visible states.

Purpose#

Non-custodial crypto-settlement paywall for a single invoice. Sanitises the invoice, asset, and chain query params, fetches a renderable invoice from fetchInvoiceRenderable, derives the rail descriptor via @oshun/payments-bridge, and renders either <CryptoPaywall> (live address + countdown + send-amount) or <PendingIssuanceNotice> (no invoice id, or not-found).

Entry points#

  • Email link from invoice issuance flow: ?invoice=<id>&asset=<token>&chain=<token>
  • In-product upgrade flow (Yemaya AAA, premium tier) that proposes crypto as a settlement option
  • Cross-domain link from /profile billing row (when crypto rail is enabled for the user)
  • Direct URL / bookmark — yes; auth required (middleware gating verified for /billing/crypto)

Layout regions#

page.tsx is a server component with export const dynamic = 'force-dynamic'. Decision logic:

  1. If invoice is missing or sanitises to null → return <PendingIssuanceNotice /> (no invoiceId arg)
  2. Otherwise, call fetchInvoiceRenderable({ invoiceId, assetHint, chainHint })
  3. If lookup.kind === 'not-found' → return <PendingIssuanceNotice invoiceId={invoiceId} />
  4. Otherwise → render <CryptoPaywall renderable={...} hasCentralIssuer={...} disclosureVersion={...} disclosureCopy={rail.disclosureCopyEn} />

Two terminal layouts:

  • PendingIssuanceNotice: rendered when the page can't show a paywall yet (no invoice param, or invoice not yet issued / found). Layout region details live in apps/oshun/web/src/app/billing/crypto/PendingIssuanceNotice.tsx
  • CryptoPaywall: the real paywall composition with rail-specific disclosure copy and central-issuer trust class flag. Layout regions in apps/oshun/web/src/app/billing/crypto/CryptoPaywall.tsx

States#

  • No invoice param<PendingIssuanceNotice /> (no invoice id argument)
  • Invalid invoice param sanitisation — short-circuits to pending-issuance notice (input failed regex/length)
  • Invoice not found — fetch returns kind === 'not-found'<PendingIssuanceNotice invoiceId={invoiceId} />
  • Native rail (e.g., BTC, ETH on L1) — rail.issuerTrustClass === 'native'hasCentralIssuer === false
  • Issued asset (stablecoin)rail.issuerTrustClass !== 'native'hasCentralIssuer === true; disclosure copy emphasises central issuer trust
  • Disclosure version — paywall surfaces disclosureVersion visibly in the paywall header (data-testid="crypto-disclosure-version")
  • Loading — server-side fetch blocks until renderable resolved; no client-side loading skeleton from this page itself
  • Error (unrecoverable)fetchInvoiceRenderable throw is caught by segment error.tsx
  • Anonymous user/billing/crypto redirects to /welcome before a paywall or pending notice renders
  • Search enginesmetadata.robots = { index: false, follow: false }; browser coverage asserts the rendered noindex, nofollow meta tag

Interactions#

Page-level: none (server component); all interactions inside the two child compositions.

PendingIssuanceNotice (per page contract)#

  • Pending state message — copy varies based on whether invoiceId is known
  • Return-to-billing CTA / contact support linkReturn to billing targets /billing and the support link targets mailto:billing@oshun.app; both are asserted as at least 44 px touch targets

CryptoPaywall (per page contract)#

  • Deposit address (mono text) — copyable via a copy button
  • Copy address (button) — copies address to clipboard
  • Amount in asset (mono text + locale-formatted) — denominated per the rail
  • QR code (if rendered) — encodes the payment URI
  • Disclosure copy blockrail.disclosureCopyEn rendered as legal disclosure
  • Disclosure version marker — visible legal version string rendered above the payment controls
  • Central-issuer warning — visible when hasCentralIssuer is true
  • Help / support link — issued paywall exposes the same mailto:billing@oshun.app action as the pending state in both disclosure-gated and address-visible states; browser coverage asserts it as a single 44 px touch target.
  • Settlement-status update contract — no client polling subscription exists in CryptoPaywall.tsx; after a signed settlement flips the live BFF invoice to confirmed, the by-id fetch returns status: "confirmed" and the server route renders PendingIssuanceNotice instead of a stale address.

E2E coverage#

  • apps/oshun/web/e2e/billing-crypto-paywall.spec.ts covers anonymous redirect, sanitization and not-found pending states, pending return/support actions with 44 px targets, route robots metadata, real BFF-issued BTC and USDC/Base invoices through the live by-id fetch, visible disclosure version, asset/network controls, trust-tier badges, QR/address block, clipboard copy, billing-support action in both issued-paywall states, central-issuer disclosure ack, signed settlement webhook, purchaser plan readback, confirmed-invoice stale-address removal, and idempotent webhook re-delivery. It also posts invalid-signature, malformed signed, and unknown-invoice settlement webhooks over real HTTP and proves the original invoice remains pending with no txId and the purchaser plan remains free.

Data & contracts#

  • Reads:
    • fetchInvoiceRenderable({ invoiceId, assetHint, chainHint }) from ./invoice-loader — returns { kind: 'found' | 'not-found', renderable?, v1Asset?, disclosureVersion? }
    • getRailDescriptor(v1Asset) from @oshun/payments-bridge — returns { issuerTrustClass, disclosureCopyEn, ... }
  • Writes: no network writes from page.tsx or CryptoPaywall; user actions are client-local clipboard write and disclosure-ack state. Settlement is driven by the BFF webhook receiver outside this page.
  • Realtime: no client polling or subscription exists in CryptoPaywall.tsx; the route is re-rendered from the live by-id invoice status on navigation.
  • Caching: export const dynamic = 'force-dynamic' — page is re-rendered per request; never cached
  • Auth/role check: middleware behaviour verified; anonymous direct visits to /billing/crypto redirect to /welcome
  • Metadata: title: 'Oshun · Pay with crypto', description: 'Settle your subscription on-chain. Non-custodial, address fresh per invoice.', robots: { index: false, follow: false }, alternates.canonical: '/billing/crypto'
  • Sanitisers: sanitiseInvoiceId (1–64 alnum/-) and sanitiseToken (1–32 alnum/-) — strict regex; null-on-invalid

Cross-references#

  • Parent: profile.md (billing settings row)
  • Upgrade flow: ../13-system/aaa-upgrade.md
  • Shell auth: shell/04-auth-session.md
  • Component sources:
    • apps/oshun/web/src/app/billing/crypto/CryptoPaywall.tsx
    • apps/oshun/web/src/app/billing/crypto/PendingIssuanceNotice.tsx
    • apps/oshun/web/src/app/billing/crypto/invoice-loader.ts
    • libs/oshun/payments-bridge/ (package @oshun/payments-bridge) — getRailDescriptor lives in libs/oshun/payments-bridge/src/trust-tier-disclosure.ts; makeOnchainAddressPaywall

Open questions / known gaps#

  • Read CryptoPaywall.tsx and PendingIssuanceNotice.tsx to confirm every interactive element covered by the pending/issued-invoice browser specs.
  • Confirm the settlement polling contract after Aje events are available in the harness: there is no in-place polling in the shipped paywall; the browser route is re-rendered from the live by-id invoice status and hides confirmed invoice addresses.
  • Confirm failed settlement receiver POSTs do not mutate the payable invoice: invalid HMAC, malformed signed payload, and unknown invoice ids are covered by billing-crypto-paywall.spec.ts against the live BFF.
  • Confirm middleware gating: /billing/crypto is signed-in-only; anonymous direct visits redirect to /welcome.
  • makeOnchainAddressPaywall is re-exported from this page.tsx — confirm whether that export is used by any tests / tooling (typically pages don't re-export business utilities)
  • Confirm what the user does after settlement: no success route or in-place success transition is currently shipped; a confirmed invoice re-renders as not payable.
  • Document the rails supported in V1 (BTC / ETH / USDC etc.)
  • Verify the disclosureVersion is surfaced visibly so it's attestable in legal review; the paywall header now renders Disclosure version vN and the E2E spec asserts it for native and issued-asset invoices.
  • The issued paywall now exposes the same billing-support mailto as the pending notice; billing-crypto-paywall.spec.ts asserts the BTC address-visible state plus the USDC disclosure-gated and post-ack states.