@nebulr-group/bridge-svelte 0.4.0-beta.8 → 0.4.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.
@@ -67,22 +67,15 @@ export async function bridgeBootstrap(url, config, routeConfig = { rules: [], de
67
67
  }
68
68
  }
69
69
  else if (stripeSuccess && sessionId) {
70
- // Stripe payment success — verify with bridge-api (server calls Stripe directly),
71
- // then refresh tokens so the new JWT has shouldSelectPlan: false before redirect.
70
+ // Stripe payment success — auth-core's confirmStripeCheckout() verifies the session
71
+ // with bridge-api (server calls Stripe directly) and refreshes tokens so the new JWT
72
+ // has shouldSelectPlan: false before we redirect. It throws on a non-OK response or
73
+ // network error → we fall through to the payment-error redirect. (TBP-369: the HTTP +
74
+ // token-refresh logic now lives in auth-core so every plugin port can reuse it.)
72
75
  logger.debug('[bridgeBootstrap] Stripe success callback — confirming with bridge-api');
73
76
  const bridge = getBridgeAuth();
74
- const ctx = bridge.getApiContext();
75
77
  try {
76
- const res = await (kitFetch ?? fetch)(`${ctx.apiBaseUrl}/v1/account/stripe/confirm-checkout`, {
77
- method: 'POST',
78
- headers: { 'Content-Type': 'application/json' },
79
- body: JSON.stringify({ sessionId, appId: ctx.appId }),
80
- });
81
- if (!res.ok) {
82
- logger.warn('[bridgeBootstrap] confirm-checkout failed', res.status);
83
- redirect(303, getConfig().billing?.paymentErrorRoute ?? '/payment-error');
84
- }
85
- await bridge.refreshTokens();
78
+ await bridge.confirmStripeCheckout(sessionId, kitFetch);
86
79
  redirect(303, redirectTo);
87
80
  }
88
81
  catch (err) {
@@ -117,22 +110,19 @@ export async function bridgeBootstrap(url, config, routeConfig = { rules: [], de
117
110
  catch {
118
111
  // Non-fatal — stale tokens will be caught by route guard
119
112
  }
120
- // 2b. Paywall redirect — fires before any page renders. Reads shouldSelectPlan
121
- // and paymentsAutoRedirect from getSubscriptionStatus(). Only redirects when:
113
+ // 2b. Paywall redirect — fires before any page renders. The framework-agnostic
114
+ // decision (authenticated + shouldSelectPlan + not opted out via
115
+ // paymentsAutoRedirect) now lives in auth-core's shouldRedirectToPaywall()
116
+ // (TBP-369). We only own the route/config guards here:
122
117
  // - billing.paywallRoute is configured
123
118
  // - the current path is not already the paywall route (no redirect loop)
124
- // - the tenant is authenticated but has not selected a plan
125
- // - the app has not opted out via paymentsAutoRedirect: false
126
119
  try {
127
120
  const paywallRoute = getConfig().billing?.paywallRoute;
128
121
  if (paywallRoute && url.pathname !== paywallRoute) {
129
122
  const bridge = getBridgeAuth();
130
- if (bridge.isAuthenticated()) {
131
- const status = await bridge.getSubscriptionStatus();
132
- if (status?.shouldSelectPlan === true && status?.paymentsAutoRedirect !== false) {
133
- logger.debug('[bridgeBootstrap] paywall redirect', paywallRoute);
134
- redirect(303, paywallRoute);
135
- }
123
+ if (await bridge.shouldRedirectToPaywall()) {
124
+ logger.debug('[bridgeBootstrap] paywall redirect', paywallRoute);
125
+ redirect(303, paywallRoute);
136
126
  }
137
127
  }
138
128
  }
@@ -15,6 +15,12 @@
15
15
  loginHref?: string | undefined;
16
16
  /** Heading text. Pass `null`/`''` to render no heading and use your own page title. */
17
17
  heading?: string | null;
18
+ /** TBP-36 — preselected plan applied at tenant creation (from ?signupPlan= links). */
19
+ plan?: string | null;
20
+ /** TBP-36 — currency of the preselected plan's price offer. */
21
+ currency?: string | null;
22
+ /** TBP-36 — recurrence interval of the preselected plan's price offer. */
23
+ recurrenceInterval?: string | null;
18
24
  footer?: Snippet;
19
25
  }
20
26
 
@@ -24,6 +30,9 @@
24
30
  showLoginLink = true,
25
31
  loginHref = undefined,
26
32
  heading = 'Create your account',
33
+ plan = null,
34
+ currency = null,
35
+ recurrenceInterval = null,
27
36
  footer,
28
37
  class: className,
29
38
  style,
@@ -44,7 +53,11 @@
44
53
  error = null;
45
54
  loading = true;
46
55
  try {
47
- await getBridgeAuth().signup(email, firstName, lastName);
56
+ await getBridgeAuth().signup(email, firstName, lastName, plan ? {
57
+ plan,
58
+ currency: currency ?? undefined,
59
+ recurrenceInterval: recurrenceInterval ?? undefined,
60
+ } : undefined);
48
61
  success = true;
49
62
  onSignup?.();
50
63
  } catch (err: any) {
@@ -8,6 +8,12 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
8
8
  loginHref?: string | undefined;
9
9
  /** Heading text. Pass `null`/`''` to render no heading and use your own page title. */
10
10
  heading?: string | null;
11
+ /** TBP-36 — preselected plan applied at tenant creation (from ?signupPlan= links). */
12
+ plan?: string | null;
13
+ /** TBP-36 — currency of the preselected plan's price offer. */
14
+ currency?: string | null;
15
+ /** TBP-36 — recurrence interval of the preselected plan's price offer. */
16
+ recurrenceInterval?: string | null;
11
17
  footer?: Snippet;
12
18
  }
13
19
  declare const SignupForm: import("svelte").Component<Props, {}, "">;
@@ -24,6 +24,7 @@
24
24
  type BillingSubscriptionSnapshot,
25
25
  } from '@nebulr-group/bridge-auth-core';
26
26
  import { getBridgeAuth } from '../../../core/bridge-instance.js';
27
+ import { getConfig } from '../../stores/config.store.js';
27
28
 
28
29
  type Chassis = 'bar' | 'rail' | 'card';
29
30
 
@@ -40,6 +41,9 @@
40
41
  class?: string;
41
42
  /** Override the default CTA click handler (links to billing surface). */
42
43
  onActionClick?: (state: BillingNoticeState) => void;
44
+ /** CTA destination for this instance. Overrides `billing.manageRoute`
45
+ * config; `onActionClick` takes precedence over both. */
46
+ actionHref?: string;
43
47
  }
44
48
 
45
49
  let {
@@ -47,6 +51,7 @@
47
51
  mode = 'soft',
48
52
  class: className = '',
49
53
  onActionClick,
54
+ actionHref,
50
55
  }: Props = $props();
51
56
 
52
57
  let snapshot = $state<BillingSubscriptionSnapshot>(useBridge().subscription.snapshot());
@@ -194,11 +199,16 @@
194
199
  onActionClick(noticeState);
195
200
  return;
196
201
  }
197
- // Default: open the existing billing surface. The exact destination
198
- // depends on app configuration for now, navigate to /billing on the
199
- // current origin. Apps can override via `onActionClick`.
202
+ // Default: open the app's billing surface. Destination priority:
203
+ // `actionHref` prop `billing.manageRoute` config '/billing'.
200
204
  if (typeof window !== 'undefined') {
201
- window.location.href = '/billing';
205
+ let manageRoute: string | undefined;
206
+ try {
207
+ manageRoute = getConfig().billing?.manageRoute;
208
+ } catch {
209
+ // Config not initialized — fall through to the default.
210
+ }
211
+ window.location.href = actionHref ?? manageRoute ?? '/billing';
202
212
  }
203
213
  }
