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
/profilebilling 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:
- If
invoiceis missing or sanitises to null → return<PendingIssuanceNotice />(noinvoiceIdarg) - Otherwise, call
fetchInvoiceRenderable({ invoiceId, assetHint, chainHint }) - If
lookup.kind === 'not-found'→ return<PendingIssuanceNotice invoiceId={invoiceId} /> - 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
disclosureVersionvisibly 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) —
fetchInvoiceRenderablethrow is caught by segmenterror.tsx - Anonymous user —
/billing/cryptoredirects to/welcomebefore a paywall or pending notice renders - Search engines —
metadata.robots = { index: false, follow: false }; browser coverage asserts the renderednoindex, nofollowmeta 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
invoiceIdis known - Return-to-billing CTA / contact support link —
Return to billingtargets/billingand the support link targetsmailto: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 block —
rail.disclosureCopyEnrendered as legal disclosure - Disclosure version marker — visible legal version string rendered above the payment controls
- Central-issuer warning — visible when
hasCentralIssueris true - Help / support link — issued paywall exposes the same
mailto:billing@oshun.appaction 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 toconfirmed, the by-id fetch returnsstatus: "confirmed"and the server route rendersPendingIssuanceNoticeinstead of a stale address.
E2E coverage#
apps/oshun/web/e2e/billing-crypto-paywall.spec.tscovers 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 notxIdand 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.tsxorCryptoPaywall; 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/cryptoredirect 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/-) andsanitiseToken(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.tsxapps/oshun/web/src/app/billing/crypto/PendingIssuanceNotice.tsxapps/oshun/web/src/app/billing/crypto/invoice-loader.tslibs/oshun/payments-bridge/(package@oshun/payments-bridge) —getRailDescriptorlives inlibs/oshun/payments-bridge/src/trust-tier-disclosure.ts;makeOnchainAddressPaywall
Open questions / known gaps#
- Read
CryptoPaywall.tsxandPendingIssuanceNotice.tsxto 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.tsagainst the live BFF. - Confirm middleware gating:
/billing/cryptois signed-in-only; anonymous direct visits redirect to/welcome. -
makeOnchainAddressPaywallis re-exported from thispage.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
disclosureVersionis surfaced visibly so it's attestable in legal review; the paywall header now rendersDisclosure version vNand 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.tsasserts the BTC address-visible state plus the USDC disclosure-gated and post-ack states.