@solvapay/react 1.0.9-preview.1 → 1.0.9

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.
package/README.md CHANGED
@@ -34,6 +34,126 @@ By default, `SolvaPayProvider` uses:
34
34
  - `/api/check-purchase` for purchase checks
35
35
  - `/api/create-payment-intent` for payment creation
36
36
  - `/api/process-payment` for payment processing
37
+ - `/api/merchant` for merchant identity (`useMerchant`, `MandateText`)
38
+ - `/api/get-product` for single-product lookup (`useProduct`, `CheckoutSummary`)
39
+
40
+ ## Golden path — `<CheckoutLayout>` one-liner
41
+
42
+ `<CheckoutLayout>` handles the full flow: plan selection, payment (paid/free),
43
+ and usage-based activation. For 90% of integrations this is the only
44
+ component you need.
45
+
46
+ ```tsx
47
+ import { SolvaPayProvider, CheckoutLayout } from '@solvapay/react'
48
+
49
+ export function BuyNow({ email }: { email: string }) {
50
+ return (
51
+ <SolvaPayProvider>
52
+ <CheckoutLayout
53
+ productRef="prd_myapi"
54
+ prefillCustomer={{ email }}
55
+ requireTermsAcceptance
56
+ onResult={result => {
57
+ // result.kind === 'paid' | 'activated'
58
+ }}
59
+ />
60
+ </SolvaPayProvider>
61
+ )
62
+ }
63
+ ```
64
+
65
+ What happens based on what `<CheckoutLayout>` resolves:
66
+
67
+ | Product shape | Flow |
68
+ |---|---|
69
+ | One active plan | Auto-skip selection → pay or activate |
70
+ | Multiple plans | Styled `<PlanSelector>` → pay or activate |
71
+ | Free plan (`requiresPayment: false`) | Skip Stripe entirely → `useActivation` |
72
+ | Usage-based plan | `<ActivationFlow>` (summary → top-up → retry → activated) |
73
+
74
+ Pass `planRef` explicitly to skip the selector and keep today's payment-only
75
+ behavior byte-for-byte (backwards compatible with pre-selector integrations).
76
+
77
+ ### Skipping plan selection
78
+
79
+ ```tsx
80
+ <CheckoutLayout
81
+ planRef="pln_premium"
82
+ productRef="prd_myapi"
83
+ prefillCustomer={{ email }}
84
+ requireTermsAcceptance
85
+ size="auto"
86
+ onSuccess={() => console.log('paid')}
87
+ />
88
+ ```
89
+
90
+ The section below ("Drop-in checkout with `<CheckoutLayout>`") is the original
91
+ single-plan form — still supported, still works the same.
92
+
93
+ ## Drop-in checkout with `<CheckoutLayout>`
94
+
95
+ For a fixed plan checkout (summary, SCA mandate, Stripe PaymentElement,
96
+ prefilled customer echo, optional terms checkbox):
97
+
98
+ ```tsx
99
+ import { SolvaPayProvider, CheckoutLayout } from '@solvapay/react'
100
+
101
+ export function BuyNow({ email }: { email: string }) {
102
+ return (
103
+ <SolvaPayProvider>
104
+ <CheckoutLayout
105
+ planRef="pln_premium"
106
+ productRef="prd_myapi"
107
+ prefillCustomer={{ email }}
108
+ requireTermsAcceptance
109
+ size="auto"
110
+ onSuccess={() => console.log('paid')}
111
+ />
112
+ </SolvaPayProvider>
113
+ )
114
+ }
115
+ ```
116
+
117
+ `size="auto"` uses a `ResizeObserver`, so the same component reflows cleanly
118
+ in chat bubbles, phone viewports, and desktop iframes.
119
+
120
+ ## Composition: slot subcomponents
121
+
122
+ When you need custom layout, compose `<PaymentForm>` with slot children:
123
+
124
+ ```tsx
125
+ <PaymentForm planRef="pln_premium" productRef="prd_myapi" prefillCustomer={{ email }}>
126
+ <PaymentForm.Summary />
127
+ <PaymentForm.CustomerFields />
128
+ <PaymentForm.PaymentElement />
129
+ <PaymentForm.MandateText />
130
+ <PaymentForm.TermsCheckbox />
131
+ <PaymentForm.SubmitButton />
132
+ </PaymentForm>
133
+ ```
134
+
135
+ Passing no children keeps the current default tree for backwards compatibility.
136
+
137
+ ## Localization
138
+
139
+ English ships by default. Swap the locale (which also flows through to Stripe
140
+ Elements) and override any strings you like:
141
+
142
+ ```tsx
143
+ import { SolvaPayProvider, CheckoutLayout, type PartialSolvaPayCopy } from '@solvapay/react'
144
+
145
+ const svSECopy: PartialSolvaPayCopy = {
146
+ cta: { subscribe: 'Prenumerera', processing: 'Bearbetar...' },
147
+ terms: { checkboxLabel: 'Jag godkänner villkoren och integritetspolicyn' },
148
+ }
149
+
150
+ <SolvaPayProvider config={{ locale: 'sv-SE', copy: svSECopy }}>
151
+ <CheckoutLayout planRef="..." productRef="..." />
152
+ </SolvaPayProvider>
153
+ ```
154
+
155
+ Currency formatting is already locale-correct — `formatPrice` uses
156
+ `Intl.NumberFormat` with the provider locale and the plan's `currency` field.
37
157
 