204
214
  </script>
@@ -13,6 +13,9 @@ interface Props {
13
13
  class?: string;
14
14
  /** Override the default CTA click handler (links to billing surface). */
15
15
  onActionClick?: (state: BillingNoticeState) => void;
16
+ /** CTA destination for this instance. Overrides `billing.manageRoute`
17
+ * config; `onActionClick` takes precedence over both. */
18
+ actionHref?: string;
16
19
  }
17
20
  declare const BridgeBillingNotice: import("svelte").Component<Props, {}, "">;
18
21
  type BridgeBillingNotice = ReturnType<typeof BridgeBillingNotice>;
@@ -27,6 +27,7 @@
27
27
  type QuotaSnapshot,
28
28
  } from '@nebulr-group/bridge-auth-core';
29
29
  import { getBridgeAuth } from '../../../core/bridge-instance.js';
30
+ import { getConfig } from '../../stores/config.store.js';
30
31
 
31
32
  type Chassis = 'rail';
32
33
  type Severity = 'warn' | 'critical';
@@ -43,6 +44,9 @@
43
44
  class?: string;
44
45
  /** Override the default Upgrade CTA click handler. */
45
46
  onActionClick?: (snap: QuotaSnapshot) => void;
47
+ /** CTA destination for this instance. Overrides `billing.manageRoute`
48
+ * config; `onActionClick` takes precedence over both. */
49
+ actionHref?: string;
46
50
  /**
47
51
  * Optional display label override. Defaults to the snapshot's `.label`
48
52
  * (raw metric key for US-11; the framework wrapper will eventually
@@ -56,6 +60,7 @@
56
60
  chassis = 'rail',
57
61
  class: className = '',
58
62
  onActionClick,
63
+ actionHref,
59
64
  label,
60
65
  }: Props = $props();
61
66
 
@@ -218,8 +223,16 @@
218
223
  onActionClick(snapshot);
219
224
  return;
220
225
  }
226
+ // Destination priority: `actionHref` prop → `billing.manageRoute` config
227
+ // → '/billing'.
221
228
  if (typeof window !== 'undefined') {
222
- window.location.href = '/billing';
229
+ let manageRoute: string | undefined;
230
+ try {
231
+ manageRoute = getConfig().billing?.manageRoute;
232
+ } catch {
233
+ // Config not initialized — fall through to the default.
234
+ }
235
+ window.location.href = actionHref ?? manageRoute ?? '/billing';
223
236
  }
224
237
  }
225
238
  </script>
@@ -12,6 +12,9 @@ interface Props {
12
12
  class?: string;
13
13
  /** Override the default Upgrade CTA click handler. */
14
14
  onActionClick?: (snap: QuotaSnapshot) => void;
15
+ /** CTA destination for this instance. Overrides `billing.manageRoute`
16
+ * config; `onActionClick` takes precedence over both. */
17
+ actionHref?: string;
15
18
  /**
16
19
  * Optional display label override. Defaults to the snapshot's `.label`
17
20
  * (raw metric key for US-11; the framework wrapper will eventually
@@ -16,12 +16,18 @@
16
16
  /**
17
17
  * Which billing interval tab is selected by default (`'month'` | `'year'` |
18
18
  * `'week'` | `'day'`). Falls back to the first available interval when the
19
- * requested one isn't offered by any plan. Default: `'month'`.
19
+ * requested one isn't offered by any plan. Default: `'year'` (TBP-34 —
20
+ * annual is the default; apps without yearly prices fall back gracefully).
20
21
  */
21
22
  defaultInterval?: BillingInterval;
22
23
  onSelect?: (detail: { plan: Plan; price: PriceOfferSdk }) => void;
23
24
  /** Custom card renderer. `prices` is the plan's full price list; `interval` is the active tab. */
24
25
  planCard?: Snippet<[{ plan: Plan; prices: PriceOfferSdk[]; isCurrent: boolean; interval: BillingInterval; onPick: (price: PriceOfferSdk) => void }]>;
26
+ /** Replaces the built-in description paragraph of the default card —
27
+ * custom copy or markup per plan without reimplementing the whole card. */
28
+ planDescription?: Snippet<[{ plan: Plan; isCurrent: boolean }]>;
29
+ /** Rendered at the bottom of the default card, after the price buttons. */
30
+ planFooter?: Snippet<[{ plan: Plan; isCurrent: boolean }]>;
25
31
  emptyState?: Snippet;
26
32
  loadingState?: Snippet;
27
33
  }
@@ -29,9 +35,11 @@
29
35
  let {
30
36
  successRedirect = '/subscription',
31
37
  cancelRedirect = '/subscription',
32
- defaultInterval = 'month',
38
+ defaultInterval = 'year',
33
39
  onSelect,
34
40
  planCard,
41
+ planDescription,
42
+ planFooter,
35
43
  emptyState,
36
44
  loadingState,
37
45
  class: className,
@@ -117,6 +125,52 @@
117
125
  }
118
126
  });
119
127
 
