@openrails/billing-ui 0.0.0-stage → 0.230.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +257 -0
  2. package/LICENSE +21 -0
  3. package/README.md +211 -2
  4. package/dist/account/account-billing.d.ts +19 -0
  5. package/dist/account/cancel-dialog.d.ts +18 -0
  6. package/dist/account/change-card-dialog.d.ts +14 -0
  7. package/dist/account/format.d.ts +25 -0
  8. package/dist/account/notice.d.ts +3 -0
  9. package/dist/account/payment-history.d.ts +8 -0
  10. package/dist/account/payment-methods-panel.d.ts +19 -0
  11. package/dist/account/section.d.ts +25 -0
  12. package/dist/account/status-badge.d.ts +8 -0
  13. package/dist/account/subscriptions-panel.d.ts +19 -0
  14. package/dist/appearance.d.ts +23 -0
  15. package/dist/authenticate.d.ts +8 -0
  16. package/dist/checkout-frame.d.ts +17 -0
  17. package/dist/checkout-page.d.ts +16 -0
  18. package/dist/checkout.d.ts +24 -0
  19. package/dist/client/client.d.ts +167 -0
  20. package/dist/client/errors.d.ts +26 -0
  21. package/dist/client/generated/openrails-routes.d.ts +2 -0
  22. package/dist/client/index.d.ts +4 -0
  23. package/dist/client/types.d.ts +754 -0
  24. package/dist/client-DhG2jv34.js +771 -0
  25. package/dist/client-DhG2jv34.js.map +1 -0
  26. package/dist/client.js +3 -0
  27. package/dist/components/billing-fields.d.ts +39 -0
  28. package/dist/components/card-brands.d.ts +6 -0
  29. package/dist/components/card-fields.d.ts +29 -0
  30. package/dist/components/ccbill-fields.d.ts +8 -0
  31. package/dist/components/methods.d.ts +13 -0
  32. package/dist/components/pay-button.d.ts +6 -0
  33. package/dist/components/qr.d.ts +6 -0
  34. package/dist/components/saved-methods.d.ts +9 -0
  35. package/dist/components/states.d.ts +10 -0
  36. package/dist/components/stripe-card.d.ts +17 -0
  37. package/dist/components/summary.d.ts +12 -0
  38. package/dist/components/ui/alert-dialog.d.ts +18 -0
  39. package/dist/components/ui/badge.d.ts +7 -0
  40. package/dist/components/ui/button.d.ts +8 -0
  41. package/dist/components/ui/card.d.ts +11 -0
  42. package/dist/components/ui/dialog.d.ts +17 -0
  43. package/dist/components/ui/input.d.ts +3 -0
  44. package/dist/components/ui/label.d.ts +3 -0
  45. package/dist/components/ui/radio-group.d.ts +5 -0
  46. package/dist/components/ui/separator.d.ts +3 -0
  47. package/dist/components/ui/skeleton.d.ts +2 -0
  48. package/dist/components/ui/spinner.d.ts +3 -0
  49. package/dist/components/ui/textarea.d.ts +3 -0
  50. package/dist/dist-CslWupfi.js +3311 -0
  51. package/dist/dist-CslWupfi.js.map +1 -0
  52. package/dist/errors-T-PRm3vV.js +54 -0
  53. package/dist/errors-T-PRm3vV.js.map +1 -0
  54. package/dist/fixtures.d.ts +9 -0
  55. package/dist/frame.d.ts +47 -0
  56. package/dist/hooks-DLFF-zx5.js +285 -0
  57. package/dist/hooks-DLFF-zx5.js.map +1 -0
  58. package/dist/i18n/context.d.ts +4 -0
  59. package/dist/i18n/index.d.ts +2 -0
  60. package/dist/i18n/messages.d.ts +58 -0
  61. package/dist/index.d.ts +29 -0
  62. package/dist/index.js +4182 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/lib/billing.d.ts +25 -0
  65. package/dist/lib/card-brands.d.ts +2 -0
  66. package/dist/lib/card-entry.d.ts +46 -0
  67. package/dist/lib/ccbill.d.ts +8 -0
  68. package/dist/lib/collect.d.ts +51 -0
  69. package/dist/lib/money.d.ts +9 -0
  70. package/dist/lib/period.d.ts +15 -0
  71. package/dist/lib/rail-meta.d.ts +15 -0
  72. package/dist/lib/redirect.d.ts +2 -0
  73. package/dist/lib/stripe-appearance.d.ts +2 -0
  74. package/dist/lib/stripe.d.ts +2 -0
  75. package/dist/locales/de.d.ts +2 -0
  76. package/dist/locales/de.js +204 -0
  77. package/dist/locales/de.js.map +1 -0
  78. package/dist/locales/en.d.ts +201 -0
  79. package/dist/locales/en.js +204 -0
  80. package/dist/locales/en.js.map +1 -0
  81. package/dist/locales/es.d.ts +2 -0
  82. package/dist/locales/es.js +204 -0
  83. package/dist/locales/es.js.map +1 -0
  84. package/dist/locales/ja.d.ts +2 -0
  85. package/dist/locales/ja.js +195 -0
  86. package/dist/locales/ja.js.map +1 -0
  87. package/dist/locales/ko.d.ts +2 -0
  88. package/dist/locales/ko.js +195 -0
  89. package/dist/locales/ko.js.map +1 -0
  90. package/dist/locales/zh.d.ts +2 -0
  91. package/dist/locales/zh.js +195 -0
  92. package/dist/locales/zh.js.map +1 -0
  93. package/dist/modal.d.ts +12 -0
  94. package/dist/provider.d.ts +18 -0
  95. package/dist/psp.d.ts +46 -0
  96. package/dist/react/context.d.ts +38 -0
  97. package/dist/react/hooks.d.ts +76 -0
  98. package/dist/react/index.d.ts +3 -0
  99. package/dist/react/provider.d.ts +18 -0
  100. package/dist/react/remote.d.ts +11 -0
  101. package/dist/react.js +37 -0
  102. package/dist/react.js.map +1 -0
  103. package/dist/save-payment-method.d.ts +26 -0
  104. package/dist/scope-context.d.ts +17 -0
  105. package/dist/scope.d.ts +6 -0
  106. package/dist/source.d.ts +14 -0
  107. package/dist/styles.css +3 -0
  108. package/dist/tokenized-card-form.d.ts +24 -0
  109. package/dist/types.d.ts +191 -0
  110. package/docs/1055-nmi-host-integration.md +28 -0
  111. package/docs/payment-form-contract.md +153 -0
  112. package/package.json +112 -3
