@openrails/billing-ui 0.0.0-stage → 0.233.2

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 (113) hide show
  1. package/CHANGELOG.md +351 -0
  2. package/LICENSE +21 -0
  3. package/README.md +260 -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 +17 -0
  11. package/dist/account/section.d.ts +27 -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 +21 -0
  18. package/dist/checkout.d.ts +24 -0
  19. package/dist/client/client.d.ts +181 -0
  20. package/dist/client/errors.d.ts +26 -0
  21. package/dist/client/generated/currencies.d.ts +1 -0
  22. package/dist/client/generated/error-codes.d.ts +7 -0
  23. package/dist/client/generated/routes.d.ts +775 -0
  24. package/dist/client/generated/wire.d.ts +682 -0
  25. package/dist/client/index.d.ts +4 -0
  26. package/dist/client/types.d.ts +800 -0
  27. package/dist/client-DXUPzA8C.js +889 -0
  28. package/dist/client-DXUPzA8C.js.map +1 -0
  29. package/dist/client.js +2 -0
  30. package/dist/components/billing-fields.d.ts +39 -0
  31. package/dist/components/card-brands.d.ts +6 -0
  32. package/dist/components/card-fields.d.ts +29 -0
  33. package/dist/components/ccbill-fields.d.ts +8 -0
  34. package/dist/components/methods.d.ts +13 -0
  35. package/dist/components/pay-button.d.ts +6 -0
  36. package/dist/components/qr.d.ts +6 -0
  37. package/dist/components/saved-methods.d.ts +9 -0
  38. package/dist/components/states.d.ts +10 -0
  39. package/dist/components/stripe-card.d.ts +17 -0
  40. package/dist/components/summary.d.ts +12 -0
  41. package/dist/components/ui/alert-dialog.d.ts +18 -0
  42. package/dist/components/ui/badge.d.ts +7 -0
  43. package/dist/components/ui/button.d.ts +8 -0
  44. package/dist/components/ui/card.d.ts +11 -0
  45. package/dist/components/ui/dialog.d.ts +17 -0
  46. package/dist/components/ui/input.d.ts +3 -0
  47. package/dist/components/ui/label.d.ts +3 -0
  48. package/dist/components/ui/radio-group.d.ts +5 -0
  49. package/dist/components/ui/separator.d.ts +3 -0
  50. package/dist/components/ui/skeleton.d.ts +2 -0
  51. package/dist/components/ui/spinner.d.ts +3 -0
  52. package/dist/components/ui/textarea.d.ts +3 -0
  53. package/dist/config-BQELtBh4.js +341 -0
  54. package/dist/config-BQELtBh4.js.map +1 -0
  55. package/dist/dist-CslWupfi.js +3311 -0
  56. package/dist/dist-CslWupfi.js.map +1 -0
  57. package/dist/fixtures.d.ts +9 -0
  58. package/dist/frame.d.ts +47 -0
  59. package/dist/i18n/context.d.ts +4 -0
  60. package/dist/i18n/index.d.ts +2 -0
  61. package/dist/i18n/messages.d.ts +58 -0
  62. package/dist/index.d.ts +29 -0
  63. package/dist/index.js +4090 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/lib/billing.d.ts +39 -0
  66. package/dist/lib/card-brands.d.ts +2 -0
  67. package/dist/lib/card-entry.d.ts +46 -0
  68. package/dist/lib/ccbill.d.ts +8 -0
  69. package/dist/lib/collect.d.ts +51 -0
  70. package/dist/lib/money.d.ts +10 -0
  71. package/dist/lib/period.d.ts +13 -0
  72. package/dist/lib/rail-meta.d.ts +15 -0
  73. package/dist/lib/redirect.d.ts +2 -0
  74. package/dist/lib/stripe-appearance.d.ts +2 -0
  75. package/dist/lib/stripe.d.ts +2 -0
  76. package/dist/locales/de.d.ts +2 -0
  77. package/dist/locales/de.js +207 -0
  78. package/dist/locales/de.js.map +1 -0
  79. package/dist/locales/en.d.ts +204 -0
  80. package/dist/locales/en.js +207 -0
  81. package/dist/locales/en.js.map +1 -0
  82. package/dist/locales/es.d.ts +2 -0
  83. package/dist/locales/es.js +207 -0
  84. package/dist/locales/es.js.map +1 -0
  85. package/dist/locales/ja.d.ts +2 -0
  86. package/dist/locales/ja.js +198 -0
  87. package/dist/locales/ja.js.map +1 -0
  88. package/dist/locales/ko.d.ts +2 -0
  89. package/dist/locales/ko.js +198 -0
  90. package/dist/locales/ko.js.map +1 -0
  91. package/dist/locales/zh.d.ts +2 -0
  92. package/dist/locales/zh.js +198 -0
  93. package/dist/locales/zh.js.map +1 -0
  94. package/dist/modal.d.ts +12 -0
  95. package/dist/provider.d.ts +18 -0
  96. package/dist/psp.d.ts +35 -0
  97. package/dist/react/config.d.ts +34 -0
  98. package/dist/react/context.d.ts +35 -0
  99. package/dist/react/hooks.d.ts +82 -0
  100. package/dist/react/index.d.ts +4 -0
  101. package/dist/react/provider.d.ts +10 -0
  102. package/dist/react/remote.d.ts +10 -0
  103. package/dist/react.js +33 -0
  104. package/dist/react.js.map +1 -0
  105. package/dist/save-payment-method.d.ts +26 -0
  106. package/dist/scope-context.d.ts +17 -0
  107. package/dist/scope.d.ts +6 -0
  108. package/dist/source.d.ts +5 -0
  109. package/dist/styles.css +3 -0
  110. package/dist/tokenized-card-form.d.ts +24 -0
  111. package/dist/types.d.ts +250 -0
  112. package/docs/payment-form-contract.md +136 -0
  113. package/package.json +112 -3