128
+ // ── Plan-change confirmation (TBP-33) ─────────────────────────────────
129
+ // Switching an existing subscriber's plan is instant (no Stripe checkout
130
+ // page), so it needs an explicit confirm step. Free selection and the
131
+ // checkout redirect keep their existing single-click behavior.
132
+ let confirmTarget = $state<{ plan: Plan; price: PriceOfferSdk } | null>(null);
133
+ let confirmBusy = $state(false);
134
+ let confirmError = $state<string | null>(null);
135
+ let successNotice = $state<string | null>(null);
136
+ let successTimer: ReturnType<typeof setTimeout> | undefined;
137
+
138
+ const currentPlanName = $derived.by(() => {
139
+ const found = (plans ?? []).find((p) => p.key === currentPlanKey);
140
+ return found?.name ?? currentPlanKey ?? 'your current plan';
141
+ });
142
+
143
+ function formatPrice(price: PriceOfferSdk): string {
144
+ return price.amount === 0
145
+ ? 'Free'
146
+ : `${price.amount} ${price.currency.toUpperCase()} / ${price.recurrenceInterval}`;
147
+ }
148
+
149
+ function showSuccess(message: string): void {
150
+ successNotice = message;
151
+ clearTimeout(successTimer);
152
+ successTimer = setTimeout(() => (successNotice = null), 6000);
153
+ }
154
+
155
+ async function confirmPlanChange(): Promise<void> {
156
+ if (!confirmTarget) return;
157
+ const { plan, price } = confirmTarget;
158
+ confirmBusy = true;
159
+ confirmError = null;
160
+ try {
161
+ await getBridgeAuth().changePlan(plan.key, price);
162
+ await loadSubscription();
163
+ confirmTarget = null;
164
+ showSuccess(`You're now on ${plan.name} (${formatPrice(price)}).`);
165
+ onSelect?.({ plan, price });
166
+ } catch (err) {
167
+ // AC: failure surfaces inside the dialog, not just a banner.
168
+ confirmError = err instanceof Error ? err.message : 'Plan change failed';
169
+ } finally {
170
+ confirmBusy = false;
171
+ }
172
+ }
173
+
120
174
  async function handlePick(plan: Plan, price: PriceOfferSdk): Promise<void> {
121
175
  picking = true;
122
176
  pickError = null;
@@ -132,10 +186,10 @@
132
186
  await loadSubscription();
133
187
  onSelect?.({ plan, price });
134
188
  } else if (status?.paymentsEnabled) {
135
- // Already has payment method — change plan
136
- await getBridgeAuth().changePlan(plan.key, price);
137
- await loadSubscription();
138
- onSelect?.({ plan, price });
189
+ // Already has payment method — instant switch, so require an explicit
190
+ // confirmation (TBP-33). The actual changePlan runs in confirmPlanChange.
191
+ confirmTarget = { plan, price };
192
+ confirmError = null;
139
193
  } else {
140
194
  // Needs checkout — redirect to Stripe (or direct plan set when Stripe not configured)
141
195
  const base = getConfig().callbackUrl ?? `${window.location.origin}/auth/oauth-callback`;
@@ -192,6 +246,12 @@
192
246
  <Alert variant="error">{pickError}</Alert>
193
247
  {/if}
194
248
 
249
+ {#if successNotice}
250
+ <div class="bridge-plan-success" data-bridge-plan-success role="status">
251
+ <Alert variant="success">{successNotice}</Alert>
252
+ </div>
253
+ {/if}
254
+
195
255
  {#if uiState === 'payment-failed'}
196
256
  <div data-bridge-plan-payment-failed class="bridge-plan-payment-failed">
197
257
  <Alert variant="error">
@@ -253,7 +313,9 @@
253
313
  {/if}
254
314
  </div>
255
315
 
256
- {#if plan.description}
316
+ {#if planDescription}
317
+ {@render planDescription({ plan, isCurrent })}
318
+ {:else if plan.description}
257
319
  <p class="bridge-plan-description">{plan.description}</p>
258
320
  {/if}
259
321
 
@@ -288,10 +350,52 @@
288
350
  </p>
289
351
  {/if}
290
352
  </div>
353
+
354
+ {#if planFooter}
355
+ {@render planFooter({ plan, isCurrent })}
356
+ {/if}
291
357
  </div>
292
358
  {/if}
293
359
  {/each}
294
360
  </div>
295
361
  {/if}
296
362
  {/if}
363
+
364
+ {#if confirmTarget}
365
+ <div class="bridge-plan-confirm-backdrop" data-bridge-plan-confirm role="dialog" aria-modal="true" aria-labelledby="bridge-plan-confirm-title" tabindex="-1">
366
+ <div class="bridge-plan-confirm">
367
+ <h3 id="bridge-plan-confirm-title" class="bridge-plan-confirm-title">Change plan?</h3>
368
+ <p class="bridge-plan-confirm-body">
369
+ Switch from <strong>{currentPlanName}</strong> to
370
+ <strong>{confirmTarget.plan.name}</strong> ({formatPrice(confirmTarget.price)}).
371
+ </p>
372
+ <p class="bridge-plan-confirm-note">
373
+ The change takes effect immediately — any price difference is prorated
374
+ on your next invoice.
375
+ </p>
376
+ {#if confirmError}
377
+ <Alert variant="error">{confirmError}</Alert>
378
+ {/if}
379
+ <div class="bridge-plan-confirm-actions">
380
+ <button
381
+ type="button"
382
+ class="bridge-btn-secondary"
383
+ disabled={confirmBusy}
384
+ onclick={() => (confirmTarget = null)}
385
+ >
386
+ Cancel
387
+ </button>
388
+ <button
389
+ type="button"
390
+ class="bridge-btn-primary"
391
+ data-bridge-plan-confirm-btn
392
+ disabled={confirmBusy}
393
+ onclick={confirmPlanChange}
394
+ >
395
+ {confirmBusy ? 'Switching…' : 'Confirm change'}
396
+ </button>
397
+ </div>
398
+ </div>
399
+ </div>
400
+ {/if}
297
401
  </div>
@@ -8,7 +8,8 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
8
8
  /**
9
9
  * Which billing interval tab is selected by default (`'month'` | `'year'` |
10
10
  * `'week'` | `'day'`). Falls back to the first available interval when the
11
- * requested one isn't offered by any plan. Default: `'month'`.
11
+ * requested one isn't offered by any plan. Default: `'year'` (TBP-34 —
12
+ * annual is the default; apps without yearly prices fall back gracefully).
12
13
  */
13
14
  defaultInterval?: BillingInterval;
14
15
  onSelect?: (detail: {
@@ -23,6 +24,17 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
23
24
  interval: BillingInterval;
24
25
  onPick: (price: PriceOfferSdk) => void;
25
26
  }]>;
27
+ /** Replaces the built-in description paragraph of the default card —
28
+ * custom copy or markup per plan without reimplementing the whole card. */
29
+ planDescription?: Snippet<[{
30
+ plan: Plan;
31
+ isCurrent: boolean;
32
+ }]>;
33
+ /** Rendered at the bottom of the default card, after the price buttons. */
34
+ planFooter?: Snippet<[{
35
+ plan: Plan;
36
+ isCurrent: boolean;
37
+ }]>;
26
38
  emptyState?: Snippet;
27
39
  loadingState?: Snippet;
28
40
  }
@@ -0,0 +1,258 @@
1
+ // Billing CTA destination — config-driven manage route (TBP-451 / S1).
2
+ //
3
+ // Under test: the destination precedence shared by <BridgeBillingNotice> and
4
+ // <BridgeQuotaBanner>:
5
+ //
6
+ // onActionClick callback (highest — short-circuits, no navigation at all)
7
+ // → `actionHref` prop
8
+ // → getConfig().billing?.manageRoute
9
+ // → '/billing' (default)
10
+ //
11
+ // HARNESS NOTE: bridge-svelte's vitest config (vitest.config.ts) runs in a
12
+ // `node` environment with no Svelte compiler plugin and no DOM (jsdom /
13
+ // happy-dom / @testing-library/svelte are NOT installed anywhere in the
14
+ // workspace), so neither component can be mounted here. Following the
15
+ // established pattern in billing-notice-gate.test.ts and plan-selector.test.ts,
16
+ // this file exercises an EXACT replica of the components' `handleAction()`
17
+ // script-block logic, with the external singletons (`getConfig` from
18
+ // config.store.js and `window.location`) injected as mocks instead of
19
+ // module-mocking them.
20
+ //
21
+ // The replicas below mirror, line for line:
22
+ // - BridgeBillingNotice.svelte `function handleAction()`
23
+ // - BridgeQuotaBanner.svelte `function handleAction()`
24
+ //
25
+ // The two differ only in (a) the argument handed to `onActionClick`
26
+ // (BillingNoticeState vs QuotaSnapshot) and (b) the quota banner's
27
+ // `if (!snapshot) return;` guard. The destination resolution is byte-identical
28
+ // and MUST stay that way — the "both components agree" block below is the
29
+ // regression guard for that.
30
+ //
31
+ // If either component's handleAction changes, update the replica here to
32
+ // match — a drift between the two is a test bug, not a component bug.
33
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
34
+ const configWith = (manageRoute) => () => manageRoute === undefined ? {} : { billing: { manageRoute } };
35
+ const uninitializedConfig = () => {
36
+ throw new Error('Config has not been initialized. Call initConfig(...) early in app startup.');
37
+ };
38
+ /** Config initialized, but the app never declared a `billing` block. */
39
+ const configWithoutBillingBlock = () => ({});
40
+ function makeNoticeHarness(opts = {}) {
41
+ const { getConfig = uninitializedConfig, actionHref, onActionClick, hasWindow = true, } = opts;
42
+ // Records every write to `window.location.href`.
43
+ const navigations = [];
44
+ const noticeState = 'past_due';
45
+ function handleAction() {
46
+ if (onActionClick) {
47
+ onActionClick(noticeState);
48
+ return;
49
+ }
50
+ // Default: open the app's billing surface. Destination priority:
51
+ // `actionHref` prop → `billing.manageRoute` config → '/billing'.
52
+ if (hasWindow) {
53
+ let manageRoute;
54
+ try {
55
+ manageRoute = getConfig().billing?.manageRoute;
56
+ }
57
+ catch {
58
+ // Config not initialized — fall through to the default.
59
+ }
60
+ navigations.push(actionHref ?? manageRoute ?? '/billing');
61
+ }
62
+ }
63
+ return { handleAction, navigations, noticeState };
64
+ }
65
+ function makeQuotaHarness(opts = {}) {
66
+ const { getConfig = uninitializedConfig, actionHref, onActionClick, hasWindow = true, snapshot = { metric: 'ai_completions' }, } = opts;
67
+ const navigations = [];
68
+ function handleAction() {
69
+ if (!snapshot)
70
+ return;
71
+ if (onActionClick) {
72
+ onActionClick(snapshot);
73
+ return;
74
+ }
75
+ // Destination priority: `actionHref` prop → `billing.manageRoute` config
76
+ // → '/billing'.
77
+ if (hasWindow) {
78
+ let manageRoute;
79
+ try {
80
+ manageRoute = getConfig().billing?.manageRoute;
81
+ }
82
+ catch {
83
+ // Config not initialized — fall through to the default.
84
+ }
85
+ navigations.push(actionHref ?? manageRoute ?? '/billing');
86
+ }
87
+ }
88
+ return { handleAction, navigations, snapshot };
89
+ }
90
+ /** The two harnesses, keyed by component, for the shared-behaviour table. */
91
+ const HARNESSES = {
92
+ BridgeBillingNotice: makeNoticeHarness,
93
+ BridgeQuotaBanner: (opts) => makeQuotaHarness(opts),
94
+ };
95
+ const COMPONENTS = Object.keys(HARNESSES);
96
+ // ── Tests ────────────────────────────────────────────────────────────────────
97
+ describe('Billing CTA manage-route precedence (TBP-451)', () => {
98
+ beforeEach(() => {
99
+ vi.restoreAllMocks();
100
+ });
101
+ describe.each(COMPONENTS)('%s', (component) => {
102
+ const harness = HARNESSES[component];
103
+ describe("default — '/billing'", () => {
104
+ it('navigates to /billing when config is uninitialized and no props are given', () => {
105
+ const h = harness({ getConfig: uninitializedConfig });
106
+ h.handleAction();
107
+ expect(h.navigations).toEqual(['/billing']);
108
+ });
109
+ it('navigates to /billing when config is loaded but has no billing block', () => {
110
+ const h = harness({ getConfig: configWithoutBillingBlock });
111
+ h.handleAction();
112
+ expect(h.navigations).toEqual(['/billing']);
113
+ });
114
+ it('navigates to /billing when billing exists but manageRoute is unset', () => {
115
+ const h = harness({ getConfig: () => ({ billing: {} }) });
116
+ h.handleAction();
117
+ expect(h.navigations).toEqual(['/billing']);
118
+ });
119
+ });
120
+ describe('config — billing.manageRoute', () => {
121
+ it('honors the configured manageRoute over the built-in default', () => {
122
+ const h = harness({ getConfig: configWith('/settings/billing') });
123
+ h.handleAction();
124
+ expect(h.navigations).toEqual(['/settings/billing']);
125
+ });
126
+ it('honors an absolute manageRoute URL verbatim', () => {
127
+ const h = harness({ getConfig: configWith('https://billing.example.com/portal') });
128
+ h.handleAction();
129
+ expect(h.navigations).toEqual(['https://billing.example.com/portal']);
130
+ });
131
+ it('reads the config on every click, so a later initConfig is picked up', () => {
132
+ let manageRoute;
133
+ const h = harness({ getConfig: () => ({ billing: { manageRoute } }) });
134
+ h.handleAction(); // config not yet carrying a route
135
+ manageRoute = '/settings/billing';
136
+ h.handleAction(); // same component instance, config now set
137
+ expect(h.navigations).toEqual(['/billing', '/settings/billing']);
138
+ });
139
+ });
140
+ describe('actionHref prop', () => {
141
+ it('overrides the configured manageRoute', () => {
142
+ const h = harness({
143
+ getConfig: configWith('/settings/billing'),
144
+ actionHref: '/team/upgrade',
145
+ });
146
+ h.handleAction();
147
+ expect(h.navigations).toEqual(['/team/upgrade']);
148
+ });
149
+ it('overrides the default when no config is available at all', () => {
150
+ const h = harness({ getConfig: uninitializedConfig, actionHref: '/team/upgrade' });
151
+ h.handleAction();
152
+ expect(h.navigations).toEqual(['/team/upgrade']);
153
+ });
154
+ });
155
+ describe('onActionClick callback (highest precedence)', () => {
156
+ it('wins over both actionHref and config, and performs NO navigation', () => {
157
+ const onActionClick = vi.fn();
158
+ const h = harness({
159
+ getConfig: configWith('/settings/billing'),
160
+ actionHref: '/team/upgrade',
161
+ onActionClick,
162
+ });
163
+ h.handleAction();
164
+ expect(onActionClick).toHaveBeenCalledOnce();
165
+ expect(h.navigations).toEqual([]);
166
+ });
167
+ it('wins even with no other destination configured', () => {
168
+ const onActionClick = vi.fn();
169
+ const h = harness({ getConfig: uninitializedConfig, onActionClick });
170
+ h.handleAction();
171
+ expect(onActionClick).toHaveBeenCalledOnce();
172
+ expect(h.navigations).toEqual([]);
173
+ });
174
+ it('never touches getConfig when the callback short-circuits', () => {
175
+ const getConfig = vi.fn(configWith('/settings/billing'));
176
+ const h = harness({ getConfig, onActionClick: vi.fn() });
177
+ h.handleAction();
178
+ expect(getConfig).not.toHaveBeenCalled();
179
+ });
180
+ });
181
+ describe('SSR guard', () => {
182
+ it('does not navigate when there is no window (typeof window === undefined)', () => {
183
+ const h = harness({ getConfig: configWith('/settings/billing'), hasWindow: false });
184
+ h.handleAction();
185
+ expect(h.navigations).toEqual([]);
186
+ });
187
+ });
188
+ });
189
+ // ── Cross-component agreement ──────────────────────────────────────────────
190
+ //
191
+ // The whole point of S1 is that an app configures `billing.manageRoute` ONCE
192
+ // and both CTAs obey it. This block fails the moment the two handlers drift.
193
+ describe('BridgeBillingNotice and BridgeQuotaBanner resolve identically', () => {
194
+ const cases = [
195
+ {
196
+ label: 'no config, no props → /billing',
197
+ opts: { getConfig: uninitializedConfig },
198
+ expected: ['/billing'],
199
+ },
200
+ {
201
+ label: 'config manageRoute only',
202
+ opts: { getConfig: configWith('/settings/billing') },
203
+ expected: ['/settings/billing'],
204
+ },
205
+ {
206
+ label: 'actionHref beats config',
207
+ opts: { getConfig: configWith('/settings/billing'), actionHref: '/team/upgrade' },
208
+ expected: ['/team/upgrade'],
209
+ },
210
+ {
211
+ label: 'actionHref with no config',
212
+ opts: { getConfig: uninitializedConfig, actionHref: '/team/upgrade' },
213
+ expected: ['/team/upgrade'],
214
+ },
215
+ {
216
+ label: 'no window → no navigation',
217
+ opts: { getConfig: configWith('/settings/billing'), hasWindow: false },
218
+ expected: [],
219
+ },
220
+ ];
221
+ it.each(cases)('$label', ({ opts, expected }) => {
222
+ const notice = makeNoticeHarness(opts);
223
+ const quota = makeQuotaHarness(opts);
224
+ notice.handleAction();
225
+ quota.handleAction();
226
+ expect(notice.navigations).toEqual(expected);
227
+ expect(quota.navigations).toEqual(expected);
228
+ expect(quota.navigations).toEqual(notice.navigations);
229
+ });
230
+ it('onActionClick suppresses navigation in both components', () => {
231
+ const noticeCb = vi.fn();
232
+ const quotaCb = vi.fn();
233
+ const shared = { getConfig: configWith('/settings/billing'), actionHref: '/team/upgrade' };
234
+ const notice = makeNoticeHarness({ ...shared, onActionClick: noticeCb });
235
+ const quota = makeQuotaHarness({ ...shared, onActionClick: quotaCb });
236
+ notice.handleAction();
237
+ quota.handleAction();
238
+ expect(noticeCb).toHaveBeenCalledOnce();
239
+ expect(quotaCb).toHaveBeenCalledOnce();
240
+ expect(notice.navigations).toEqual([]);
241
+ expect(quota.navigations).toEqual([]);
242
+ });
243
+ });
244
+ // ── Quota-banner-only guard ────────────────────────────────────────────────
245
+ describe('BridgeQuotaBanner snapshot guard', () => {
246
+ it('does nothing at all — no callback, no navigation — without a snapshot', () => {
247
+ const onActionClick = vi.fn();
248
+ const h = makeQuotaHarness({
249
+ getConfig: configWith('/settings/billing'),
250
+ snapshot: null,
251
+ onActionClick,
252
+ });
253
+ h.handleAction();
254
+ expect(onActionClick).not.toHaveBeenCalled();
255
+ expect(h.navigations).toEqual([]);
256
+ });
257
+ });
258
+ });
@@ -0,0 +1,391 @@
1
+ // PlanSelector — annual-default interval (TBP-34) + plan-change confirmation (TBP-33).
2
+ //
3
+ // HARNESS NOTE: bridge-svelte's vitest config (vitest.config.ts) runs in a
4
+ // `node` environment with no Svelte compiler plugin and no DOM (jsdom /
5
+ // @testing-library/svelte are NOT installed anywhere in the workspace), so the
6
+ // PlanSelector.svelte component cannot be mounted here. Following the
7
+ // established pattern in billing-notice-gate.test.ts, this file exercises an
8
+ // EXACT replica of the component's script-block decisions, with the external
9
+ // singletons (getBridgeAuth / loadSubscription / getConfig / window.location)
10
+ // injected as mocks instead of module-mocking `bridge-instance.js`.
11
+ //
12
+ // The replicated logic mirrors PlanSelector.svelte:
13
+ // - availableIntervals ($derived.by, paid prices only, stable order)
14
+ // - selectedInterval (override → defaultInterval → first available)
15
+ // - showIntervalTabs (≥2 intervals)
16
+ // - pricesForInterval (matching interval + interval-agnostic free prices)
17
+ // - handlePick (free → selectFreePlan; paymentsEnabled+paid →
18
+ // confirm dialog, NO changePlan; else → checkout)
19
+ // - confirmPlanChange (changePlan behind explicit confirm; success notice;
20
+ // failure kept inside the dialog)
21
+ //
22
+ // If PlanSelector.svelte's script logic changes, update the replica here to
23
+ // match — a drift between the two is a test bug, not a component bug.
24
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
25
+ // ── Replicas of the interval derivations (TBP-34) ────────────────────────────
26
+ const INTERVAL_LABELS = {
27
+ day: 'Daily',
28
+ week: 'Weekly',
29
+ month: 'Monthly',
30
+ year: 'Yearly',
31
+ };
32
+ /** Mirror of the component's `availableIntervals` $derived.by. */
33
+ function availableIntervals(plans) {
34
+ const order = ['day', 'week', 'month', 'year'];
35
+ return order.filter((i) => (plans ?? []).some((plan) => plan.prices.some((p) => p.amount > 0 && p.recurrenceInterval === i)));
36
+ }
37
+ /** Mirror of the component's `selectedInterval` $derived.by. */
38
+ function selectedInterval(plans, defaultInterval, intervalOverride = null) {
39
+ const available = availableIntervals(plans);
40
+ if (intervalOverride && available.includes(intervalOverride)) {
41
+ return intervalOverride;
42
+ }
43
+ return available.includes(defaultInterval)
44
+ ? defaultInterval
45
+ : (available[0] ?? defaultInterval);
46
+ }
47
+ /** Mirror of the component's `showIntervalTabs` $derived. */
48
+ function showIntervalTabs(plans) {
49
+ return availableIntervals(plans).length >= 2;
50
+ }
51
+ /** Mirror of the component's `pricesForInterval(plan)`. */
52
+ function pricesForInterval(plan, selected) {
53
+ return plan.prices.filter((p) => p.amount === 0 || p.recurrenceInterval === selected);
54
+ }
55
+ function makeHarness(opts = {}) {
56
+ const { plans = null, status = null, callbackUrl, onSelect, successRedirect = '/subscription', cancelRedirect = '/subscription', } = opts;
57
+ // Injected singletons (component gets these from bridge-instance.js /
58
+ // config.store.js / window.location).
59
+ const auth = {
60
+ selectFreePlan: vi.fn(async (_planKey) => { }),
61
+ changePlan: vi.fn(async (_planKey, _price) => { }),
62
+ startCheckout: vi.fn(async (_planKey, _price, _urls) => ({ sessionId: null, checkoutUrl: null })),
63
+ };
64
+ const loadSubscription = vi.fn(async () => { });
65
+ const navigate = vi.fn((_url) => { });
66
+ const origin = 'http://localhost:5173';
67
+ // Mirrored component state.
68
+ const state = {
69
+ picking: false,
70
+ pickError: null,
71
+ confirmTarget: null,
72
+ confirmBusy: false,
73
+ confirmError: null,
74
+ successNotice: null,
75
+ };
76
+ let successTimer;
77
+ function formatPrice(price) {
78
+ return price.amount === 0
79
+ ? 'Free'
80
+ : `${price.amount} ${price.currency.toUpperCase()} / ${price.recurrenceInterval}`;
81
+ }
82
+ function showSuccess(message) {
83
+ state.successNotice = message;
84
+ clearTimeout(successTimer);
85
+ successTimer = setTimeout(() => (state.successNotice = null), 6000);
86
+ }
87
+ async function confirmPlanChange() {
88
+ if (!state.confirmTarget)
89
+ return;
90
+ const { plan, price } = state.confirmTarget;
91
+ state.confirmBusy = true;
92
+ state.confirmError = null;
93
+ try {
94
+ await auth.changePlan(plan.key, price);
95
+ await loadSubscription();
96
+ state.confirmTarget = null;
97
+ showSuccess(`You're now on ${plan.name} (${formatPrice(price)}).`);
98
+ onSelect?.({ plan, price });
99
+ }
100
+ catch (err) {
101
+ // AC: failure surfaces inside the dialog, not just a banner.
102
+ state.confirmError = err instanceof Error ? err.message : 'Plan change failed';
103
+ }
104
+ finally {
105
+ state.confirmBusy = false;
106
+ }
107
+ }
108
+ async function handlePick(plan, price) {
109
+ state.picking = true;
110
+ state.pickError = null;
111
+ try {
112
+ if (price.amount === 0 && !plan.hasCost) {
113
+ // Free plan — select directly (TBP-275 guards metered $0-base plans).
114
+ await auth.selectFreePlan(plan.key);
115
+ await loadSubscription();
116
+ onSelect?.({ plan, price });
117
+ }
118
+ else if (status?.paymentsEnabled) {
119
+ // Instant switch — requires explicit confirmation (TBP-33).
120
+ state.confirmTarget = { plan, price };
121
+ state.confirmError = null;
122
+ }
123
+ else {
124
+ const base = callbackUrl ?? `${origin}/auth/oauth-callback`;
125
+ const successUrl = `${base}?stripe_success=1&session_id={CHECKOUT_SESSION_ID}&redirect=${encodeURIComponent(successRedirect)}`;
126
+ const cancelUrl = `${base}?stripe_cancel=1&redirect=${encodeURIComponent(cancelRedirect)}`;
127
+ const session = await auth.startCheckout(plan.key, price, { successUrl, cancelUrl });
128
+ if (session.sessionId === null) {
129
+ await loadSubscription();
130
+ onSelect?.({ plan, price });
131
+ }
132
+ else {
133
+ if (!session.checkoutUrl)
134
+ throw new Error('Checkout session URL missing');
135
+ navigate(session.checkoutUrl);
136
+ }
137
+ }
138
+ }
139
+ catch (err) {
140
+ state.pickError = err instanceof Error ? err.message : 'Something went wrong';
141
+ }
142
+ finally {
143
+ state.picking = false;
144
+ }
145
+ }
146
+ function cancelConfirm() {
147
+ // Mirror of the dialog's Cancel button: onclick={() => (confirmTarget = null)}
148
+ state.confirmTarget = null;
149
+ }
150
+ return { state, auth, loadSubscription, navigate, handlePick, confirmPlanChange, cancelConfirm };
151
+ }
152
+ // ── Fixtures ──────────────────────────────────────────────────────────────────
153
+ const price = (amount, recurrenceInterval, currency = 'usd') => ({ id: `${recurrenceInterval}-${amount}`, amount, currency, recurrenceInterval });
154
+ const plan = (key, prices, extra = {}) => ({
155
+ key,
156
+ name: key.charAt(0).toUpperCase() + key.slice(1),
157
+ prices,
158
+ ...extra,
159
+ });
160
+ const FREE_PLAN = plan('free', [price(0, 'month')]);
161
+ const PRO_PLAN = plan('pro', [price(29, 'month'), price(290, 'year')]);
162
+ const TEAM_PLAN = plan('team', [price(99, 'month'), price(990, 'year')]);
163
+ const MONTHLY_ONLY_PLAN = plan('starter', [price(9, 'month')]);
164
+ const METERED_ZERO_BASE_PLAN = plan('usage', [price(0, 'month')], { hasCost: true });
165
+ // ── TBP-34: annual default interval + fallback ───────────────────────────────
166
+ describe('PlanSelector interval selection (TBP-34)', () => {
167
+ describe('availableIntervals', () => {
168
+ it('collects only intervals with paid prices, in stable day→week→month→year order', () => {
169
+ const plans = [
170
+ plan('a', [price(290, 'year'), price(29, 'month')]),
171
+ plan('b', [price(2, 'week')]),
172
+ ];
173
+ expect(availableIntervals(plans)).toEqual(['week', 'month', 'year']);
174
+ });
175
+ it('ignores free (amount-0) prices — they are interval-agnostic and never add a tab', () => {
176
+ expect(availableIntervals([FREE_PLAN])).toEqual([]);
177
+ expect(availableIntervals([FREE_PLAN, MONTHLY_ONLY_PLAN])).toEqual(['month']);
178
+ });
179
+ it('returns [] for null or empty plan lists', () => {
180
+ expect(availableIntervals(null)).toEqual([]);
181
+ expect(availableIntervals([])).toEqual([]);
182
+ });
183
+ });
184
+ describe('selectedInterval default', () => {
185
+ it("defaults to 'year' when yearly prices exist (annual is the default tab)", () => {
186
+ expect(selectedInterval([PRO_PLAN, TEAM_PLAN], 'year')).toBe('year');
187
+ });
188
+ it('falls back to the first available interval when no plan offers yearly', () => {
189
+ expect(selectedInterval([MONTHLY_ONLY_PLAN], 'year')).toBe('month');
190
+ });
191
+ it('falls back to the requested default itself when no paid intervals exist at all', () => {
192
+ // Free-only app: no tabs are rendered, the value is inert but must not crash.
193
+ expect(selectedInterval([FREE_PLAN], 'year')).toBe('year');
194
+ expect(selectedInterval(null, 'year')).toBe('year');
195
+ });
196
+ it('honors a non-year defaultInterval prop when available', () => {
197
+ expect(selectedInterval([PRO_PLAN], 'month')).toBe('month');
198
+ });
199
+ });
200
+ describe('selectedInterval user override', () => {
201
+ it('uses the user-picked tab when it is still available', () => {
202
+ expect(selectedInterval([PRO_PLAN], 'year', 'month')).toBe('month');
203
+ });
204
+ it('discards an override that no longer matches any paid price (reconciles to default)', () => {
205
+ // e.g. plans reloaded and weekly prices disappeared.
206
+ expect(selectedInterval([PRO_PLAN], 'year', 'week')).toBe('year');
207
+ });
208
+ });
209
+ describe('showIntervalTabs', () => {
210
+ it('hides the toggle when fewer than two intervals are offered', () => {
211
+ expect(showIntervalTabs([MONTHLY_ONLY_PLAN])).toBe(false);
212
+ expect(showIntervalTabs([FREE_PLAN])).toBe(false);
213
+ expect(showIntervalTabs(null)).toBe(false);
214
+ });
215
+ it('shows the toggle when two or more intervals are offered', () => {
216
+ expect(showIntervalTabs([PRO_PLAN])).toBe(true);
217
+ });
218
+ });
219
+ describe('pricesForInterval', () => {
220
+ it('shows only the active-interval price plus interval-agnostic free prices', () => {
221
+ const mixed = plan('mixed', [price(0, 'month'), price(29, 'month'), price(290, 'year')]);
222
+ expect(pricesForInterval(mixed, 'year').map((p) => p.id)).toEqual([
223
+ 'month-0',
224
+ 'year-290',
225
+ ]);
226
+ expect(pricesForInterval(mixed, 'month').map((p) => p.id)).toEqual([
227
+ 'month-0',
228
+ 'month-29',
229
+ ]);
230
+ });
231
+ it('returns [] for a paid-only plan not offered under the active interval', () => {
232
+ expect(pricesForInterval(MONTHLY_ONLY_PLAN, 'year')).toEqual([]);
233
+ });
234
+ });
235
+ });
236
+ // ── TBP-33: plan-change confirmation flow ────────────────────────────────────
237
+ describe('PlanSelector pick flow + confirmation dialog (TBP-33)', () => {
238
+ beforeEach(() => {
239
+ vi.useFakeTimers();
240
+ });
241
+ afterEach(() => {
242
+ vi.useRealTimers();
243
+ });
244
+ describe('free plan (single-click, no confirmation)', () => {
245
+ it('calls selectFreePlan immediately and never opens the dialog', async () => {
246
+ const onSelect = vi.fn();
247
+ const h = makeHarness({ status: { paymentsEnabled: true }, onSelect });
248
+ await h.handlePick(FREE_PLAN, FREE_PLAN.prices[0]);
249
+ expect(h.auth.selectFreePlan).toHaveBeenCalledExactlyOnceWith('free');
250
+ expect(h.auth.changePlan).not.toHaveBeenCalled();
251
+ expect(h.auth.startCheckout).not.toHaveBeenCalled();
252
+ expect(h.loadSubscription).toHaveBeenCalledOnce();
253
+ expect(h.state.confirmTarget).toBeNull();
254
+ expect(onSelect).toHaveBeenCalledExactlyOnceWith({
255
+ plan: FREE_PLAN,
256
+ price: FREE_PLAN.prices[0],
257
+ });
258
+ });
259
+ it('TBP-275: a $0-base plan with hasCost is NOT treated as free — it needs confirm/checkout', async () => {
260
+ const h = makeHarness({ status: { paymentsEnabled: true } });
261
+ await h.handlePick(METERED_ZERO_BASE_PLAN, METERED_ZERO_BASE_PLAN.prices[0]);
262
+ expect(h.auth.selectFreePlan).not.toHaveBeenCalled();
263
+ expect(h.state.confirmTarget).toEqual({
264
+ plan: METERED_ZERO_BASE_PLAN,
265
+ price: METERED_ZERO_BASE_PLAN.prices[0],
266
+ });
267
+ });
268
+ });
269
+ describe('paid pick with paymentsEnabled (confirmation gate)', () => {
270
+ it('opens the dialog and does NOT call changePlan until confirmed', async () => {
271
+ const onSelect = vi.fn();
272
+ const h = makeHarness({ status: { paymentsEnabled: true }, onSelect });
273
+ await h.handlePick(PRO_PLAN, PRO_PLAN.prices[1]);
274
+ expect(h.state.confirmTarget).toEqual({ plan: PRO_PLAN, price: PRO_PLAN.prices[1] });
275
+ expect(h.state.confirmError).toBeNull();
276
+ expect(h.auth.changePlan).not.toHaveBeenCalled();
277
+ expect(h.auth.startCheckout).not.toHaveBeenCalled();
278
+ expect(h.loadSubscription).not.toHaveBeenCalled();
279
+ expect(onSelect).not.toHaveBeenCalled();
280
+ });
281
+ it('re-opening the dialog clears a stale confirmError from a previous attempt', async () => {
282
+ const h = makeHarness({ status: { paymentsEnabled: true } });
283
+ h.state.confirmError = 'old failure';
284
+ await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
285
+ expect(h.state.confirmError).toBeNull();
286
+ });
287
+ it('cancel closes the dialog without ever calling changePlan', async () => {
288
+ const h = makeHarness({ status: { paymentsEnabled: true } });
289
+ await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
290
+ h.cancelConfirm();
291
+ expect(h.state.confirmTarget).toBeNull();
292
+ expect(h.auth.changePlan).not.toHaveBeenCalled();
293
+ // A later confirm click on the closed dialog must be a no-op.
294
+ await h.confirmPlanChange();
295
+ expect(h.auth.changePlan).not.toHaveBeenCalled();
296
+ });
297
+ });
298
+ describe('confirmPlanChange success', () => {
299
+ it('calls changePlan with the picked plan/price, refreshes, closes, notifies', async () => {
300
+ const onSelect = vi.fn();
301
+ const h = makeHarness({ status: { paymentsEnabled: true }, onSelect });
302
+ await h.handlePick(PRO_PLAN, PRO_PLAN.prices[1]); // 290 usd / year
303
+ await h.confirmPlanChange();
304
+ expect(h.auth.changePlan).toHaveBeenCalledExactlyOnceWith('pro', PRO_PLAN.prices[1]);
305
+ expect(h.loadSubscription).toHaveBeenCalledOnce();
306
+ expect(h.state.confirmTarget).toBeNull();
307
+ expect(h.state.confirmBusy).toBe(false);
308
+ expect(h.state.confirmError).toBeNull();
309
+ expect(h.state.successNotice).toBe("You're now on Pro (290 USD / year).");
310
+ expect(onSelect).toHaveBeenCalledExactlyOnceWith({
311
+ plan: PRO_PLAN,
312
+ price: PRO_PLAN.prices[1],
313
+ });
314
+ });
315
+ it('auto-dismisses the success notice after 6 seconds', async () => {
316
+ const h = makeHarness({ status: { paymentsEnabled: true } });
317
+ await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
318
+ await h.confirmPlanChange();
319
+ expect(h.state.successNotice).not.toBeNull();
320
+ vi.advanceTimersByTime(5999);
321
+ expect(h.state.successNotice).not.toBeNull();
322
+ vi.advanceTimersByTime(1);
323
+ expect(h.state.successNotice).toBeNull();
324
+ });
325
+ });
326
+ describe('confirmPlanChange failure', () => {
327
+ it('keeps the dialog open with the error inside it — no crash, no success path', async () => {
328
+ const onSelect = vi.fn();
329
+ const h = makeHarness({ status: { paymentsEnabled: true }, onSelect });
330
+ h.auth.changePlan.mockRejectedValueOnce(new Error('card declined'));
331
+ await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
332
+ await expect(h.confirmPlanChange()).resolves.toBeUndefined();
333
+ expect(h.state.confirmError).toBe('card declined');
334
+ expect(h.state.confirmTarget).toEqual({ plan: PRO_PLAN, price: PRO_PLAN.prices[0] });
335
+ expect(h.state.confirmBusy).toBe(false);
336
+ expect(h.state.successNotice).toBeNull();
337
+ expect(h.loadSubscription).not.toHaveBeenCalled();
338
+ expect(onSelect).not.toHaveBeenCalled();
339
+ });
340
+ it('falls back to a generic message for non-Error rejections', async () => {
341
+ const h = makeHarness({ status: { paymentsEnabled: true } });
342
+ h.auth.changePlan.mockRejectedValueOnce('boom');
343
+ await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
344
+ await h.confirmPlanChange();
345
+ expect(h.state.confirmError).toBe('Plan change failed');
346
+ });
347
+ it('retry after failure succeeds and closes the dialog', async () => {
348
+ const h = makeHarness({ status: { paymentsEnabled: true } });
349
+ h.auth.changePlan.mockRejectedValueOnce(new Error('transient'));
350
+ await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
351
+ await h.confirmPlanChange();
352
+ expect(h.state.confirmError).toBe('transient');
353
+ await h.confirmPlanChange();
354
+ expect(h.auth.changePlan).toHaveBeenCalledTimes(2);
355
+ expect(h.state.confirmTarget).toBeNull();
356
+ expect(h.state.confirmError).toBeNull();
357
+ expect(h.state.successNotice).toContain("You're now on Pro");
358
+ });
359
+ });
360
+ describe('paid pick without paymentsEnabled (checkout keeps single-click behavior)', () => {
361
+ it('goes straight to startCheckout — no confirmation dialog', async () => {
362
+ const h = makeHarness({ status: { paymentsEnabled: false } });
363
+ h.auth.startCheckout.mockResolvedValueOnce({
364
+ sessionId: 'cs_123',
365
+ checkoutUrl: 'https://checkout.stripe.test/cs_123',
366
+ });
367
+ await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
368
+ expect(h.state.confirmTarget).toBeNull();
369
+ expect(h.auth.changePlan).not.toHaveBeenCalled();
370
+ expect(h.auth.startCheckout).toHaveBeenCalledOnce();
371
+ expect(h.navigate).toHaveBeenCalledExactlyOnceWith('https://checkout.stripe.test/cs_123');
372
+ });
373
+ it('sessionId === null (Stripe not configured) refreshes and fires onSelect directly', async () => {
374
+ const onSelect = vi.fn();
375
+ const h = makeHarness({ status: null, onSelect });
376
+ h.auth.startCheckout.mockResolvedValueOnce({ sessionId: null, checkoutUrl: null });
377
+ await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
378
+ expect(h.loadSubscription).toHaveBeenCalledOnce();
379
+ expect(h.navigate).not.toHaveBeenCalled();
380
+ expect(onSelect).toHaveBeenCalledExactlyOnceWith({ plan: PRO_PLAN, price: PRO_PLAN.prices[0] });
381
+ });
382
+ it('a session without a checkoutUrl surfaces a pickError banner', async () => {
383
+ const h = makeHarness({ status: null });
384
+ h.auth.startCheckout.mockResolvedValueOnce({ sessionId: 'cs_456', checkoutUrl: null });
385
+ await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
386
+ expect(h.state.pickError).toBe('Checkout session URL missing');
387
+ expect(h.navigate).not.toHaveBeenCalled();
388
+ expect(h.state.picking).toBe(false);
389
+ });
390
+ });
391
+ });
@@ -33,15 +33,23 @@ afterEach(() => {
33
33
  });
34
34
  // Stub the bridge-instance singleton so bridge.app.plans.load() doesn't
35
35
  // require a real BridgeAuth in vitest. The actual load impl reads from
36
- // getBridgeAuth() which throws when uninitialized.
37
- vi.mock('./bridge-instance.js', () => ({
38
- getBridgeAuth: () => ({
39
- getPlans: async () => [
40
- { key: 'free', name: 'Free' },
41
- { key: 'pro', name: 'Pro' },
42
- ],
43
- }),
44
- }));
36
+ // getBridgeAuth() which throws when uninitialized. `tokenStore` must be
37
+ // exported too — bridge.ts derives the JWT user fallback from it at
38
+ // module-eval time.
39
+ vi.mock('./bridge-instance.js', async () => {
40
+ const { writable } = await import('svelte/store');
41
+ return {
42
+ tokenStore: writable(null),
43
+ subscriptionStore: writable({ status: null, plans: null, loading: false, error: null }),
44
+ loadSubscription: async () => { },
45
+ getBridgeAuth: () => ({
46
+ getPlans: async () => [
47
+ { key: 'free', name: 'Free' },
48
+ { key: 'pro', name: 'Pro' },
49
+ ],
50
+ }),
51
+ };
52
+ });
45
53
  describe('bridge surface (Phase 4, TBP-319)', () => {
46
54
  it('initial slice stores are null until a snapshot lands', () => {
47
55
  expect(get(bridge.app.branding)).toBeNull();
@@ -19,7 +19,6 @@
19
19
  // the BillingAttributeProvider without spinning up real billing stores.
20
20
  import { describe, it, expect, beforeEach, vi } from 'vitest';
21
21
  import { writable } from 'svelte/store';
22
- let _tokenStore;
23
22
  function makeJwt(claims) {
24
23
  // Browser-compatible base64url (no padding). atob lives in Node 18+ and in the
25
24
  // bootstrap module's decodeJwtPayload path.
@@ -36,12 +35,15 @@ function makeJwt(claims) {
36
35
  // ── Mocks ───────────────────────────────────────────────────────────────────
37
36
  // Mock the bridge-instance module so we don't bring up real BridgeAuth.
38
37
  // `tokenStore` is a real svelte writable so we can push JWT-shaped values
39
- // during the test; the rest is a safe stub.
40
- vi.mock('../core/bridge-instance.js', () => {
38
+ // during the test; the rest is a safe stub. The store must be created INSIDE
39
+ // the hoisted factory: bridge.ts derives from `tokenStore` at module-eval
40
+ // time (during import), before any test-body binding would initialize.
41
+ vi.mock('../core/bridge-instance.js', async () => {
42
+ const { writable } = await import('svelte/store');
41
43
  return {
42
- get tokenStore() {
43
- return _tokenStore;
44
- },
44
+ tokenStore: writable(null),
45
+ subscriptionStore: writable({ status: null, plans: null, loading: false, error: null }),
46
+ loadSubscription: async () => { },
45
47
  getBridgeAuth: () => ({
46
48
  getApiContext: () => ({ appId: 'app-1', accessToken: null }),
47
49
  refreshTokens: async () => { },
@@ -82,7 +84,7 @@ vi.mock('@nebulr-group/bridge-auth-core', async (importOriginal) => {
82
84
  // bootstrap (which fires `void fetch(...)`) doesn't write to a real network.
83
85
  // Bootstrap swallows the resulting non-ok response, so a 500 is fine.
84
86
  beforeEach(() => {
85
- _tokenStore = writable(null);
87
+ _tokenStore.set(null);
86
88
  vi.stubGlobal('fetch', vi.fn(async () =>
87
89
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
90
  ({ ok: false, status: 500, json: async () => [] })));
@@ -91,6 +93,9 @@ beforeEach(() => {
91
93
  // Imported AFTER the mocks above so the bootstrap module picks up the stubs.
92
94
  import { createBridgeFlags } from './bootstrap.js';
93
95
  import { AuthAttributeProvider, BillingAttributeProvider } from '@nebulr-group/bridge-auth-core';
96
+ import { tokenStore as _mockedTokenStore } from '../core/bridge-instance.js';
97
+ // The mocked writable, typed for test-side .set() pushes.
98
+ const _tokenStore = _mockedTokenStore;
94
99
  // After the TBP-Live-Channel-Unification hoist, the realtime client lives
95
100
  // in `core/bridge-runtime.ts` and is owned by `<BridgeBootstrap />`. Tests
96
101
  // don't start the core runtime so `getBridgeRealtime()` returns undefined
package/dist/styles.css CHANGED
@@ -936,7 +936,11 @@
936
936
  }
937
937
 
938
938
  .bridge-plan-interval-tabs {
939
- display: inline-flex;
939
+ /* Centered above the plan cards; flex + fit-content (not inline-flex) so
940
+ auto margins can center it regardless of the host page's text alignment. */
941
+ display: flex;
942
+ width: fit-content;
943
+ margin-inline: auto;
940
944
  gap: 0.25rem;
941
945
  margin-bottom: 1.25rem;
942
946
  padding: 0.25rem;
@@ -968,6 +972,60 @@
968
972
  color: var(--bridge-muted, #64748b);
969
973
  }
970
974
 
975
+ /* Plan-change confirmation dialog + success notice (TBP-33) */
976
+ .bridge-plan-success {
977
+ margin-bottom: 1rem;
978
+ }
979
+
980
+ .bridge-plan-confirm-backdrop {
981
+ position: fixed;
982
+ inset: 0;
983
+ background: rgba(15, 23, 42, 0.45);
984
+ display: grid;
985
+ place-items: center;
986
+ z-index: 1000;
987
+ padding: 1.25rem;
988
+ }
989
+
990
+ .bridge-plan-confirm {
991
+ background: var(--bridge-bg, #fff);
992
+ border: 1px solid var(--bridge-border, #e2e8f0);
993
+ border-radius: var(--bridge-border-radius, 0.5rem);
994
+ box-shadow: 0 24px 60px -12px rgba(0, 0, 0, 0.35);
995
+ max-width: 26rem;
996
+ width: 100%;
997
+ padding: 1.25rem 1.25rem 1rem;
998
+ display: flex;
999
+ flex-direction: column;
1000
+ gap: 0.75rem;
1001
+ }
1002
+
1003
+ .bridge-plan-confirm-title {
1004
+ margin: 0;
1005
+ font-size: 1rem;
1006
+ font-weight: 600;
1007
+ }
1008
+
1009
+ .bridge-plan-confirm-body {
1010
+ margin: 0;
1011
+ font-size: 0.875rem;
1012
+ line-height: 1.5;
1013
+ }
1014
+
1015
+ .bridge-plan-confirm-note {
1016
+ margin: 0;
1017
+ font-size: 0.8125rem;
1018
+ color: var(--bridge-muted, #64748b);
1019
+ line-height: 1.5;
1020
+ }
1021
+
1022
+ .bridge-plan-confirm-actions {
1023
+ display: flex;
1024
+ justify-content: flex-end;
1025
+ gap: 0.5rem;
1026
+ margin-top: 0.25rem;
1027
+ }
1028
+
971
1029
  .bridge-plan-cards {
972
1030
  display: grid;
973
1031
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nebulr-group/bridge-svelte",
3
- "version": "0.4.0-beta.8",
3
+ "version": "0.4.0",
4
4
  "description": "Bridge Svelte library, This library helps you to add bridge authentication and feature flags, and payments to your svelte application.",
5
5
  "author": "Iman Pouya",
6
6
  "license": "MIT",
@@ -55,19 +55,16 @@
55
55
  "peerDependencies": {
56
56
  "svelte": "^5.0.0",
57
57
  "@sveltejs/kit": "^2.0.0",
58
- "@simplewebauthn/browser": "^13",
59
58
  "@stripe/stripe-js": ">=4.0.0"
60
59
  },
61
60
  "peerDependenciesMeta": {
62
- "@simplewebauthn/browser": {
63
- "optional": true
64
- },
65
61
  "@stripe/stripe-js": {
66
62
  "optional": true
67
63
  }
68
64
  },
69
65
  "dependencies": {
70
- "@nebulr-group/bridge-auth-core": "0.4.0-beta.9"
66
+ "@simplewebauthn/browser": "^13.0.0",
67
+ "@nebulr-group/bridge-auth-core": "0.4.0"
71
68
  },
72
69
  "devDependencies": {
73
70
  "@sveltejs/kit": "^2.16.0",