38
158
  ### Custom API Routes
39
159
 
@@ -62,12 +182,10 @@ export default function App() {
62
182
  ```tsx
63
183
  import { SolvaPayProvider } from '@solvapay/react'
64
184
  import { createSupabaseAuthAdapter } from '@solvapay/react-supabase'
185
+ import { supabase } from './lib/supabase'
65
186
 
66
187
  export default function App() {
67
- const adapter = createSupabaseAuthAdapter({
68
- supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL!,
69
- supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
70
- })
188
+ const adapter = createSupabaseAuthAdapter({ client: supabase })
71
189
 
72
190
  return (
73
191
  <SolvaPayProvider config={{ auth: { adapter } }}>
@@ -79,32 +197,175 @@ export default function App() {
79
197
 
80
198
  ### Fully Custom Implementation
81
199
 
200
+ Every data-access call flows through `config.transport`. Build a custom one by
201
+ spreading `createHttpTransport(config)` and overriding the methods you need:
202
+
82
203
  ```tsx
83
- import { SolvaPayProvider } from '@solvapay/react'
204
+ import { SolvaPayProvider, createHttpTransport } from '@solvapay/react'
84
205
 
85
206
  export default function App() {
207
+ const transport = {
208
+ ...createHttpTransport(undefined),
209
+ createPayment: async ({ planRef, productRef }) => {
210
+ const res = await fetch('/api/custom/payment', {
211
+ method: 'POST',
212
+ body: JSON.stringify({ planRef, productRef }),
213
+ })
214
+ if (!res.ok) throw new Error('Failed to create payment')
215
+ return res.json()
216
+ },
217
+ checkPurchase: async () => {
218
+ const res = await fetch('/api/custom/purchase')
219
+ if (!res.ok) throw new Error('Failed to check purchase')
220
+ return res.json()
221
+ },
222
+ }
223
+
86
224
  return (
87
- <SolvaPayProvider
88
- createPayment={async ({ planRef, productRef }) => {
89
- const res = await fetch('/api/custom/payment', {
90
- method: 'POST',
91
- body: JSON.stringify({ planRef, productRef }),
92
- })
93
- if (!res.ok) throw new Error('Failed to create payment')
94
- return res.json()
95
- }}
96
- checkPurchase={async customerRef => {
97
- const res = await fetch(`/api/custom/purchase?customerRef=${customerRef}`)
98
- if (!res.ok) throw new Error('Failed to check purchase')
99
- return res.json()
100
- }}
101
- >
225
+ <SolvaPayProvider config={{ transport }}>
226
+ <CheckoutPage />
227
+ </SolvaPayProvider>
228
+ )
229
+ }
230
+ ```
231
+
232
+ ### MCP App
233
+
234
+ For React trees hosted inside an MCP App (where Stripe.js and direct HTTP
235
+ to your backend are both blocked by the host sandbox), use the MCP subpath:
236
+
237
+ ```tsx
238
+ import { App } from '@modelcontextprotocol/ext-apps'
239
+ import { SolvaPayProvider } from '@solvapay/react'
240
+ import { createMcpAppAdapter } from '@solvapay/react/mcp'
241
+
242
+ const app = new App({ name: 'solvapay', version: '1.0.0' })
243
+ const transport = createMcpAppAdapter(app)
244
+
245
+ export default function Root() {
246
+ return (
247
+ <SolvaPayProvider config={{ transport }}>
102
248
  <CheckoutPage />
103
249
  </SolvaPayProvider>
104
250
  )
105
251
  }
106
252
  ```
107
253
 
254
+ The MCP server is expected to expose tools whose names match `MCP_TOOL_NAMES`
255
+ from `@solvapay/react/mcp` — each transport method maps 1:1 to a tool call.
256
+
257
+ ### Managing plans in an MCP App
258
+
259
+ Once a customer is paid, drop `<CurrentPlanCard />` into the UI and the
260
+ SDK does the rest — plan name, price, next-billing / expiry line,
261
+ payment-method summary (via `get_payment_method`), Update-card and
262
+ Cancel-plan actions. The card returns `null` when there's no active
263
+ purchase, so you can render it unconditionally:
264
+
265
+ ```tsx
266
+ import { CurrentPlanCard } from '@solvapay/react'
267
+
268
+ function Account() {
269
+ return <CurrentPlanCard />
270
+ }
271
+ ```
272
+
273
+ Behind the scenes:
274
+
275
+ - Plan metadata comes from `usePurchase` (provider state, no extra fetch)
276
+ - Payment-method line comes from `usePaymentMethod` → `transport.getPaymentMethod()`
277
+ - `<UpdatePaymentMethodButton>` pre-fetches `transport.createCustomerSession()`
278
+ on mount and renders a real `<a target="_blank">` to the hosted portal —
279
+ MCP host sandboxes permit direct anchor clicks even though scripted
280
+ `window.open` after an async round-trip is blocked.
281
+ - `<CancelPlanButton>` reuses Phase 1 behaviour — no new plumbing.
282
+
283
+ If you want the bare portal-launch button on its own (e.g. in a top nav),
284
+ use `<LaunchCustomerPortalButton />` directly.
285
+
286
+ > `<PlanSwitcher>` (plan upgrade/downgrade with proration) and
287
+ > `<PaymentMethodForm>` (inline Stripe Elements card update) are deferred
288
+ > to follow-up PRs — see the plan in the SDK repo for scope. For plan
289
+ > changes today, MCP apps use `<CancelPlanButton>` + `<CurrentPlanCard>`
290
+ > + a fresh hosted checkout flow.
291
+
292
+ ## Building MCP Apps
293
+
294
+ `@solvapay/react/mcp` ships a turnkey compound plus four composable
295
+ primitives for SolvaPay MCP Apps built on
296
+ [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps).
297
+
298
+ ### Quick start — `<McpApp>`
299
+
300
+ ```tsx
301
+ import { createRoot } from 'react-dom/client'
302
+ import { App } from '@modelcontextprotocol/ext-apps'
303
+ import { McpApp } from '@solvapay/react/mcp'
304
+ import '@solvapay/react/styles.css'
305
+ import '@solvapay/react/mcp/styles.css'
306
+
307
+ const app = new App({ name: 'my-mcp-app', version: '1.0.0' })
308
+ createRoot(document.getElementById('root')!).render(<McpApp app={app} />)
309
+ ```
310
+
311
+ `<McpApp>` handles `app.connect()`, calls the `open_*` tool matching the
312
+ host's invocation context, mounts `<SolvaPayProvider>` with
313
+ `createMcpAppAdapter(app)` + `createMcpFetch(transport)`, and routes to the
314
+ correct per-view primitive.
315
+
316
+ Pass `applyContext` to wire host theme / fonts / safe-area insets from the
317
+ `ext-apps` helpers, override individual screens via `views`, and tweak
318
+ per-slot styling via `classNames`:
319
+
320
+ ```tsx
321
+ <McpApp
322
+ app={app}
323
+ applyContext={ctx => {
324
+ if (ctx?.theme) applyDocumentTheme(ctx.theme)
325
+ }}
326
+ views={{
327
+ account: MyCustomAccountScreen, // optional — one-off override
328
+ }}
329
+ classNames={{ card: 'my-card', button: 'my-btn' }}
330
+ onInitError={err => console.error('[mcp]', err)}
331
+ />
332
+ ```
333
+
334
+ See [`examples/mcp-checkout-app`](../../examples/mcp-checkout-app) for the
335
+ full host integration including the server-side `open_*` tool + UI resource
336
+ registration.
337
+
338
+ ### Per-view primitives
339
+
340
+ When you need a custom shell — your own provider mount, additional routes,
341
+ a bespoke layout — compose the pieces directly:
342
+
343
+ ```tsx
344
+ import {
345
+ createMcpAppAdapter,
346
+ createMcpFetch,
347
+ fetchMcpBootstrap,
348
+ McpCheckoutView,
349
+ McpAccountView,
350
+ McpTopupView,
351
+ McpPaywallView,
352
+ McpNudgeView,
353
+ McpViewRouter,
354
+ useStripeProbe,
355
+ } from '@solvapay/react/mcp'
356
+ ```
357
+
358
+ Every view accepts a `classNames?: McpViewClassNames` partial. Props are
359
+ typed per-view (`McpCheckoutViewProps`, `McpAccountViewProps`,
360
+ `McpTopupViewProps`, `McpPaywallViewProps`, `McpNudgeViewProps`).
361
+
362
+ ### Architecture ADR
363
+
364
+ See [`docs/mcp-app-architecture.md`](./docs/mcp-app-architecture.md) for the
365
+ hybrid-API decision record and the three primitive seam fixes
366
+ (`AmountPicker emit="minor"` / `selector={…}`, `LaunchCustomerPortalButton asChild`)
367
+ that landed with this lift.
368
+
108
369
  ## Components
109
370
 
110
371
  ### SolvaPayProvider
@@ -251,14 +512,42 @@ Access purchase status, active purchases, and helper functions.
251
512
 
252
513
  ```tsx
253
514
  const {
254
- purchases, // Array of all purchases
515
+ purchases, // Array of all purchases (raw — plans + balance transactions)
516
+ balanceTransactions, // Credit top-ups and other non-plan rows
255
517
  loading, // Loading state
256
- hasPaidPurchase, // Boolean: has any paid purchase
257
- activePurchase, // Most recent active purchase
518
+ hasPaidPurchase, // Boolean: has any paid plan purchase
519
+ activePurchase, // Most recent active plan purchase
258
520
  refetch, // Function to refetch purchases
259
521
  } = usePurchase()
260
522
  ```
261
523
 
524
+ #### Plans vs balance
525
+
526
+ Plans and balance credits are orthogonal. A customer can hold at most one
527
+ active plan and any number of balance transactions (credit top-ups today; gift
528
+ credits, referral bonuses, refunds tomorrow).
529
+
530
+ Every plan-shaped accessor — `activePurchase`, `activePaidPurchase`,
531
+ `hasPaidPurchase`, `hasProduct`, plus `cancelledPurchase` /
532
+ `shouldShowCancelledNotice` on `usePurchaseStatus` — filters out balance
533
+ transactions. Top-ups surface on `balanceTransactions`, and the aggregate
534
+ credit balance on `useBalance()`.
535
+
536
+ Classification is structural (`isPlanPurchase` / `isTopupPurchase`): a purchase
537
+ with no `planSnapshot` was never a plan. `metadata.purpose === 'credit_topup'`
538
+ acts as a defense-in-depth guard.
539
+
540
+ ```tsx
541
+ import { isPlanPurchase, isTopupPurchase } from '@solvapay/react'
542
+
543
+ const plans = purchases.filter(isPlanPurchase)
544
+ const topups = purchases.filter(isTopupPurchase)
545
+ ```
546
+
547
+ If you previously relied on top-ups surfacing on `activePurchase` (e.g. "show
548
+ the most recent transaction"), read from `purchases` or `balanceTransactions`
549
+ directly.
550
+
262
551
  ### usePlans
263
552
 
264
553
  Fetch and manage available plans.
@@ -316,9 +605,122 @@ const {
316
605
  All components and hooks are fully typed. Import types as needed:
317
606
 
318
607
  ```tsx
319
- import type { PaymentFormProps, PurchaseStatus, PaymentIntentResult } from '@solvapay/react'
608
+ import type {
609
+ PaymentFormProps,
610
+ PurchaseStatus,
611
+ PaymentIntentResult,
612
+ CheckoutResult,
613
+ PaymentResult,
614
+ ActivationResult,
615
+ } from '@solvapay/react'
320
616
  ```
321
617
 
618
+ ## Plan lifecycle
619
+
620
+ Beyond checkout itself, three thin styled-default components cover the
621
+ post-purchase experience without requiring custom UI.
622
+
623
+ ### `<CancelPlanButton>`
624
+
625
+ Wraps `usePurchaseActions.cancelRenewal` with a built-in confirm dialog,
626
+ loading state, and plan-type-aware copy (subscription vs usage-based).
627
+ Auto-reads the active purchase from `usePurchase()`.
628
+
629
+ ```tsx
630
+ import { CancelPlanButton } from '@solvapay/react'
631
+
632
+ <CancelPlanButton onCancelled={() => router.push('/')} />
633
+ ```
634
+
635
+ Use `confirm={false}` for a single-click cancel, or pass a string to override
636
+ the default copy. The render-prop form exposes `{ cancel, isCancelling,
637
+ disabled, purchase }` for fully custom UI.
638
+
639
+ ### `<CancelledPlanNotice>`
640
+
641
+ Surfaces automatically when the customer has a cancelled-but-still-active
642
+ purchase. Renders the expiration date, days remaining, cancellation reason,
643
+ and a reactivate CTA. Renders nothing when there's nothing to show.
644
+
645
+ ```tsx
646
+ import { CancelledPlanNotice } from '@solvapay/react'
647
+
648
+ <CancelledPlanNotice onReactivated={() => refetch()} />
649
+ ```
650
+
651
+ ### `<CreditGate>`
652
+
653
+ Companion to `<PurchaseGate>` for usage-based flows. Blocks access when the
654
+ customer's credit balance falls below a threshold; renders an embedded
655
+ `<TopupForm>` by default.
656
+
657
+ ```tsx
658
+ import { CreditGate } from '@solvapay/react'
659
+
660
+ <CreditGate minCredits={10}>
661
+ <ExpensiveFeature />
662
+ </CreditGate>
663
+ ```
664
+
665
+ Customize via the `fallback` prop, or use the render-prop form
666
+ (`children: ({ balance, hasCredits, topup }) => …`) for fully custom UI.
667
+
668
+ ## Server-side usage tracking
669
+
670
+ There is intentionally no `useTrackUsage()` client hook. Client-reported usage
671
+ is trivially gamed — a user can simply block the fetch to conserve credits.
672
+ Instead, record usage from your server when the expensive work actually runs:
673
+
674
+ ```ts
675
+ // app/api/do-thing/route.ts
676
+ import { trackUsage } from '@solvapay/next'
677
+
678
+ export async function POST(request: NextRequest) {
679
+ const result = await doTheExpensiveThing()
680
+ await trackUsage(request, { units: 1 })
681
+ return NextResponse.json(result)
682
+ }
683
+ ```
684
+
685
+ The same pattern works from a Supabase Edge Function — see below.
686
+
687
+ ## Using with Supabase Edge Functions
688
+
689
+ Lovable-style apps deploy React frontends against Supabase Edge Functions
690
+ (Deno runtime). The SDK's `api` URL overrides let the exact same
691
+ `<CheckoutLayout>` drop-in work against `/functions/v1/*` routes:
692
+
693
+ ```tsx
694
+ <SolvaPayProvider
695
+ config={{
696
+ api: {
697
+ checkPurchase: `${SUPABASE_URL}/functions/v1/check-purchase`,
698
+ createPayment: `${SUPABASE_URL}/functions/v1/create-payment-intent`,
699
+ processPayment: `${SUPABASE_URL}/functions/v1/process-payment`,
700
+ listPlans: `${SUPABASE_URL}/functions/v1/list-plans`,
701
+ getMerchant: `${SUPABASE_URL}/functions/v1/get-merchant`,
702
+ getProduct: `${SUPABASE_URL}/functions/v1/get-product`,
703
+ // …same pattern for every other endpoint
704
+ },
705
+ auth: {
706
+ adapter: createSupabaseAuthAdapter({ client: supabase }),
707
+ },
708
+ }}
709
+ >
710
+ <CheckoutLayout
711
+ productRef="prd_myapi"
712
+ prefillCustomer={{ email }}
713
+ requireTermsAcceptance
714
+ />
715
+ </SolvaPayProvider>
716
+ ```
717
+
718
+ The matching Deno edge functions ship in
719
+ [`@solvapay/fetch`](../../packages/fetch) — see
720
+ [`examples/supabase-edge`](../../examples/supabase-edge) for one-liner
721
+ `Deno.serve(handler)` files covering every handler including the new
722
+ `get-merchant` and `get-product` endpoints.
723
+
322
724
  ## More Information
323
725
 
324
726
  See [`docs/contributing/architecture.md`](../../docs/contributing/architecture.md) for contributor
@@ -32,6 +32,19 @@ interface AuthAdapter {
32
32
  * return null and let the caller decide how to handle unauthenticated requests.
33
33
  */
34
34
  getUserId: () => Promise<string | null>;
35
+ /**
36
+ * Subscribe to auth-state changes. Optional.
37
+ *
38
+ * When provided, `SolvaPayProvider` calls `subscribe` on mount and
39
+ * triggers `detectAuth` every time the listener fires instead of
40
+ * only running `detectAuth` once. This is how reactive adapters such
41
+ * as `@solvapay/react-supabase` push sign-in / sign-out / token-refresh
42
+ * events into the SDK without the app having to reload.
43
+ *
44
+ * @param listener - Called with no arguments whenever auth state changes
45
+ * @returns An unsubscribe function the provider calls on cleanup
46
+ */
47
+ subscribe?: (listener: () => void) => () => void;
35
48
  }
36
49
  /**
37
50
  * Default auth adapter that only checks localStorage
@@ -32,6 +32,19 @@ interface AuthAdapter {
32
32
  * return null and let the caller decide how to handle unauthenticated requests.
33
33
  */
34
34
  getUserId: () => Promise<string | null>;
35
+ /**
36
+ * Subscribe to auth-state changes. Optional.
37
+ *
38
+ * When provided, `SolvaPayProvider` calls `subscribe` on mount and
39
+ * triggers `detectAuth` every time the listener fires instead of
40
+ * only running `detectAuth` once. This is how reactive adapters such
41
+ * as `@solvapay/react-supabase` push sign-in / sign-out / token-refresh
42
+ * events into the SDK without the app having to reload.
43
+ *
44
+ * @param listener - Called with no arguments whenever auth state changes
45
+ * @returns An unsubscribe function the provider calls on cleanup
46
+ */
47
+ subscribe?: (listener: () => void) => () => void;
35
48
  }
36
49
  /**
37
50
  * Default auth adapter that only checks localStorage
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  defaultAuthAdapter
3
3
  } from "../chunk-OUSEQRCT.js";
4
+ import "../chunk-MLKGABMK.js";
4
5
  export {
5
6
  defaultAuthAdapter
6
7
  };