package/CHANGELOG.md ADDED
@@ -0,0 +1,351 @@
1
+ # Changelog
2
+
3
+ ## The public catalog is under /catalog
4
+
5
+ - `listProducts()` reads `GET /catalog/products` (was `GET /products`).
6
+
7
+ ## One public configuration
8
+
9
+ - `getConfig()` reads `GET /config`: the mount's `capabilities`, the currency
10
+ registry (`currencies`) and the merchant's `payment` setup (its PSPs and
11
+ Solana network and tokens; null without a merchant). It replaces
12
+ `getCheckoutConfig()` and `listCurrencies()`: `checkoutConfigSchema` and
13
+ `CheckoutConfig` are `publicConfigSchema` and `PublicConfig` (its `payment`
14
+ is a `PaymentConfig`), and `currencyRegistrySchema` is removed.
15
+ `client.currencies` stays the pinned registry.
16
+ - `BillingProvider` fetches it once and shares it: `useConfig()` reads it,
17
+ `useCurrencyScales()` layers the pinned registry and host overrides over its
18
+ currencies, and the account panels use both.
19
+ - `AccountBilling` and `PaymentMethodsPanel` take their PSPs from it; their
20
+ `psps` prop is removed. "Add card" waits for it, says so when it fails (with
21
+ a retry) or every card PSP is temporarily unavailable, and asks again after
22
+ the PSP's `retry_after`.
23
+ - `PspConfig` has `status` and `retry_after`; a temporarily unavailable PSP
24
+ saves no card (`cardSetupDriver` is null).
25
+
26
+ ## Prices come with their products
27
+
28
+ - `listPrices` is removed with `GET /prices`: `listProducts()` returns every
29
+ product on sale with its current prices. Filter by currency, product or
30
+ renewal in the browser.
31
+
32
+ ## Paying on a customer surface
33
+
34
+ - `client.checkoutSource(id, { customerBase })` reads and pays a checkout
35
+ session as its signed-in customer at a customer surface's
36
+ `/checkout-sessions/{id}` (for example a host's
37
+ `/api/v1/merchants/acme/billing/me`), so its saved cards pay. Without
38
+ `customerBase` the session id alone pays at `/checkout-sessions/{id}` as
39
+ before. `<CheckoutPage customerBase>` passes it through.
40
+
41
+ ## 1.0.0
42
+
43
+ Ships with OpenRails v1.0.0; use the two at the same version. Everything a host
44
+ changes is in [Migrating to v1](../../docs/migrating-to-v1.md).
45
+
46
+ Checkout
47
+
48
+ - A checkout session is the only browser purchase. `createCheckoutSession()`
49
+ mints at `/me/checkout-sessions` and returns a `CheckoutSessionLink` (`url`
50
+ null without a payment page); `client.checkoutSource(id)` is the source of
51
+ `<Checkout>`, `<CheckoutModal>` and `<CheckoutPage>`.
52
+ - The session document's `rails` is `options` (`PaymentOption`, with the
53
+ option's `psp_id`). A rail's own step is one `next_action`
54
+ (`redirect_to_url`, `solana_pay`); a card challenge is `operation`. Saved
55
+ methods carry `card` (`brand`, `last4`, `exp_month`, `exp_year`) and no
56
+ `default`. Fields OpenRails sends as null read as `undefined`.
57
+ - `PayRequest` carries one `billing_details` (`name`, `email`, `phone`,
58
+ `address` with `postal_code` and `country`) in place of the flat
59
+ `name_on_card`, `zip`, `country`, `last_four`, `card_type` and `expiry_date`.
60
+ - Stripe Elements runs on a session inside a `BillingProvider`: the card is
61
+ saved through the customer's client and the session pays it by id.
62
+ - Removed: `createHttpSource`, `CheckoutSourceError`, `checkoutRails`,
63
+ `CheckoutRailOffer`, `PaymentRailOption`.
64
+
65
+ Client
66
+
67
+ - Lists are cursor pages: `listSubscriptions`, `listPaymentMethods`,
68
+ `listPayments` and `listInvoices` take `cursor` and answer `next_cursor`;
69
+ there is no `total`. The hooks page with `nextCursor`.
70
+ - `cancelSubscription`, `resumeSubscription` and
71
+ `setSubscriptionPaymentMethod` resolve to the `Subscription`. A Solana cancel
72
+ or tier change answers `next_action` (`solana_sign_transactions`); sign it
73
+ with `signWalletAction` and repeat the call with `signature`. The separate
74
+ Solana cancel and tier-change methods are removed.
75
+ - `addPaymentMethod` takes `NewCard` with `psp_id`, a `payment_token` or
76
+ `card`, and `billing_details`.
77
+ - There is no default card: `setDefaultPaymentMethod` is
78
+ `setCollectionPaymentMethod`, the hook's `setDefault` is `setCollection`, the
79
+ change event is `payment_method.collection_changed`, and the
80
+ `AccountBilling` / `PaymentMethodsPanel` prop `defaultCurrency` is
81
+ `collectionCurrency`. `PaymentMethod` lists `collection_currencies`.
82
+ - `getSolanaConfig()` is `getCheckoutConfig()` (the network is
83
+ `solana.network`); `listSolanaTokens` takes `{ priceId, wallet }`.
84
+ - `getStatus()` and `BillingStatus` are removed: read `/me/entitlements` and
85
+ `listSubscriptions()`.
86
+ - `listPrices` filters by `productId` and `autoRenew`. A `Price` has
87
+ `product_id`, `archived`, `access_duration_hours` (null: for good) and
88
+ `auto_renew`; `type`, `recurring.interval` and `active` are gone.
89
+ - `Payment` has `kind`, a typed `status` and the `price` it bought; a
90
+ subscription's status is `canceled`.
91
+ - A `TierChange` has no `mode`, `url` or `payment`: a redirect is
92
+ `next_action.url`. Generated list fields are `T[]`, never `null`.
93
+ - A subscription's `access` windows are `starts_at` and `ends_at`.
94
+ - A `Product` has no `catalog_id`. A refusal's code fixes its status:
95
+ `changeTier` answers a registered code for each condition, and a declined
96
+ tier-change charge is `card_declined` with `decline_reason`.
97
+
98
+ ## Hosted checkout sessions
99
+
100
+ - OpenRails serves the checkout session; hosts no longer write
101
+ `/api/v1/checkout/sessions` routes. `client.createCheckoutSession()` mints
102
+ one for the signed-in customer (`priceKey` or `priceId`, `successUrl`) and
103
+ `client.checkoutSource(id)` reads and pays it with the id alone (no bearer).
104
+ A refusal the buyer can act on resolves as a `failed`, `blocked` or
105
+ `expired` result. A `card` rail pays through the shared page too.
106
+ - `<CheckoutFrame url onComplete theme>` frames the shared payment page and
107
+ `<CheckoutPage>` is that page; they speak an origin-checked frame protocol
108
+ (`ready`, `resize`, `complete`, `redirect`; `init {theme}`).
109
+ - `<Checkout onRedirect>` replaces the top-window navigation of redirect rails.
110
+ - The session document gains `embed_origin`.
111
+
112
+ ## Server card entry
113
+
114
+ - Driver `card` (a PSP declared `card_entry: server`, flow `card`): `<Checkout>`
115
+ and `SavePaymentMethod` render plain card inputs with the Collect.js form's
116
+ layout and field errors, load no gateway script, and send
117
+ `card: {number, exp_month, exp_year, cvc}`. Inside a `BillingProvider` the
118
+ card is saved first (`addPaymentMethod({ provider, card })`) and charged by
119
+ id; a page without one sends `card` in `PayRequest`. The inputs are cleared
120
+ once the card is sent.
121
+ - `cardSetupDriver` may return `"card"`; `NewCard` takes `payment_token` or
122
+ `card`, never both.
123
+
124
+ ## Catalog and plan-change client
125
+
126
+ - `@openrails/billing-ui/client` gains the public catalog (`listProducts`,
127
+ `listPrices`, `listCurrencies`), plan change (`previewTierChange`,
128
+ `changeTier`) and Solana (`getSolanaConfig`, `listSolanaTokens`,
129
+ `prepareSolanaTierChange`, `confirmSolanaTierChange`) calls, with the types
130
+ `Product`, `Price`, `Currency`, `TierChangePreview`, `TierChange`,
131
+ `SolanaConfig`, `SolanaToken`, `SolanaTierChangeTx` and `SolanaTierChange`.
132
+ `changeTier` requires an `idempotencyKey`; reuse it until the change
133
+ resolves.
134
+ - React: `useProducts`, and `useSubscriptions().changeTier` (per-row `pending`
135
+ `"change_tier"`, `onChange` `subscription.tier_changed`), which resolves to
136
+ the `TierChange` or a `BillingError`.
137
+
138
+ ## Explicit payment method, fail-fast errors
139
+
140
+ - The checkout pre-selects the customer's default card (`PaymentMethod.default`,
141
+ `SavedPaymentMethod.default`) and always sends the chosen card's id.
142
+ OpenRails refuses a charge that names no method with
143
+ `payment_method_required` (400).
144
+ - A server error (5xx) from `pay` is shown at once and the panel returns to
145
+ ready; hosts keep their idempotency key so paying again replays. A host
146
+ source signals it by throwing an error with a numeric `status`
147
+ (`CheckoutSourceError`, or any error carrying `status`).
148
+ - The client never retries a write. A GET retries once, only on a network
149
+ error or 502/503/504 (429/503 with `Retry-After`), within `RETRY_BUDGET_MS`
150
+ (2 s). New `isServerError`; a 5xx without an error envelope has code
151
+ `server_error`.
152
+
153
+ ## One-click card subscriptions
154
+
155
+ - A checkout host relays a card subscription's pay request with OpenRails'
156
+ `CreateCheckoutSessionRequest.Confirm`; a new Collect.js token subscribes in
157
+ one call. No package API change.
158
+
159
+ ## Advertised checkout rails
160
+
161
+ - Breaking: `checkoutRails(offers)` takes OpenRails' checkout options alone.
162
+ Each option carries the `driver` and `public_config` OpenRails derived from
163
+ the armed PSP (Solana: `token_symbol`, `token_name`, `network`); the package
164
+ no longer derives drivers from `psps`. `CheckoutRailOffer` gains `selector`,
165
+ `driver` and `public_config`.
166
+
167
+ ## Moved into OpenRails
168
+
169
+ The package now lives in `open-rails/openrails` under `sdk/billing-ui` and
170
+ takes the OpenRails version: each OpenRails release `vX.Y.Z` attaches
171
+ `openrails-billing-ui-X.Y.Z.tgz`. The e2e server and generated contract build
172
+ the OpenRails at the same commit. `OPENRAILS_VERSION` is gone from the
173
+ generated contract.
174
+
175
+ ## 0.9.0
176
+
177
+ One embedded card panel for one-time purchases and subscriptions, on every
178
+ card PSP. Requires OpenRails v0.164.0.
179
+
180
+ - Stripe Elements checkout rail (`driver: "stripe_elements"`, from a PSP with
181
+ `flow: "elements"`): saved Stripe cards or a new card in the page, 3-D
182
+ Secure via `authenticatePayment`; no redirect to hosted Checkout. The
183
+ Payment Element is cards only (no Link bank tab or wallets).
184
+ - One screen: saved cards (brand •••• last4 · MM/YY, most recent first),
185
+ inline "Use a new card", one "Pay $X" / "Subscribe for $X every <period>"
186
+ button with the agreement line. No provider chooser for a single rail.
187
+ - New cards are always saved to the account (no consent checkbox) before
188
+ paying by id; Collect.js display metadata (`last_four`, `card_type`,
189
+ `expiry_date`) is sent with every token.
190
+ - Declines stay on the panel: `PayResult.failure` / `CheckoutSession.failure`
191
+ (`{ reason, message, field }`) render next to the card field; retry with
192
+ another card. `requires_action` + `operation_id` authenticates in the page.
193
+ - Collect.js `validationCallback`: inline number/expiry/CVC errors; the
194
+ button waits for three valid fields. Stripe Elements and Collect.js fields
195
+ are themed from the page tokens.
196
+ - `defaultCountry` on Checkout / SavePaymentMethod / AccountBilling /
197
+ TokenizedCardForm; fallback: the browser locale's region
198
+ (`initialCountry`, `browserCountry`).
199
+ - `PspConfig.checkout`; `checkoutPsps(psps)` — only checkout PSPs take new
200
+ cards. `isCardRail`, `useOptionalBillingContext`.
201
+ - `CheckoutModal`: always closable; `gate` renders host content (sign-in)
202
+ in place of the checkout.
203
+ - Account: card labels read "Visa •••• 4242 · 12/30"; failed payments show
204
+ `payment.failure.message`.
205
+ - Breaking: `paymentMethods.consent`/`enterCard` messages are replaced by
206
+ `paymentMethods.saveNotice`; `cardLabel` is "{brand} •••• {last4}".
207
+
208
+ ## 0.8.0
209
+
210
+ Provider-neutral hosts: a host passes OpenRails's browser PSP configs through
211
+ and never branches on Stripe, NMI or any other provider.
212
+
213
+ - `checkoutRails(offers, psps)` / `savedMethodsFor(methods, rails)` derive the
214
+ checkout rails and chargeable saved cards from each PSP's `flow` and public
215
+ config.
216
+ - `SavePaymentMethod`: consented in-page card saving with any PSP — Collect.js
217
+ tokenization or Stripe Elements card setup (Stripe.js is loaded on first
218
+ use). `canSavePaymentMethod`, `cardSetupDriver`.
219
+ - `authenticatePayment(client, operationId, psp)`: 3-D Secure for a pending
220
+ payment operation. `canAuthenticatePayment`.
221
+ - Client: `createCardSetup`, `getCardSetup`, `confirmCardSetup`,
222
+ `getPaymentAuthentication`, `confirmPaymentAuthentication`.
223
+ - Breaking: `AccountBilling`/`PaymentMethodsPanel` take `psps` (and optional
224
+ `cardSetupReturnURL`) instead of `cardSetup`; `CardSetupConfig` is removed.
225
+ Stripe card setup needs OpenRails serving Stripe's `publishable_key`.
226
+
227
+ ## 0.7.0
228
+
229
+ - Periods are exact. OpenRails windows are hours, so a 720h price reads
230
+ "every 30 days" (was "every month") and 8760h "every 365 days"; only exact
231
+ weeks, days or hours are named, in the account panels and the checkout
232
+ summary, in all six locales.
233
+ - Plural messages: `interval.every|per|access.<hour|day|week>` are CLDR plural
234
+ nodes (`{ one, other }`, exact `"1"` for 毎日-style forms) chosen by the
235
+ provider's `locale`; `Translator.plural(key, count)`. Breaking for hosts
236
+ overriding the removed `interval.day|week|month|year|days|hours` keys.
237
+ - Payment history names what was bought: the product's `display_name` from
238
+ OpenRails (`payment.product`, OpenRails after v0.160.0) and the renewal
239
+ period; older servers fall back to "Subscription"/"Purchase".
240
+ - Checkout summary copy ("Renews {period}") is a message: `checkout.renews`.
241
+ - The e2e harness and route contract pin OpenRails master `78039ea` (first
242
+ commit serving `payment.product`; untagged, after v0.160.0).
243
+
244
+ ## 0.6.0
245
+
246
+ - "Add card" works against real OpenRails, which requires the PSP that
247
+ tokenized the card: `CardSetupConfig.provider` (the PSP key, e.g. `"nmi"`)
248
+ is required and sent as `NewCard.provider`. Before, every add was refused
249
+ with 400 `provider is required`.
250
+
251
+ ## 0.5.2
252
+
253
+ - `renderSubscriptionFooter` (on `SubscriptionsPanel` and `AccountBilling`)
254
+ renders host content under a subscription row, e.g. a plan-change control.
255
+ - `useBillingRefresh()` (from `./react`) refetches every hook after a
256
+ host-side change such as a plan switch.
257
+ - The cancel and change-card dialogs use the panel's `appearance`; they read
258
+ only the provider's, so `AccountBilling appearance` missed them.
259
+
260
+ ## 0.5.1
261
+
262
+ - `appearance.theme: "inherit"`: no bundled palette; components use the host
263
+ page's shadcn tokens and follow its `.dark` class.
264
+ - `SubscriptionsPanel`: "Change card" switches the saved card an active NMI
265
+ subscription renews on (`useSubscriptions().setPaymentMethod`), which now
266
+ notifies `subscription.payment_method_changed`.
267
+
268
+ ## 0.5.0
269
+
270
+ Account billing, alongside checkout. No change to checkout's API.
271
+
272
+ - `@openrails/billing-ui/client`: `createBillingClient` for the OpenRails
273
+ customer surface (`/billing/v1/me/*`): subscriptions (list, get, cancel with
274
+ feedback, resume, payment method, Solana on-chain cancel), payment methods
275
+ (list, add tokenized card, remove, per-currency default), payments, invoices,
276
+ status. zod-validated responses, `BillingError` from the error envelope, the
277
+ pinned currency registry for exact money.
278
+ - `@openrails/billing-ui/react`: `BillingProvider` (`onChange` for host cache
279
+ invalidation), `useSubscriptions`, `usePaymentMethods`, `usePayments`.
280
+ - Styled: `AccountBilling`, `SubscriptionsPanel` with cancel/resume dialog and
281
+ provider-portal links, `PaymentMethodsPanel` (adding a card uses
282
+ `TokenizedCardForm`), `PaymentHistory`, `BillingStatusBadge`,
283
+ `BillingUiProvider`, `BillingUiRoot`.
284
+ - Messages: typed bundles with `en de es ja ko zh` under `./locales/*`.
285
+ - `formatAmount` takes an optional locale.
286
+ - The route contract and e2e suite run against real OpenRails v0.159.0.
287
+
288
+ ## 0.4.2
289
+
290
+ No API or visual change.
291
+
292
+ - Components import `cn` from the [`cn`](https://github.com/shadcn-ui/cn)
293
+ package (pinned `0.4.0`), replacing `clsx` and `tailwind-merge`.
294
+
295
+ ## 0.4.1
296
+
297
+ Visual only; no API change.
298
+
299
+ - UI primitives regenerated as shadcn `base-vega` on a zinc palette.
300
+ - Theme tokens now map to Tailwind colors, so token utilities (`bg-primary`,
301
+ `text-muted-foreground`, `border-input`, …) render; in 0.4.0 they emitted no
302
+ CSS, leaving the pay button and selected radios unstyled.
303
+ - `dark:` variants follow `appearance.theme` instead of the OS alone.
304
+ - `CheckoutModal` now paints its popover panel; buttons inside `.orck` drop the
305
+ UA button face.
306
+
307
+ ## 0.4.0
308
+
309
+ Breaking: the package is renamed `openrails-checkout` → `@openrails/billing-ui`.
310
+
311
+ - Import from `@openrails/billing-ui` and `@openrails/billing-ui/styles.css`.
312
+ - The injected stylesheet is `<style id="billing-ui-styles" data-billing-ui>`.
313
+ - Releases attach the packed tarball to the GitHub release instead of
314
+ publishing to npm:
315
+ `https://github.com/open-rails/billing-ui/releases/download/v0.4.0/openrails-billing-ui-0.4.0.tgz`.
316
+
317
+ ## 0.3.0
318
+
319
+ Breaking: the session document carries exact money.
320
+
321
+ - Accepted `processing` outcomes poll the same source without repeating payment
322
+ or tokenization. Ambiguous pay errors retain that pending attempt; local expiry
323
+ cannot turn it into a failure. Pending sessions may omit/null `expires_at`.
324
+ - Export `TokenizedCardForm` for separately consented NMI card setup, reusing the
325
+ existing hosted fields without presenting setup as a payment.
326
+ - Source changes fence late payment callbacks from the previous checkout.
327
+
328
+ - `plan.unit_amount`, `line_items[].amount`, `tax` and `due_today` are int64
329
+ decimal strings of `plan.currency`'s native unit; `plan.unit_decimals` (the
330
+ currency's registered scale) is required. Numeric amounts are refused as an
331
+ unavailable session. Replaces `unit_amount_micros`, `amount_micros`,
332
+ `tax_micros` and `due_today_micros`, which were JSON numbers at an assumed
333
+ six decimals.
334
+ - Rendering never passes money through a JS number (`BigInt` scaling, exact
335
+ Intl decimal formatting, refusal instead of rounding). `formatAmount`,
336
+ `amountToDecimal`, `addAmounts`, `isAmount`, `amountSchema` and
337
+ `unitDecimalsSchema` are exported.
338
+ - Hosts build the plan with `openrails.NewHostedCheckoutPlan` (OpenRails
339
+ `HostedCheckoutSession` types); the package decodes OpenRails' canonical
340
+ `hosted_checkout_session.json` fixture.
341
+
342
+ ## 0.2.5
343
+
344
+ - A Solana option is named after the host-bound token and network; the former
345
+ `USDC` default is removed.
346
+ - Releases publish to npm through the GitHub release workflow.
347
+
348
+ ## 0.2.4
349
+
350
+ - Hosts must accept `name_on_card` on the pay endpoint; `first_name` and
351
+ `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,261 @@
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({
39
+ productKey: "pro",
40
+ priceKey: "monthly",
41
+ })
42
+
43
+ // A shared payment page (Config.Checkout.PageURL) answers with a url:
44
+ <CheckoutFrame url={session.url} theme="dark" onComplete={() => refetchAccess()} />
45
+ // Without one, the app renders the checkout itself:
46
+ <Checkout source={billing.checkoutSource(session.id)} onComplete={() => refetchAccess()} />
47
+ ```
48
+
49
+ `onComplete` is a hint: confirm access from your own authenticated API before
50
+ granting anything. Pass `successUrl` to return the buyer from a redirect step
51
+ (Stripe's hosted page); it must be on one of your app's return origins. A
52
+ server that starts checkout itself mints the session with the Go
53
+ `Client.CreateCheckoutSession` and hands the browser its `id` and `url`.
54
+
55
+ For prepaid credits, a fixed pack is another product/price selection. A
56
+ `customer_amount` price also accepts `amount` when minting. Collect the amount
57
+ on your product page before opening checkout; the resulting session shows and
58
+ charges that exact amount, and its payment form cannot change it:
59
+
60
+ ```tsx
61
+ import { decimalToAmount } from "@openrails/billing-ui/client"
62
+
63
+ // `depositPrice` is in a product from listProducts; `currency` from getConfig().currencies.
64
+ // `enteredAmount` is the text from an <input inputMode="decimal">, e.g. "100".
65
+ const amount = decimalToAmount(enteredAmount, currency.decimals)
66
+ const bounds = depositPrice.customer_amount
67
+ if (
68
+ !amount ||
69
+ !bounds ||
70
+ BigInt(amount) < BigInt(bounds.min_amount) ||
71
+ BigInt(amount) > BigInt(bounds.max_amount)
72
+ ) {
73
+ throw new Error("Choose an amount within the deposit limits")
74
+ }
75
+ const session = await billing.createCheckoutSession({
76
+ priceId: depositPrice.id,
77
+ amount, // USD 100 becomes "100000000"; no floating-point conversion
78
+ })
79
+ ```
80
+
81
+ OpenRails validates the bounds and whole currency minor units before accepting
82
+ payment. Fixed prices reject `amount`; their pack benefit can be greater than
83
+ the purchase price for a bulk discount. Customer-selected deposits currently
84
+ use NMI or Stripe. Changing the selected amount requires minting a new session.
85
+
86
+ A rail's own step is the pay result's `next_action`: `redirect_to_url` (an
87
+ https page, opened in the top window) or `solana_pay` (a `solana:` link shown
88
+ as a QR code). A card challenge is its `operation`, authenticated in the page.
89
+ Stripe Elements needs the buyer's billing client (`<Checkout>` inside a
90
+ `BillingProvider`): the shared payment page offers token rails only.
91
+
92
+ The payment host serves `<CheckoutPage>` from one HTML entry at `PageURL`,
93
+ behind its adapter's `CheckoutFramePolicy` (only the sites in
94
+ `Config.Checkout.EmbedOrigins` may frame it):
95
+
96
+ ```tsx
97
+ import { BillingUiProvider, CheckoutPage } from "@openrails/billing-ui"
98
+
99
+ createRoot(root).render(
100
+ <BillingUiProvider>
101
+ <CheckoutPage appearance={{ variables: brand }} />
102
+ </BillingUiProvider>
103
+ )
104
+ ```
105
+
106
+ The page reads the session id from its URL fragment and talks to its frame
107
+ with `postMessage`, origin-checked both ways: page to app `ready`,
108
+ `resize {height}`, `complete {status}` and `redirect {url}` (a nested frame
109
+ cannot navigate the top window, so the app does); app to page
110
+ `init {theme}`. The page messages only the app origin recorded on its session.
111
+
112
+ ## Account billing
113
+
114
+ ```tsx
115
+ import { createBillingClient } from "@openrails/billing-ui/client"
116
+ import { BillingProvider } from "@openrails/billing-ui/react"
117
+ import { AccountBilling, BillingUiProvider } from "@openrails/billing-ui"
118
+ import { de } from "@openrails/billing-ui/locales/de"
119
+
120
+ // auth-ui's authFetch attaches the bearer and retries once after a refresh;
121
+ // `getToken: () => token` works for any other auth.
122
+ const billing = createBillingClient({ baseUrl: "/billing/v1", fetch: auth.authFetch })
123
+
124
+ <BillingUiProvider appearance={{ theme: "auto" }} messages={de} locale="de" navigate={navigate}>
125
+ <BillingProvider client={billing} onChange={() => queryClient.invalidateQueries({ queryKey: ["billing"] })}>
126
+ <AccountBilling
127
+ plansHref="/plans"
128
+ collectionCurrency="USD" // offers "Use for invoices"
129
+ sendSolanaTransaction={(tx) => wallet.signAndSend(tx)} // signs a Solana cancel
130
+ />
131
+ </BillingProvider>
132
+ </BillingUiProvider>
133
+ ```
134
+
135
+ The panels read OpenRails' public configuration (`GET /config`) themselves,
136
+ once per `BillingProvider`: "Add card" offers each PSP of its `payment` that
137
+ saves a card in the page, and says so while the configuration fails or its
138
+ card PSPs are temporarily unavailable; amounts use its currency registry over
139
+ the pinned copy. `useConfig()` and `useCurrencyScales()` read the same copy
140
+ for host components.
141
+
142
+ ## Catalog and plan changes
143
+
144
+ The client also reads the public catalog and changes a subscription's plan.
145
+ No UI ships for these; hosts render their own. The catalog and Solana reads
146
+ are public routes, served only when the host mounts OpenRails' checkout
147
+ routes; `GET /config` is always served.
148
+
149
+ | Call | Route |
150
+ | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
151
+ | `listProducts()` (each product embeds its prices on sale) | `GET /catalog/products` |
152
+ | `getConfig()` (capabilities, `currencies`, `payment` with PSPs, `solana.network` and tokens; `client.currencies` is the pinned registry) | `GET /config` |
153
+ | `previewTierChange(id, priceId)` | `POST /me/subscriptions/{id}/change-tier/preview` |
154
+ | `changeTier(id, { priceId, idempotencyKey, signature? })` | `POST /me/subscriptions/{id}/change-tier` |
155
+ | `listSolanaTokens({ priceId, wallet })` | `GET /solana/tokens` |
156
+
157
+ ```ts
158
+ const preview = await billing.previewTierChange(sub.id, price.id) // amount_due_now, effective
159
+ const change = await billing.changeTier(sub.id, {
160
+ priceId: price.id,
161
+ idempotencyKey,
162
+ })
163
+ ```
164
+
165
+ - `changeTier` is a money write. Mint one `idempotencyKey` per attempt and
166
+ reuse it until the change resolves (`status: "processing"`, a
167
+ `tier_change_in_flight` refusal naming `metadata.operation_id`, a 5xx or a
168
+ lost response), so OpenRails replays the stored result instead of charging
169
+ twice. `requires_action` carries `operation_id` for `authenticatePayment`;
170
+ replay the same key afterwards.
171
+ - A rail that needs the customer's own step answers `requires_action` with a
172
+ `next_action`. Solana's is `solana_sign_transactions`:
173
+ `signWalletAction(change.next_action, send)` signs and sends its
174
+ transactions and returns the signature; repeat `changeTier` with the same
175
+ key and `signature`. `cancelSubscription(id, { reason, signature? })` works
176
+ the same way: its answer is the subscription, unchanged and carrying
177
+ `next_action` until the signed cancel lands. The hooks run this when given
178
+ `sendTransaction`.
179
+
180
+ ## Provider-neutral flows
181
+
182
+ OpenRails's browser PSP configs (`GET /config`'s `payment.psps`) pick the
183
+ browser flow from each PSP's `flow` and public `config`
184
+ (Collect.js, native card inputs, Stripe Elements, redirect, wallet). Hosts never branch on a
185
+ provider:
186
+
187
+ - A session's `options` are exactly the armed PSPs whose rail can make this
188
+ sale (Solana when it is configured, never CCBill),
189
+ each with its `driver` and `public_config`; `saved_methods` are the buyer's
190
+ cards on them, newest first (paying sends the chosen card's id explicitly).
191
+ Card rails (`collect_js`, `card`, `stripe_elements`) render one panel: saved cards,
192
+ an inline new card and one Pay/Subscribe button, which is the customer's
193
+ confirmation of the displayed terms. No provider chooser with one rail.
194
+ - Inside a `BillingProvider`, a new card is always saved to the account first
195
+ and the source is paid with `payment_method_id`; `requires_action` results
196
+ carrying an `operation` run 3-D Secure in the page. A `failed` result stays
197
+ on the panel with `failure.message` next to its `field`, so the buyer can
198
+ pick another card; the host gives each new attempt a new idempotency key.
199
+ - Only PSPs with `checkout !== false` (`checkoutPsps`) take new cards; others
200
+ stay listed so existing cards and subscriptions keep working.
201
+ - `<SavePaymentMethod psp={psp} onSaved={(id) => ...} />` saves a card in the
202
+ page (needs `BillingProvider`); `canSavePaymentMethod(psp)` filters PSPs
203
+ that support it. After an off-page verification the provider returns to
204
+ `returnURL(setupId)`; confirm with `client.confirmCardSetup(id)`.
205
+ - `authenticatePayment(client, operationId, psp)` completes a pending
206
+ payment's 3-D Secure challenge when `canAuthenticatePayment(psp)`.
207
+ - `defaultCountry` (Checkout, SavePaymentMethod, AccountBilling) preselects
208
+ the billing country, else the browser locale's region.
209
+ - `CheckoutModal gate={<SignIn />}` renders host content (e.g. sign-in) in
210
+ the modal instead of the checkout until cleared.
211
+
212
+ - Panels: `SubscriptionsPanel` (cancel, resume, change card),
213
+ `PaymentMethodsPanel`, `PaymentHistory`, `BillingStatusBadge`,
214
+ `CancelSubscriptionDialog`; `AccountBilling` stacks them.
215
+ - `renderSubscriptionFooter={(s) => ...}` adds host content under a
216
+ subscription row; `useBillingRefresh()` refetches after host-side changes.
217
+ - Hooks: `useSubscriptions` (`cancel`, `resume`, `setPaymentMethod`,
218
+ `changeTier`, per-row `pending`, `nextCursor`), `usePaymentMethods` (`add`,
219
+ `remove`, `setCollection`), `usePayments` (cursor pages), `useProducts` (the
220
+ catalog).
221
+ Actions resolve to `null` or a `BillingError`; they never throw.
222
+ `changeTier` resolves to the `TierChange` instead of `null`. Cancel, resume
223
+ and the card change answer the subscription, which replaces the row.
224
+ - Money is exact: `/me` amounts are int64 native-unit strings, scaled by the
225
+ OpenRails currency registry (`currencies` option to extend it).
226
+ - Messages: English is complete and the fallback; `messages` layers bundles,
227
+ `t` lets the host's i18n win. `useMessages().error(err)` maps error codes.
228
+ Count-dependent messages are CLDR plural nodes (`{ one, other }`, plus an
229
+ exact `"1"` like ICU `=1`), selected with `locale`.
230
+ - Periods are exact: OpenRails windows are hours, so 720h reads "every 30
231
+ days", never "monthly"; only exact weeks, days or hours are named.
232
+ - Styles are scoped under `.orck`; `appearance.theme` is `light`, `dark`,
233
+ `auto` or `inherit`. `inherit` bundles no palette: the host page's shadcn
234
+ tokens (`--background`, `--primary`, ...) and its `.dark` class apply.
235
+
236
+ ## UI primitives
237
+
238
+ `src/components/ui/*` is shadcn (`base-vega`, zinc; see `components.json`),
239
+ managed with `pnpm dlx shadcn@4.21.0 add <name> --overwrite`, importing `cn`
240
+ from the [`cn`](https://github.com/shadcn-ui/cn) package.
241
+
242
+ ## E2E against real OpenRails
243
+
244
+ `e2e/server` is a Go module that builds the OpenRails at this commit (`replace`
245
+ to the repository root) with AuthKit. It serves the
246
+ embedded `/billing/v1` API and `/auth/v1` on a throwaway `postgres:18-alpine`
247
+ container (Docker and Go required), with test-only `POST /__test/users` (user +
248
+ access token) and `POST /__test/users/{id}/billing` (imported subscription,
249
+ sale and saved card on a credential-less NMI PSP).
250
+
251
+ ```sh
252
+ pnpm test:e2e # Playwright against the real server
253
+ pnpm contract # regenerate OpenRails' contract files, src/client/generated among them
254
+ pnpm contract:check # fail if a generated contract file is stale
255
+ ```
256
+
257
+ `e2e/openrails/account.spec.ts` drives the packaged `AccountBilling` (built from
258
+ `dist/`) through list, cancel, resume, the in-use card refusal and history. Set
259
+ `BILLING_UI_SCREENSHOTS=<dir>` to capture light, dark and mobile screenshots.
260
+ Card deletion calls NMI, which the harness has no credentials for; jsdom tests
261
+ 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
+ cardSetupReturnURL?: PaymentMethodsPanelProps["cardSetupReturnURL"];
7
+ /** Offers to make a card the one that collects this currency's invoices. */
8
+ collectionCurrency?: 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({ cardSetupReturnURL, collectionCurrency, defaultCountry, sendSolanaTransaction, plansHref, renderSubscriptionFooter, historyPageSize, appearance, className, }: AccountBillingProps): import("react").JSX.Element;