@startsimpli/billing 0.4.24 → 0.4.26

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.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * usePlanChange — choose a plan, confirming first when the click changes or
3
+ * ends a plan the viewer already has (crochet_patterns-anw61, -kigcj, -lr7mj).
4
+ *
5
+ * A new buyer choosing a paid plan goes straight on: the provider's checkout
6
+ * page is their confirmation. Anyone already paying, or choosing a free plan,
7
+ * is asked first, with what the backend says will happen (charged now and how
8
+ * much, or the date it changes, or that an unlisted plan ends for good).
9
+ *
10
+ * While a preview or the change itself is in flight `busy` is true, so
11
+ * callers disable their plan buttons — a second click used to start a second
12
+ * checkout (crochet_patterns-9e9o).
13
+ *
14
+ * `proceed(offer, preview)` does the change (checkout, subscribe-free, …).
15
+ * Return "navigating" when it has sent the browser away, so the buttons stay
16
+ * disabled until the page unloads. Throw to show the message.
17
+ */
18
+
19
+ import { useCallback, useRef, useState } from "react";
20
+ import type { PlanChangePreview, ProductOffer } from "../types";
21
+ import { useBillingContext } from "./BillingProvider";
22
+
23
+ export type PlanChangeProceed = (
24
+ offer: ProductOffer,
25
+ preview: PlanChangePreview | null,
26
+ ) => Promise<unknown> | unknown;
27
+
28
+ export interface PlanChangeContext {
29
+ /** The viewer pays for a running plan (isPayingSubscription). */
30
+ paying: boolean;
31
+ /** The viewer's plan is not one of the offers for sale (a complimentary plan). */
32
+ leavingUnlisted?: boolean;
33
+ /** The viewer's plan, for the dialog copy when the preview can't be had. */
34
+ currentOffer?: ProductOffer | null;
35
+ }
36
+
37
+ export interface PlanChangeConfirmState {
38
+ offer: ProductOffer;
39
+ preview: PlanChangePreview | null;
40
+ currentName: string | null;
41
+ leavingUnlisted: boolean;
42
+ }
43
+
44
+ export interface UsePlanChangeResult {
45
+ choose: (offer: ProductOffer, context: PlanChangeContext) => Promise<void>;
46
+ confirm: () => Promise<void>;
47
+ cancel: () => void;
48
+ /** Open when the viewer must confirm. */
49
+ pending: PlanChangeConfirmState | null;
50
+ busy: boolean;
51
+ /** The confirm step itself is running. */
52
+ confirming: boolean;
53
+ error: string | null;
54
+ clearError: () => void;
55
+ }
56
+
57
+ export function usePlanChange(proceed: PlanChangeProceed): UsePlanChangeResult {
58
+ const { client } = useBillingContext();
59
+ const [pending, setPending] = useState<PlanChangeConfirmState | null>(null);
60
+ const [busy, setBusy] = useState(false);
61
+ const [confirming, setConfirming] = useState(false);
62
+ const [error, setError] = useState<string | null>(null);
63
+ const inFlight = useRef(false);
64
+
65
+ const run = useCallback(
66
+ async (offer: ProductOffer, preview: PlanChangePreview | null) => {
67
+ try {
68
+ const outcome = await proceed(offer, preview);
69
+ if (outcome !== "navigating") {
70
+ inFlight.current = false;
71
+ setBusy(false);
72
+ }
73
+ return true;
74
+ } catch (e) {
75
+ inFlight.current = false;
76
+ setBusy(false);
77
+ setError(e instanceof Error ? e.message : "Something went wrong. Please try again.");
78
+ return false;
79
+ }
80
+ },
81
+ [proceed],
82
+ );
83
+
84
+ const choose = useCallback(
85
+ async (offer: ProductOffer, context: PlanChangeContext) => {
86
+ if (inFlight.current) return;
87
+ inFlight.current = true;
88
+ setBusy(true);
89
+ setError(null);
90
+
91
+ const leavingUnlisted = !!context.leavingUnlisted;
92
+ // Not paying yet (free, or an unbilled complimentary plan) and choosing a
93
+ // paid plan: that is a checkout, and its page is the confirmation.
94
+ if (!context.paying && !offer.isFree) {
95
+ await run(offer, null);
96
+ return;
97
+ }
98
+
99
+ let preview: PlanChangePreview | null = null;
100
+ try {
101
+ preview = await client.previewPlanChange(offer.id);
102
+ } catch {
103
+ preview = null; // still confirm, with the copy we can stand behind
104
+ }
105
+ if (preview?.mode === "current") {
106
+ inFlight.current = false;
107
+ setBusy(false);
108
+ return;
109
+ }
110
+ if (preview?.mode === "checkout") {
111
+ await run(offer, preview);
112
+ return;
113
+ }
114
+ setPending({
115
+ offer,
116
+ preview,
117
+ currentName: preview?.currentOffer?.name ?? context.currentOffer?.name ?? null,
118
+ leavingUnlisted: preview?.currentOffer ? !preview.currentOffer.isListed : leavingUnlisted,
119
+ });
120
+ },
121
+ [client, run],
122
+ );
123
+
124
+ const confirm = useCallback(async () => {
125
+ if (!pending || confirming) return;
126
+ setConfirming(true);
127
+ setError(null);
128
+ const ok = await run(pending.offer, pending.preview);
129
+ setConfirming(false);
130
+ if (ok) setPending(null);
131
+ }, [pending, confirming, run]);
132
+
133
+ const cancel = useCallback(() => {
134
+ if (confirming) return;
135
+ setPending(null);
136
+ setError(null);
137
+ inFlight.current = false;
138
+ setBusy(false);
139
+ }, [confirming]);
140
+
141
+ const clearError = useCallback(() => setError(null), []);
142
+
143
+ return { choose, confirm, cancel, pending, busy, confirming, error, clearError };
144
+ }
@@ -16,7 +16,7 @@ import type { CachedSubscriptionState } from "../types";
16
16
  import { useBillingContext } from "./BillingProvider";