package/CHANGELOG.md ADDED
@@ -0,0 +1,257 @@
1
+ # Changelog
2
+
3
+ ## Hosted checkout sessions (openrails#1124)
4
+
5
+ - OpenRails serves the checkout session; hosts no longer write
6
+ `/api/v1/checkout/sessions` routes. `client.createCheckoutSession()` mints
7
+ one for the signed-in customer (`priceKey` or `priceId`, `successUrl`) and
8
+ `client.checkoutSource(id)` reads and pays it with the id alone (no bearer).
9
+ A refusal the buyer can act on resolves as a `failed`, `blocked` or
10
+ `expired` result. A `card` rail pays through the shared page too.
11
+ - `<CheckoutFrame url onComplete theme>` frames the shared payment page and
12
+ `<CheckoutPage>` is that page; they speak an origin-checked frame protocol
13
+ (`ready`, `resize`, `complete`, `redirect`; `init {theme}`).
14
+ - `<Checkout onRedirect>` replaces the top-window navigation of redirect rails.
15
+ - The session document gains `embed_origin`.
16
+
17
+ ## Server card entry (openrails#1129)
18
+
19
+ - Driver `card` (a PSP declared `card_entry: server`, flow `card`): `<Checkout>`
20
+ and `SavePaymentMethod` render plain card inputs with the Collect.js form's
21
+ layout and field errors, load no gateway script, and send
22
+ `card: {number, exp_month, exp_year, cvc}`. Inside a `BillingProvider` the
23
+ card is saved first (`addPaymentMethod({ provider, card })`) and charged by
24
+ id; a page without one sends `card` in `PayRequest`. The inputs are cleared
25
+ once the card is sent.
26
+ - `cardSetupDriver` may return `"card"`; `NewCard` takes `payment_token` or
27
+ `card`, never both.
28
+
29
+ ## Catalog and plan-change client (openrails#1128)
30
+
31
+ - `@openrails/billing-ui/client` gains the public catalog (`listProducts`,
32
+ `listPrices`, `listCurrencies`), plan change (`previewTierChange`,
33
+ `changeTier`) and Solana (`getSolanaConfig`, `listSolanaTokens`,
34
+ `prepareSolanaTierChange`, `confirmSolanaTierChange`) calls, with the types
35
+ `Product`, `Price`, `Currency`, `TierChangePreview`, `TierChange`,
36
+ `SolanaConfig`, `SolanaToken`, `SolanaTierChangeTx` and `SolanaTierChange`.
37
+ `changeTier` requires an `idempotencyKey`; reuse it until the change
38
+ resolves.
39
+ - React: `useProducts`, and `useSubscriptions().changeTier` (per-row `pending`
40
+ `"change_tier"`, `onChange` `subscription.tier_changed`), which resolves to
41
+ the `TierChange` or a `BillingError`.
42
+
43
+ ## Explicit payment method, fail-fast errors (openrails#1087, #1088)
44
+
45
+ - The checkout pre-selects the customer's default card (`PaymentMethod.default`,
46
+ `SavedPaymentMethod.default`) and always sends the chosen card's id.
47
+ OpenRails refuses a charge that names no method with
48
+ `payment_method_required` (400).
49
+ - A server error (5xx) from `pay` is shown at once and the panel returns to
50
+ ready; hosts keep their idempotency key so paying again replays. A host
51
+ source signals it by throwing an error with a numeric `status`
52
+ (`CheckoutSourceError`, or any error carrying `status`).
53
+ - The client never retries a write. A GET retries once, only on a network
54
+ error or 502/503/504 (429/503 with `Retry-After`), within `RETRY_BUDGET_MS`
55
+ (2 s). New `isServerError`; a 5xx without an error envelope has code
56
+ `server_error`.
57
+
58
+ ## One-click card subscriptions (openrails#1085)
59
+
60
+ - A checkout host relays a card subscription's pay request with OpenRails'
61
+ `CreateCheckoutSessionRequest.Confirm`; a new Collect.js token subscribes in
62
+ one call. No package API change.
63
+
64
+ ## Advertised checkout rails (openrails#1078)
65
+
66
+ - Breaking: `checkoutRails(offers)` takes OpenRails' checkout options alone.
67
+ Each option carries the `driver` and `public_config` OpenRails derived from
68
+ the armed PSP (Solana: `token_symbol`, `token_name`, `network`); the package
69
+ no longer derives drivers from `psps`. `CheckoutRailOffer` gains `selector`,
70
+ `driver` and `public_config`.
71
+
72
+ ## Moved into OpenRails
73
+
74
+ The package now lives in `open-rails/openrails` under `sdk/billing-ui` and
75
+ takes the OpenRails version: each OpenRails release `vX.Y.Z` attaches
76
+ `openrails-billing-ui-X.Y.Z.tgz`. The e2e server and generated contract build
77
+ the OpenRails at the same commit. `OPENRAILS_VERSION` is gone from the
78
+ generated contract.
79
+
80
+ ## 0.9.0
81
+
82
+ One embedded card panel for one-time purchases and subscriptions, on every
83
+ card PSP (openrails#1064). Requires OpenRails v0.164.0.
84
+
85
+ - Stripe Elements checkout rail (`driver: "stripe_elements"`, from a PSP with
86
+ `flow: "elements"`): saved Stripe cards or a new card in the page, 3-D
87
+ Secure via `authenticatePayment`; no redirect to hosted Checkout. The
88
+ Payment Element is cards only (no Link bank tab or wallets).
89
+ - One screen: saved cards (brand •••• last4 · MM/YY, most recent first),
90
+ inline "Use a new card", one "Pay $X" / "Subscribe for $X every <period>"
91
+ button with the agreement line. No provider chooser for a single rail.
92
+ - New cards are always saved to the account (no consent checkbox) before
93
+ paying by id; Collect.js display metadata (`last_four`, `card_type`,
94
+ `expiry_date`) is sent with every token.
95
+ - Declines stay on the panel: `PayResult.failure` / `CheckoutSession.failure`
96
+ (`{ reason, message, field }`) render next to the card field; retry with
97
+ another card. `requires_action` + `operation_id` authenticates in the page.
98
+ - Collect.js `validationCallback`: inline number/expiry/CVC errors; the
99
+ button waits for three valid fields. Stripe Elements and Collect.js fields
100
+ are themed from the page tokens.
101
+ - `defaultCountry` on Checkout / SavePaymentMethod / AccountBilling /
102
+ TokenizedCardForm; fallback: the browser locale's region
103
+ (`initialCountry`, `browserCountry`).
104
+ - `PspConfig.checkout`; `checkoutPsps(psps)` — only checkout PSPs take new
105
+ cards. `isCardRail`, `useOptionalBillingContext`.
106
+ - `CheckoutModal`: always closable; `gate` renders host content (sign-in)
107
+ in place of the checkout.
108
+ - Account: card labels read "Visa •••• 4242 · 12/30"; failed payments show
109
+ `payment.failure.message`.
110
+ - Breaking: `paymentMethods.consent`/`enterCard` messages are replaced by
111
+ `paymentMethods.saveNotice`; `cardLabel` is "{brand} •••• {last4}".
112
+
113
+ ## 0.8.0
114
+
115
+ Provider-neutral hosts: a host passes OpenRails's browser PSP configs through
116
+ and never branches on Stripe, NMI or any other provider.
117
+
118
+ - `checkoutRails(offers, psps)` / `savedMethodsFor(methods, rails)` derive the
119
+ checkout rails and chargeable saved cards from each PSP's `flow` and public
120
+ config.
121
+ - `SavePaymentMethod`: consented in-page card saving with any PSP — Collect.js
122
+ tokenization or Stripe Elements card setup (Stripe.js is loaded on first
123
+ use). `canSavePaymentMethod`, `cardSetupDriver`.
124
+ - `authenticatePayment(client, operationId, psp)`: 3-D Secure for a pending
125
+ payment operation. `canAuthenticatePayment`.
126
+ - Client: `createCardSetup`, `getCardSetup`, `confirmCardSetup`,
127
+ `getPaymentAuthentication`, `confirmPaymentAuthentication`.
128
+ - Breaking: `AccountBilling`/`PaymentMethodsPanel` take `psps` (and optional
129
+ `cardSetupReturnURL`) instead of `cardSetup`; `CardSetupConfig` is removed.
130
+ Stripe card setup needs OpenRails serving Stripe's `publishable_key`
131
+ (openrails#1062).
132
+
133
+ ## 0.7.0
134
+
135
+ - Periods are exact. OpenRails windows are hours, so a 720h price reads
136
+ "every 30 days" (was "every month") and 8760h "every 365 days"; only exact
137
+ weeks, days or hours are named, in the account panels and the checkout
138
+ summary, in all six locales.
139
+ - Plural messages: `interval.every|per|access.<hour|day|week>` are CLDR plural
140
+ nodes (`{ one, other }`, exact `"1"` for 毎日-style forms) chosen by the
141
+ provider's `locale`; `Translator.plural(key, count)`. Breaking for hosts
142
+ overriding the removed `interval.day|week|month|year|days|hours` keys.
143
+ - Payment history names what was bought: the product's `display_name` from
144
+ OpenRails (`payment.product`, OpenRails after v0.160.0) and the renewal
145
+ period; older servers fall back to "Subscription"/"Purchase".
146
+ - Checkout summary copy ("Renews {period}") is a message: `checkout.renews`.
147
+ - The e2e harness and route contract pin OpenRails master `78039ea` (first
148
+ commit serving `payment.product`; untagged, after v0.160.0).
149
+
150
+ ## 0.6.0
151
+
152
+ - "Add card" works against real OpenRails, which requires the PSP that
153
+ tokenized the card: `CardSetupConfig.provider` (the PSP key, e.g. `"nmi"`)
154
+ is required and sent as `NewCard.provider`. Before, every add was refused
155
+ with 400 `provider is required`.
156
+
157
+ ## 0.5.2
158
+
159
+ - `renderSubscriptionFooter` (on `SubscriptionsPanel` and `AccountBilling`)
160
+ renders host content under a subscription row, e.g. a plan-change control.
161
+ - `useBillingRefresh()` (from `./react`) refetches every hook after a
162
+ host-side change such as a plan switch.
163
+ - The cancel and change-card dialogs use the panel's `appearance`; they read
164
+ only the provider's, so `AccountBilling appearance` missed them.
165
+
166
+ ## 0.5.1
167
+
168
+ - `appearance.theme: "inherit"`: no bundled palette; components use the host
169
+ page's shadcn tokens and follow its `.dark` class.
170
+ - `SubscriptionsPanel`: "Change card" switches the saved card an active NMI
171
+ subscription renews on (`useSubscriptions().setPaymentMethod`), which now
172
+ notifies `subscription.payment_method_changed`.
173
+
174
+ ## 0.5.0
175
+
176
+ Account billing, alongside checkout. No change to checkout's API.
177
+
178
+ - `@openrails/billing-ui/client`: `createBillingClient` for the OpenRails
179
+ customer surface (`/billing/v1/me/*`): subscriptions (list, get, cancel with
180
+ feedback, resume, payment method, Solana on-chain cancel), payment methods
181
+ (list, add tokenized card, remove, per-currency default), payments, invoices,
182
+ status. zod-validated responses, `BillingError` from the error envelope, the
183
+ pinned currency registry for exact money.
184
+ - `@openrails/billing-ui/react`: `BillingProvider` (`onChange` for host cache
185
+ invalidation), `useSubscriptions`, `usePaymentMethods`, `usePayments`.
186
+ - Styled: `AccountBilling`, `SubscriptionsPanel` with cancel/resume dialog and
187
+ provider-portal links, `PaymentMethodsPanel` (adding a card uses
188
+ `TokenizedCardForm`), `PaymentHistory`, `BillingStatusBadge`,
189
+ `BillingUiProvider`, `BillingUiRoot`.
190
+ - Messages: typed bundles with `en de es ja ko zh` under `./locales/*`.
191
+ - `formatAmount` takes an optional locale.
192
+ - The route contract and e2e suite run against real OpenRails v0.159.0.
193
+
194
+ ## 0.4.2
195
+
196
+ No API or visual change.
197
+
198
+ - Components import `cn` from the [`cn`](https://github.com/shadcn-ui/cn)
199
+ package (pinned `0.4.0`), replacing `clsx` and `tailwind-merge`.
200
+
201
+ ## 0.4.1
202
+
203
+ Visual only; no API change.
204
+
205
+ - UI primitives regenerated as shadcn `base-vega` on a zinc palette.
206
+ - Theme tokens now map to Tailwind colors, so token utilities (`bg-primary`,
207
+ `text-muted-foreground`, `border-input`, …) render; in 0.4.0 they emitted no
208
+ CSS, leaving the pay button and selected radios unstyled.
209
+ - `dark:` variants follow `appearance.theme` instead of the OS alone.
210
+ - `CheckoutModal` now paints its popover panel; buttons inside `.orck` drop the
211
+ UA button face.
212
+
213
+ ## 0.4.0
214
+
215
+ Breaking: the package is renamed `openrails-checkout` → `@openrails/billing-ui`.
216
+
217
+ - Import from `@openrails/billing-ui` and `@openrails/billing-ui/styles.css`.
218
+ - The injected stylesheet is `<style id="billing-ui-styles" data-billing-ui>`.
219
+ - Releases attach the packed tarball to the GitHub release instead of
220
+ publishing to npm:
221
+ `https://github.com/open-rails/billing-ui/releases/download/v0.4.0/openrails-billing-ui-0.4.0.tgz`.
222
+
223
+ ## 0.3.0
224
+
225
+ Breaking: the session document carries exact money.
226
+
227
+ - Accepted `processing` outcomes poll the same source without repeating payment
228
+ or tokenization. Ambiguous pay errors retain that pending attempt; local expiry
229
+ cannot turn it into a failure. Pending sessions may omit/null `expires_at`.
230
+ - Export `TokenizedCardForm` for separately consented NMI card setup, reusing the
231
+ existing hosted fields without presenting setup as a payment.
232
+ - Source changes fence late payment callbacks from the previous checkout.
233
+
234
+ - `plan.unit_amount`, `line_items[].amount`, `tax` and `due_today` are int64
235
+ decimal strings of `plan.currency`'s native unit; `plan.unit_decimals` (the
236
+ currency's registered scale) is required. Numeric amounts are refused as an
237
+ unavailable session. Replaces `unit_amount_micros`, `amount_micros`,
238
+ `tax_micros` and `due_today_micros`, which were JSON numbers at an assumed
239
+ six decimals.
240
+ - Rendering never passes money through a JS number (`BigInt` scaling, exact
241
+ Intl decimal formatting, refusal instead of rounding). `formatAmount`,
242
+ `amountToDecimal`, `addAmounts`, `isAmount`, `amountSchema` and
243
+ `unitDecimalsSchema` are exported.
244
+ - Hosts build the plan with `openrails.NewHostedCheckoutPlan` (OpenRails
245
+ `HostedCheckoutSession` types); the package decodes OpenRails' canonical
246
+ `hosted_checkout_session.json` fixture.
247
+
248
+ ## 0.2.5
249
+
250
+ - A Solana option is named after the host-bound token and network; the former
251
+ `USDC` default is removed.
252
+ - Releases publish to npm through the GitHub release workflow.
253
+
254
+ ## 0.2.4
255
+
256
+ - Hosts must accept `name_on_card` on the pay endpoint; `first_name` and
257
+ `last_name` are no longer emitted.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenRails and Paul Fidika
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,212 @@
1
- # Temporary Holding Version
1
+ # @openrails/billing-ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Embeddable OpenRails billing UI: the checkout flow and the customer's account
4
+ billing (subscriptions, saved cards, payment history).
5
+
6
+ | Import | Contents |
7
+ | --------------------------------- | ---------------------------------------------------------------------------------- |
8
+ | `@openrails/billing-ui/client` | Framework-free typed client for `/billing/v1/me/*` and the catalog, `BillingError` |
9
+ | `@openrails/billing-ui/react` | `BillingProvider` and headless hooks |
10
+ | `@openrails/billing-ui` | Styled checkout and account components, `BillingUiProvider`, i18n |
11
+ | `@openrails/billing-ui/locales/*` | `en de es ja ko zh` message bundles |
12
+
13
+ The checkout owns the browser payment flow. OpenRails holds the checkout
14
+ session: the signed-in customer mints one for a price, and its `ocs_` id alone
15
+ reads and pays it. Card data is tokenized in NMI-hosted Collect.js iframes and
16
+ never enters the host application, unless the PSP takes cards on OpenRails
17
+ itself (`card_entry: server`, driver `card`).
18
+
19
+ See [Payment form contract](docs/payment-form-contract.md) for the exact-money
20
+ session document, billing fields, browser-autofill behavior, and the checkout
21
+ request schema.
22
+
23
+ The package lives in the OpenRails repository and shares its version: each
24
+ OpenRails release `vX.Y.Z` attaches `openrails-billing-ui-X.Y.Z.tgz`.
25
+
26
+ ```sh
27
+ pnpm add https://github.com/open-rails/openrails/releases/download/vX.Y.Z/openrails-billing-ui-X.Y.Z.tgz
28
+ ```
29
+
30
+ ## Checkout
31
+
32
+ ```tsx
33
+ import { createBillingClient } from "@openrails/billing-ui/client"
34
+ import { Checkout, CheckoutFrame } from "@openrails/billing-ui"
35
+ import "@openrails/billing-ui/styles.css"
36
+
37
+ const billing = createBillingClient({ fetch: auth.authFetch })
38
+ const session = await billing.createCheckoutSession({ priceKey: "pro-monthly" })
39
+
40
+ // A shared payment page (Config.HTTP.Checkout.PageURL) answers with a url:
41
+ <CheckoutFrame url={session.url} theme="dark" onComplete={() => refetchAccess()} />
42
+ // Without one, the app renders the checkout itself:
43
+ <Checkout source={billing.checkoutSource(session.id)} onComplete={() => refetchAccess()} />
44
+ ```
45
+
46
+ `onComplete` is a hint: confirm access from your own authenticated API before
47
+ granting anything. Pass `successUrl` to return the buyer from a redirect rail
48
+ (CCBill, Stripe hosted); it must be on one of your app's return origins.
49
+
50
+ The payment host serves `<CheckoutPage>` from one HTML entry at `PageURL`,
51
+ behind its adapter's `CheckoutFramePolicy` (only the sites in
52
+ `Config.HTTP.Checkout.EmbedOrigins` may frame it):
53
+
54
+ ```tsx
55
+ import { BillingUiProvider, CheckoutPage } from "@openrails/billing-ui"
56
+
57
+ createRoot(root).render(
58
+ <BillingUiProvider>
59
+ <CheckoutPage appearance={{ variables: brand }} />
60
+ </BillingUiProvider>
61
+ )
62
+ ```
63
+
64
+ The page reads the session id from its URL fragment and talks to its frame
65
+ with `postMessage`, origin-checked both ways: page to app `ready`,
66
+ `resize {height}`, `complete {status}` and `redirect {url}` (a nested frame
67
+ cannot navigate the top window, so the app does); app to page
68
+ `init {theme}`. The page messages only the app origin recorded on its session.
69
+
70
+ ## Account billing
71
+
72
+ ```tsx
73
+ import { createBillingClient } from "@openrails/billing-ui/client"
74
+ import { BillingProvider } from "@openrails/billing-ui/react"
75
+ import { AccountBilling, BillingUiProvider } from "@openrails/billing-ui"
76
+ import { de } from "@openrails/billing-ui/locales/de"
77
+
78
+ // auth-ui's authFetch attaches the bearer and retries once after a refresh;
79
+ // `getToken: () => token` works for any other auth.
80
+ const billing = createBillingClient({ baseUrl: "/billing/v1", fetch: auth.authFetch })
81
+
82
+ <BillingUiProvider appearance={{ theme: "auto" }} messages={de} locale="de" navigate={navigate}>
83
+ <BillingProvider client={billing} onChange={() => queryClient.invalidateQueries({ queryKey: ["billing"] })}>
84
+ <AccountBilling
85
+ plansHref="/plans"
86
+ psps={checkoutConfig.psps} // OpenRails checkout config; enables "Add card"
87
+ defaultCurrency="USD" // enables "Make default"
88
+ sendSolanaTransaction={(tx) => wallet.signAndSend(tx)} // Solana-rail cancel
89
+ />
90
+ </BillingProvider>
91
+ </BillingUiProvider>
92
+ ```
93
+
94
+ ## Catalog and plan changes
95
+
96
+ The client also reads the public catalog and changes a subscription's plan.
97
+ No UI ships for these; hosts render their own. The catalog, currency and
98
+ Solana reads are public routes, served only when the host mounts OpenRails'
99
+ checkout routes.
100
+
101
+ | Call | Route |
102
+ | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
103
+ | `listProducts()`, `listPrices({ currency, product, type })` | `GET /products`, `GET /prices` |
104
+ | `listCurrencies()` (`client.currencies` is the pinned copy) | `GET /currencies` |
105
+ | `previewTierChange(id, priceId)` | `POST /me/subscriptions/{id}/change-tier/preview` |
106
+ | `changeTier(id, { priceId, idempotencyKey })` | `POST /me/subscriptions/{id}/change-tier` |
107
+ | `getSolanaConfig()`, `listSolanaTokens({ priceId, wallet })` | `GET /solana/config`, `GET /solana/tokens` |
108
+ | `prepareSolanaTierChange(id, newPriceId)`, `confirmSolanaTierChange(id, { signature, newPriceId })` | `POST /me/subscriptions/{id}/solana-tier-change`, `.../confirm` |
109
+
110
+ ```ts
111
+ const preview = await billing.previewTierChange(sub.id, price.id) // amount_due_now, effective
112
+ const change = await billing.changeTier(sub.id, {
113
+ priceId: price.id,
114
+ idempotencyKey,
115
+ })
116
+ ```
117
+
118
+ - `changeTier` is a money write. Mint one `idempotencyKey` per attempt and
119
+ reuse it until the change resolves (`status: "processing"`, a
120
+ `tier_change_in_flight` refusal naming `metadata.operation_id`, a 5xx or a
121
+ lost response), so OpenRails replays the stored result instead of charging
122
+ twice. `requires_action` carries `operation_id` for `authenticatePayment`;
123
+ replay the same key afterwards.
124
+ - Solana rail: the wallet signs and sends `prepareSolanaTierChange`'s
125
+ `transaction`; pass its signature to `confirmSolanaTierChange`. Keep the
126
+ signature until the confirm succeeds; confirming again replays the result.
127
+
128
+ ## Provider-neutral flows
129
+
130
+ Hosts pass OpenRails's browser PSP configs (`GET /checkout-config`, `psps`)
131
+ through unchanged; the PSP's `flow` and public `config` pick the browser flow
132
+ (Collect.js, native card inputs, Stripe Elements, redirect, wallet). Hosts never branch on a
133
+ provider:
134
+
135
+ - `checkoutRails(offers)` and `savedMethodsFor(methods, rails)` build a
136
+ `CheckoutSource`'s `rails` and `saved_methods` (the default card first, pre-selected; paying sends
137
+ the chosen card's id explicitly).
138
+ `offers` is OpenRails' `ListCheckoutRailOptions` result, passed through: it
139
+ lists exactly the armed PSPs whose rail can make this sale (Solana when it is
140
+ configured, never CCBill for a new subscription), each with its `driver` and
141
+ `public_config`. An offer without a driver is not rendered.
142
+ Card rails (`collect_js`, `card`, `stripe_elements`) render one panel: saved cards,
143
+ an inline new card and one Pay/Subscribe button, which is the payer's
144
+ confirmation of the displayed terms. No provider chooser with one rail.
145
+ - Inside a `BillingProvider`, a new card is always saved to the account first
146
+ and the source is paid with `payment_method_id`; `requires_action` results
147
+ carrying `operation_id` run 3-D Secure in the page. A `failed` result stays
148
+ on the panel with `failure.message` next to its `field`, so the buyer can
149
+ pick another card; the host gives each new attempt a new idempotency key.
150
+ - Only PSPs with `checkout !== false` (`checkoutPsps`) take new cards; others
151
+ stay listed so existing cards and subscriptions keep working.
152
+ - `<SavePaymentMethod psp={psp} onSaved={(id) => ...} />` saves a card in the
153
+ page (needs `BillingProvider`); `canSavePaymentMethod(psp)` filters PSPs
154
+ that support it. After an off-page verification the provider returns to
155
+ `returnURL(setupId)`; confirm with `client.confirmCardSetup(id)`.
156
+ - `authenticatePayment(client, operationId, psp)` completes a pending
157
+ payment's 3-D Secure challenge when `canAuthenticatePayment(psp)`.
158
+ - `defaultCountry` (Checkout, SavePaymentMethod, AccountBilling) preselects
159
+ the billing country, else the browser locale's region.
160
+ - `CheckoutModal gate={<SignIn />}` renders host content (e.g. sign-in) in
161
+ the modal instead of the checkout until cleared.
162
+
163
+ - Panels: `SubscriptionsPanel` (cancel, resume, change card),
164
+ `PaymentMethodsPanel`, `PaymentHistory`, `BillingStatusBadge`,
165
+ `CancelSubscriptionDialog`; `AccountBilling` stacks them.
166
+ - `renderSubscriptionFooter={(s) => ...}` adds host content under a
167
+ subscription row; `useBillingRefresh()` refetches after host-side changes.
168
+ - Hooks: `useSubscriptions` (`cancel`, `cancelOnChain`, `resume`,
169
+ `setPaymentMethod`, `changeTier`, per-row `pending`), `usePaymentMethods`
170
+ (`add`, `remove`, `setDefault`), `usePayments` (offset pages), `useProducts`
171
+ (the catalog). Actions resolve to `null` or a `BillingError`; they never
172
+ throw. `changeTier` resolves to the `TierChange` instead of `null`. Cancel
173
+ and resume are queued by OpenRails (202); hooks re-read the subscription
174
+ until the change shows.
175
+ - Money is exact: `/me` amounts are int64 native-unit strings, scaled by the
176
+ OpenRails currency registry (`currencies` option to extend it).
177
+ - Messages: English is complete and the fallback; `messages` layers bundles,
178
+ `t` lets the host's i18n win. `useMessages().error(err)` maps error codes.
179
+ Count-dependent messages are CLDR plural nodes (`{ one, other }`, plus an
180
+ exact `"1"` like ICU `=1`), selected with `locale`.
181
+ - Periods are exact: OpenRails windows are hours, so 720h reads "every 30
182
+ days", never "monthly"; only exact weeks, days or hours are named.
183
+ - Styles are scoped under `.orck`; `appearance.theme` is `light`, `dark`,
184
+ `auto` or `inherit`. `inherit` bundles no palette: the host page's shadcn
185
+ tokens (`--background`, `--primary`, ...) and its `.dark` class apply.
186
+
187
+ ## UI primitives
188
+
189
+ `src/components/ui/*` is shadcn (`base-vega`, zinc; see `components.json`),
190
+ managed with `pnpm dlx shadcn@4.21.0 add <name> --overwrite`, importing `cn`
191
+ from the [`cn`](https://github.com/shadcn-ui/cn) package.
192
+
193
+ ## E2E against real OpenRails
194
+
195
+ `e2e/server` is a Go module that builds the OpenRails at this commit (`replace`
196
+ to the repository root) with AuthKit. It serves the
197
+ embedded `/billing/v1` API and `/auth/v1` on a throwaway `postgres:18-alpine`
198
+ container (Docker and Go required), with test-only `POST /__test/users` (user +
199
+ access token) and `POST /__test/users/{id}/billing` (imported subscription,
200
+ sale and saved card on a credential-less NMI PSP).
201
+
202
+ ```sh
203
+ pnpm test:e2e # Playwright against the real server
204
+ pnpm contract # regenerate src/client/generated from the in-repo OpenRails
205
+ pnpm contract:check # fail if the generated contract is stale
206
+ ```
207
+
208
+ `e2e/openrails/account.spec.ts` drives the packaged `AccountBilling` (built from
209
+ `dist/`) through list, cancel, resume, the in-use card refusal and history. Set
210
+ `BILLING_UI_SCREENSHOTS=<dir>` to capture light, dark and mobile screenshots.
211
+ Card deletion calls NMI, which the harness has no credentials for; jsdom tests
212
+ cover it.
@@ -0,0 +1,19 @@
1
+ import { CheckoutAppearance } from '../appearance';
2
+ import { SendSolanaTransaction } from '../client/client';
3
+ import { PaymentMethodsPanelProps } from './payment-methods-panel';
4
+ import { SubscriptionsPanelProps } from './subscriptions-panel';
5
+ export interface AccountBillingProps {
6
+ psps?: PaymentMethodsPanelProps["psps"];
7
+ cardSetupReturnURL?: PaymentMethodsPanelProps["cardSetupReturnURL"];
8
+ defaultCurrency?: string;
9
+ /** Billing country to preselect when adding a card. */
10
+ defaultCountry?: string;
11
+ sendSolanaTransaction?: SendSolanaTransaction;
12
+ plansHref?: string;
13
+ renderSubscriptionFooter?: SubscriptionsPanelProps["renderSubscriptionFooter"];
14
+ historyPageSize?: number;
15
+ appearance?: CheckoutAppearance;
16
+ className?: string;
17
+ }
18
+ /** Subscriptions, payment methods and history in one column. */
19
+ export declare function AccountBilling({ psps, cardSetupReturnURL, defaultCurrency, defaultCountry, sendSolanaTransaction, plansHref, renderSubscriptionFooter, historyPageSize, appearance, className, }: AccountBillingProps): import("react").JSX.Element;
@@ -0,0 +1,18 @@
1
+ import { CheckoutAppearance } from '../appearance';
2
+ import { BillingError } from '../client/errors';
3
+ import { SubscriptionAction } from '../react/hooks';
4
+ import * as React from "react";
5
+ export interface CancelSubscriptionDialogProps {
6
+ open: boolean;
7
+ onOpenChange: (open: boolean) => void;
8
+ /** Subscription display name. */
9
+ name: string;
10
+ /** On-chain (Solana) cancel: no feedback, the wallet signs. */
11
+ onChain?: boolean;
12
+ /** In-flight stage, when cancelling. */
13
+ pending?: SubscriptionAction;
14
+ /** Resolves null on success; the dialog then closes. */
15
+ onConfirm: (feedback: string) => Promise<BillingError | null>;
16
+ appearance?: CheckoutAppearance;
17
+ }
18
+ export declare function CancelSubscriptionDialog({ open, onOpenChange, name, onChain, pending, onConfirm, appearance, }: CancelSubscriptionDialogProps): React.JSX.Element;
@@ -0,0 +1,14 @@
1
+ import { CheckoutAppearance } from '../appearance';
2
+ import { BillingError } from '../client/errors';
3
+ import { Subscription } from '../client/types';
4
+ import * as React from "react";
5
+ export interface ChangeCardDialogProps {
6
+ subscription: Subscription | null;
7
+ name: string;
8
+ onOpenChange: (open: boolean) => void;
9
+ pending: boolean;
10
+ /** Resolves null on success; the dialog then closes. */
11
+ onConfirm: (paymentMethodId: string) => Promise<BillingError | null>;
12
+ appearance?: CheckoutAppearance;
13
+ }
14
+ export declare function ChangeCardDialog({ subscription, name, onOpenChange, pending, onConfirm, appearance, }: ChangeCardDialogProps): React.JSX.Element;
@@ -0,0 +1,25 @@
1
+ import { CardSummary, CurrencyScales, Payment, Subscription, SubscriptionPrice } from '../client/types';
2
+ import { Translator } from '../i18n/messages';
3
+ export declare function formatDate(value: string | null | undefined, locale?: string): string | null;
4
+ /** Exact money, or null while the currency's scale is unknown. */
5
+ export declare function formatMoney(amount: string | null | undefined, currency: string, scales: CurrencyScales, locale?: string): string | null;
6
+ /** "every 30 days", "30 days of access" or "one-time". */
7
+ export declare function intervalLabel(price: SubscriptionPrice | null | undefined, m: Translator): string | null;
8
+ export declare function subscriptionName(s: Subscription, { t }: Translator): string;
9
+ /** What a payment bought: the product's name, else its kind, plus its cadence. */
10
+ export declare function paymentItem(p: Payment, m: Translator): {
11
+ name: string;
12
+ detail: string | null;
13
+ };
14
+ export declare function brandName(brand: string | null | undefined, fallback: string): string;
15
+ /** "Visa •••• 4242 · 12/30"; the brand alone when no digits are known. */
16
+ export declare function cardText(card: CardSummary | null | undefined, m: Translator): string;
17
+ export declare function expiry(card: CardSummary | null | undefined): string | null;
18
+ export declare const LIVE_STATUSES: Set<string>;
19
+ /** Still grants access: live, or cancelled with the paid period running. */
20
+ export declare const isLive: (s: Subscription) => boolean;
21
+ /** Access ends at period end and nothing renews it. */
22
+ export declare const isEnding: (s: Subscription) => boolean;
23
+ export type StatusTone = "success" | "warning" | "destructive" | "neutral";
24
+ export declare const statusTone: (status: string) => StatusTone;
25
+ export declare const RESET = "[&_h2]:m-0 [&_p]:m-0 [&_ul]:m-0 [&_ul]:list-none [&_ul]:p-0 [&_table]:border-spacing-0";
@@ -0,0 +1,3 @@
1
+ import * as React from "react";
2
+ /** Polite confirmation line that clears itself. */
3
+ export declare function useNotice(): [React.ReactNode, (text: string) => void];
@@ -0,0 +1,8 @@
1
+ import { CheckoutAppearance } from '../appearance';
2
+ import { PaymentsOptions } from '../react/hooks';
3
+ import * as React from "react";
4
+ export interface PaymentHistoryProps extends PaymentsOptions {
5
+ appearance?: CheckoutAppearance;
6
+ className?: string;
7
+ }
8
+ export declare function PaymentHistory({ appearance, className, ...options }: PaymentHistoryProps): React.JSX.Element;
@@ -0,0 +1,19 @@
1
+ import { CheckoutAppearance } from '../appearance';
2
+ import { PspConfig } from '../psp';
3
+ import * as React from "react";
4
+ export interface PaymentMethodsPanelProps {
5
+ /**
6
+ * OpenRails's browser PSP configs (checkout config). "Add card" offers
7
+ * each one a card can be saved with in the page.
8
+ */
9
+ psps?: readonly PspConfig[];
10
+ /** Return target after off-page card verification; see `SavePaymentMethod`. */
11
+ cardSetupReturnURL?: (setupId: string) => string;
12
+ /** Enables "Make default" for this currency's invoice collection. */
13
+ defaultCurrency?: string;
14
+ /** Billing country to preselect when adding a card. */
15
+ defaultCountry?: string;
16
+ appearance?: CheckoutAppearance;
17
+ className?: string;
18
+ }
19
+ export declare function PaymentMethodsPanel({ psps, cardSetupReturnURL, defaultCurrency, defaultCountry, appearance, className, }: PaymentMethodsPanelProps): React.JSX.Element;
@@ -0,0 +1,25 @@
1
+ import { CheckoutAppearance } from '../appearance';
2
+ import { BillingError } from '../client/errors';
3
+ import * as React from "react";
4
+ export interface SectionProps {
5
+ title: string;
6
+ description?: string;
7
+ action?: React.ReactNode;
8
+ appearance?: CheckoutAppearance;
9
+ className?: string;
10
+ children?: React.ReactNode;
11
+ "data-testid"?: string;
12
+ }
13
+ export declare function Section({ title, description, action, appearance, className, children, ...rest }: SectionProps): React.JSX.Element;
14
+ export declare function ListSkeleton({ rows }: {
15
+ rows?: number;
16
+ }): React.JSX.Element;
17
+ export declare function EmptyState({ title, description, action, }: {
18
+ title: string;
19
+ description?: string;
20
+ action?: React.ReactNode;
21
+ }): React.JSX.Element;
22
+ export declare function ErrorState({ error, onRetry, }: {
23
+ error: BillingError;
24
+ onRetry: () => void;
25
+ }): React.JSX.Element;
@@ -0,0 +1,8 @@
1
+ export interface BillingStatusBadgeProps {
2
+ /** Subscription, payment or card-health status. */
3
+ status: string;
4
+ /** Overrides the translated label. */
5
+ label?: string;
6
+ className?: string;
7
+ }
8
+ export declare function BillingStatusBadge({ status, label, className, }: BillingStatusBadgeProps): import("react").JSX.Element;