17
17
 
18
18
  interface UseSuccessSyncResult {
19
- sync: () => Promise<CachedSubscriptionState | null>;
19
+ sync: (params?: { sessionId?: string | null; productSlug?: string | null }) => Promise<CachedSubscriptionState | null>;
20
20
  data: CachedSubscriptionState | null;
21
21
  loading: boolean;
22
22
  error: Error | null;
@@ -28,11 +28,11 @@ export function useSuccessSync(): UseSuccessSyncResult {
28
28
  const [loading, setLoading] = useState(false);
29
29
  const [error, setError] = useState<Error | null>(null);
30
30
 
31
- const sync = useCallback(async () => {
31
+ const sync = useCallback(async (params?: { sessionId?: string | null; productSlug?: string | null }) => {
32
32
  setLoading(true);
33
33
  setError(null);
34
34
  try {
35
- const result = await client.successSync();
35
+ const result = await client.successSync(params);
36
36
  setData(result);
37
37
  return result;
38
38
  } catch (e) {
package/src/index.ts CHANGED
@@ -19,10 +19,11 @@ export {
19
19
  useSubscription,
20
20
  useSuccessSync,
21
21
  useUsage,
22
+ usePlanChange,
22
23
  notifyUsageChanged,
23
24
  USAGE_CHANGED_EVENT,
24
25
  } from "./hooks";
25
- export type { BillingProviderProps } from "./hooks";
26
+ export type { BillingProviderProps, PlanChangeContext, PlanChangeConfirmState, PlanChangeProceed, UsePlanChangeResult } from "./hooks";
26
27
 
27
28
  // Types
28
29
  export type {
@@ -34,13 +35,14 @@ export type {
34
35
  CachedSubscriptionState,
35
36
  FreeSubscriptionResult,
36
37
  PaymentMethod,
38
+ PlanChangePreview,
37
39
  PortalResult,
38
40
  SubscriptionInfo,
39
41
  UsageMap,
40
42
  } from "./types";
41
43
 
42
44
  // Components
43
- export { PricingPage, PricingSection, PricingDetailPage, UpgradeModal, ManageSubscription, SubscriptionManager, PlanGate, CurrentPlanCard, LimitNudge } from "./components";
45
+ export { PricingPage, PricingSection, PricingDetailPage, UpgradeModal, ManageSubscription, SubscriptionManager, PlanGate, CurrentPlanCard, LimitNudge, PlanChangeConfirm, planChangeCopy } from "./components";
44
46
  export type {
45
47
  PricingPageProps,
46
48
  PricingSectionProps,
@@ -51,14 +53,28 @@ export type {
51
53
  PlanGateProps,
52
54
  CurrentPlanCardProps,
53
55
  LimitNudgeProps,
56
+ PlanChangeConfirmProps,
54
57
  } from "./components";
55
58
 
56
59
  // API client (for advanced usage)
57
60
  export { BillingApiClient } from "./utils/api";
58
61
 
59
62
  // Pure helpers
60
- export { isPaidSubscription, getFeatureLimit, resolvePlanEntitlement, resolveUsageWindow, resolveCurrentOfferId } from "./utils/plan";
61
- export type { PlanEntitlement, ResolvePlanEntitlementOptions, UsageWindow, CurrentPlanState } from "./utils/plan";
63
+ export {
64
+ isPaidSubscription,
65
+ isPayingSubscription,
66
+ isUnlistedOffer,
67
+ getFeatureLimit,
68
+ resolvePlanEntitlement,
69
+ resolveUsageWindow,
70
+ resolveCurrentOfferId,
71
+ resolveCurrentOffer,
72
+ planDirection,
73
+ planCtaLabel,
74
+ trialApplies,
75
+ TRIAL_NOTE_FEATURE_KEY,
76
+ } from "./utils/plan";
77
+ export type { PlanEntitlement, ResolvePlanEntitlementOptions, UsageWindow, CurrentPlanState, PlanDirection } from "./utils/plan";
62
78
  export { formatPrice, formatFeatureLabel } from "./utils/format";
63
79
  export { groupOffersByTier, offersForInterval, hasIntervalChoice, annualSavings, tierOffer } from "./utils/tiers";
64
80
  export type { BillingInterval, OfferTier } from "./utils/tiers";
@@ -52,14 +52,43 @@ export interface BillingProduct {
52
52
  offers: ProductOffer[];
53
53
  }
54
54
 
55
+ /**
56
+ * What choosing an offer will do, from POST /billing/plan-change-preview/
57
+ * (crochet_patterns-anw61):
58
+ * - "current" already on it
59
+ * - "immediate" changes now; a paid offer is charged `amountDue` now
60
+ * (null when it could not be priced) and never trials;
61
+ * a free offer ends an unbilled plan now
62
+ * - "at_period_end" changes on `effectiveAt`; nothing is charged now
63
+ * - "checkout" a new checkout, with `trialDays`
64
+ */
65
+ export interface PlanChangePreview {
66
+ mode: "current" | "immediate" | "at_period_end" | "checkout";
67
+ offerId: string;
68
+ amountDue: string | null;
69
+ currency: string;
70
+ effectiveAt: string | null;
71
+ trialDays: number;
72
+ /** The plan being left; `isListed` false = not for sale, cannot be chosen again. */
73
+ currentOffer: { slug: string; name: string; isListed: boolean } | null;
74
+ }
75
+
55
76
  export interface CheckoutResult {
56
77
  sessionId: string;
57
78
  url: string;
79
+ /** Set when the offer was a change to a plan already paid for (no checkout):
80
+ * "immediate", "at_period_end", "payment_required", "pending_change_cancelled".
81
+ * The backend sends it as `plan_change`. */
82
+ plan_change?: string;
83
+ planChange?: string;
58
84
  }
59
85
 
60
86
  export interface FreeSubscriptionResult {
61
87
  subscriptionId: string;
62
88
  status: string;
89
+ /** Leaving a paid plan: it ends at `effective_at` instead of now. */
90
+ scheduled_downgrade?: boolean;
91
+ effective_at?: string | null;
63
92
  }
64
93
 
65
94
  export interface PortalResult {
package/src/utils/api.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  CachedSubscriptionState,
9
9
  CheckoutResult,
10
10
  FreeSubscriptionResult,
11
+ PlanChangePreview,
11
12
  PortalResult,
12
13
  SubscriptionInfo,
13
14
  UsageMap,
@@ -104,11 +105,47 @@ export class BillingApiClient {
104
105
  * Eagerly sync subscription state after Stripe checkout redirect.
105
106
  * Call this from your success_url page to avoid waiting for the webhook.
106
107
  */
107
- async successSync(): Promise<CachedSubscriptionState | null> {
108
+ /** What choosing an offer will do, without doing it (crochet_patterns-anw61). */
109
+ async previewPlanChange(offerId: string): Promise<PlanChangePreview> {
110
+ const url = `${this.config.apiBaseUrl}/billing/plan-change-preview/`;
111
+ const resp = await this.fetcher(url, {
112
+ method: "POST",
113
+ headers: this.headers,
114
+ body: JSON.stringify({ offer_id: offerId }),
115
+ });
116
+ if (!resp.ok) {
117
+ const data = await resp.json().catch(() => ({}));
118
+ throw new Error(billingErrorMessage(data, `Plan preview failed: ${resp.status}`));
119
+ }
120
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
121
+ const raw: any = await resp.json();
122
+ const cur = raw.current_offer ?? raw.currentOffer ?? null;
123
+ return {
124
+ mode: raw.mode,
125
+ offerId: raw.offer_id ?? raw.offerId ?? offerId,
126
+ amountDue: raw.amount_due ?? raw.amountDue ?? null,
127
+ currency: raw.currency ?? "usd",
128
+ effectiveAt: raw.effective_at ?? raw.effectiveAt ?? null,
129
+ trialDays: raw.trial_days ?? raw.trialDays ?? 0,
130
+ currentOffer: cur
131
+ ? { slug: cur.slug, name: cur.name, isListed: (cur.is_listed ?? cur.isListed) !== false }
132
+ : null,
133
+ };
134
+ }
135
+
136
+ /** `sessionId` is the checkout session from the success URL
137
+ * ({CHECKOUT_SESSION_ID}): it sends the sync straight to the checkout that
138
+ * was paid, not to whichever customer the team resolves to first
139
+ * (crochet_patterns-eq11). */
140
+ async successSync(params: { sessionId?: string | null; productSlug?: string | null } = {}): Promise<CachedSubscriptionState | null> {
108
141
  const url = `${this.config.apiBaseUrl}/billing/success-sync/`;
142
+ const body: Record<string, string> = {};
143
+ if (params.sessionId) body.session_id = params.sessionId;
144
+ if (params.productSlug) body.product_slug = params.productSlug;
109
145
  const resp = await this.fetcher(url, {
110
146
  method: "POST",
111
147
  headers: this.headers,
148
+ ...(Object.keys(body).length > 0 && { body: JSON.stringify(body) }),
112
149
  });
113
150
  if (resp.status === 404) {
114
151
  return null;
package/src/utils/plan.ts CHANGED
@@ -213,6 +213,8 @@ export interface CurrentPlanState {
213
213
  loading?: boolean;
214
214
  /** The subscription fetch failed. */
215
215
  error?: unknown;
216
+ /** Fetch the subscription again (offered when it failed). */
217
+ retry?: () => void;
216
218
  }
217
219
 
218
220
  /**
@@ -239,3 +241,87 @@ export function resolveCurrentOfferId(
239
241
  }
240
242
  return offers.find((o) => o.isFree)?.id ?? null;
241
243
  }
244
+
245
+ /**
246
+ * The plan to treat as the viewer's own on a picker: their subscription's
247
+ * offer while it counts (see resolveCurrentOfferId), else the free offer for a
248
+ * signed-in viewer. The offer may be one the picker does not list (a hidden
249
+ * complimentary plan); `isUnlistedOffer` says so. Null while unknown.
250
+ */
251
+ export function resolveCurrentOffer(
252
+ offers: ProductOffer[],
253
+ state: CurrentPlanState | null | undefined,
254
+ ): ProductOffer | null {
255
+ const id = resolveCurrentOfferId(offers, state);
256
+ if (!id) return null;
257
+ const own = state?.subscription?.offer ?? null;
258
+ if (own && own.id === id) return own;
259
+ return offers.find((o) => o.id === id) ?? null;
260
+ }
261
+
262
+ /** True when `offer` is not one of the offers for sale (e.g. a complimentary plan). */
263
+ export function isUnlistedOffer(offers: ProductOffer[], offer: ProductOffer | null | undefined): boolean {
264
+ return !!offer && !offers.some((o) => o.id === offer.id);
265
+ }
266
+
267
+ /**
268
+ * True while the viewer pays for a plan that is still running (active,
269
+ * trialing or past due). Choosing another paid plan then changes that
270
+ * subscription in place — an upgrade is charged now, prorated, and never
271
+ * starts a trial — so pickers must not sell it as one (crochet_patterns-anw61).
272
+ */
273
+ export function isPayingSubscription(sub: SubscriptionInfo | null | undefined): boolean {
274
+ if (!sub?.offer || sub.offer.isFree) return false;
275
+ return PAID_STATUSES.includes(sub.status) || sub.status === "past_due";
276
+ }
277
+
278
+ /**
279
+ * A card's direction relative to the viewer's plan:
280
+ * - "current" their exact offer
281
+ * - "switch" the same plan on the other billing interval (Lite ↔ Lite Annual)
282
+ * - "upgrade" paid over free, or a higher sortOrder
283
+ * - "downgrade" free under paid, or a lower sortOrder
284
+ * Unknown plan (null) → every card is an upgrade.
285
+ */
286
+ export type PlanDirection = "current" | "upgrade" | "downgrade" | "switch";
287
+
288
+ export function planDirection(
289
+ card: ProductOffer,
290
+ current: ProductOffer | null | undefined,
291
+ tierKeyOf: (offerId: string) => string | null = () => null,
292
+ ): PlanDirection {
293
+ if (!current) return "upgrade";
294
+ if (card.id === current.id) return "current";
295
+ const tierKey = tierKeyOf(card.id);
296
+ if (tierKey !== null && tierKey === tierKeyOf(current.id)) return "switch";
297
+ if (card.isFree && !current.isFree) return "downgrade";
298
+ if (!card.isFree && current.isFree) return "upgrade";
299
+ return card.sortOrder > current.sortOrder ? "upgrade" : "downgrade";
300
+ }
301
+
302
+ /**
303
+ * Whether the offer's free trial applies to this viewer: only an upgrade by
304
+ * someone not already paying goes through a checkout that starts one.
305
+ */
306
+ export function trialApplies(card: ProductOffer, direction: PlanDirection, paying: boolean): boolean {
307
+ return direction === "upgrade" && !paying && (card.trialDays ?? 0) > 0;
308
+ }
309
+
310
+ /**
311
+ * The CTA for a card. `offer.ctaText` ("Start Free Trial") is the pitch to a
312
+ * new buyer; someone already paying is upgraded in place, so they get
313
+ * "Upgrade to X" (startsim-21f, crochet_patterns-anw61).
314
+ */
315
+ export function planCtaLabel(card: ProductOffer, direction: PlanDirection, paying = false): string {
316
+ if (direction === "current") return "Current plan";
317
+ if (direction === "switch") return card.intervalUnit === "year" ? "Switch to annual" : "Switch to monthly";
318
+ if (direction === "downgrade") return `Downgrade to ${card.name}`;
319
+ if (paying) return `Upgrade to ${card.name}`;
320
+ return card.ctaText || `Upgrade to ${card.name}`;
321
+ }
322
+
323
+ /**
324
+ * A feature row that only describes the trial (key `trial_note`, e.g. "7-day
325
+ * free trial with AI designs included") is not true when the trial does not apply.
326
+ */
327
+ export const TRIAL_NOTE_FEATURE_KEY = "trial_note";