@startsimpli/billing 0.4.32 → 0.4.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +16 -3
  2. package/src/components/PricingDetailPage.tsx +2 -1
  3. package/src/components/PricingPage.tsx +2 -2
  4. package/src/components/PricingSection.tsx +2 -2
  5. package/src/components/SubscriptionManager.tsx +5 -4
  6. package/src/components/UpgradeModal.tsx +2 -1
  7. package/src/components/UsageMeters.test.tsx +27 -0
  8. package/src/components/UsageMeters.tsx +85 -0
  9. package/src/components/index.ts +2 -0
  10. package/src/credits/CreditBalanceBadge.tsx +13 -0
  11. package/src/credits/CreditHistoryList.tsx +32 -0
  12. package/src/credits/CreditPackCard.tsx +51 -0
  13. package/src/credits/CreditsPanel.tsx +60 -0
  14. package/src/credits/CreditsReturn.tsx +89 -0
  15. package/src/credits/UseCreditPrompt.tsx +46 -0
  16. package/src/credits/copy.ts +96 -0
  17. package/src/credits/credits.test.tsx +75 -0
  18. package/src/credits/index.ts +13 -0
  19. package/src/credits/types.ts +17 -0
  20. package/src/hooks/index.ts +4 -0
  21. package/src/hooks/useAutoCheckout.test.tsx +34 -0
  22. package/src/hooks/useAutoCheckout.ts +31 -0
  23. package/src/hooks/useOfferCheckout.test.tsx +73 -0
  24. package/src/hooks/useOfferCheckout.ts +82 -0
  25. package/src/index.ts +24 -2
  26. package/src/types/index.ts +2 -0
  27. package/src/utils/checkout-flow.test.ts +56 -0
  28. package/src/utils/checkout-flow.ts +55 -0
  29. package/src/utils/credit-math.test.ts +18 -0
  30. package/src/utils/credit-math.ts +32 -0
  31. package/src/utils/plan.test.ts +13 -1
  32. package/src/utils/plan.ts +22 -0
  33. package/src/utils/return-urls.test.ts +38 -0
  34. package/src/utils/return-urls.ts +74 -0
  35. package/src/utils/usage-meters.test.ts +20 -0
  36. package/src/utils/usage-meters.ts +35 -0
@@ -0,0 +1,32 @@
1
+ /** Price math for one-time credit packs. Pure. */
2
+
3
+ export interface PackPrice {
4
+ price: string;
5
+ compareAt?: string | null;
6
+ credits: number;
7
+ }
8
+
9
+ /** "$19.99" from a decimal-string price and its currency; cents always shown. */
10
+ export function formatPackPrice(amount: string, currency: string): string {
11
+ const n = Number(amount);
12
+ if (!Number.isFinite(n)) return amount;
13
+ try {
14
+ return new Intl.NumberFormat("en-US", { style: "currency", currency: (currency || "USD").toUpperCase() }).format(n);
15
+ } catch {
16
+ return `${n.toFixed(2)} ${currency}`;
17
+ }
18
+ }
19
+
20
+ /** Whole-percent saving of `price` against a struck-through `compareAt`, or null when there is none. */
21
+ export function savingsPercent(offer: Pick<PackPrice, "price" | "compareAt">): number | null {
22
+ const price = Number(offer.price);
23
+ const was = Number(offer.compareAt);
24
+ if (!offer.compareAt || !Number.isFinite(price) || !Number.isFinite(was) || was <= price || was <= 0) return null;
25
+ return Math.round((1 - price / was) * 100);
26
+ }
27
+
28
+ /** Price of one credit in this pack (decimal string, 2 places). */
29
+ export function pricePerCredit(offer: Pick<PackPrice, "price" | "credits">): string {
30
+ const total = Number(offer.price);
31
+ return offer.credits > 0 && Number.isFinite(total) ? (total / offer.credits).toFixed(2) : offer.price;
32
+ }
@@ -99,7 +99,7 @@ describe('isPaidSubscription', () => {
99
99
  // current_period_start is frozen at signup and never rolls.
100
100
  // ---------------------------------------------------------------------------
101
101
 
102
- import { getFeatureLimit, resolvePlanEntitlement, resolveUsageWindow } from './plan'
102
+ import { getFeatureLimit, resolvePlanEntitlement, resolveUsageWindow, trialLabel } from './plan'
103
103
 
104
104
  const freeOffer = makeOffer({
105
105
  slug: 'crochet-free',
@@ -312,3 +312,15 @@ describe('resolveUsageWindow', () => {
312
312
  expect(w.end.toISOString()).toBe('2027-01-01T00:00:00.000Z')
313
313
  })
314
314
  })
315
+
316
+ describe("trialLabel", () => {
317
+ const base = { trialDays: 7, currency: "USD" } as const;
318
+ it("says free when nothing is charged", () => {
319
+ expect(trialLabel({ ...base })).toBe("7-day free trial");
320
+ expect(trialLabel({ ...base, trialFeeCents: 0 })).toBe("7-day free trial");
321
+ });
322
+ it("does not say free when a card-check fee is charged", () => {
323
+ expect(trialLabel({ ...base, trialFeeCents: 100 })).toBe("7-day trial · $1 today");
324
+ expect(trialLabel({ ...base, trialFeeCents: 150 })).toBe("7-day trial · $1.50 today");
325
+ });
326
+ });
package/src/utils/plan.ts CHANGED
@@ -307,6 +307,28 @@ export function trialApplies(card: ProductOffer, direction: PlanDirection, payin
307
307
  return direction === "upgrade" && !paying && (card.trialDays ?? 0) > 0;
308
308
  }
309
309
 
310
+ /**
311
+ * "7-day free trial", or "7-day trial · $1 today" when starting it charges a
312
+ * card-check fee (then it is not free, so it must not say so).
313
+ */
314
+ export function trialLabel(offer: Pick<ProductOffer, "trialDays" | "trialFeeCents" | "currency">): string {
315
+ const days = offer.trialDays ?? 0;
316
+ const cents = offer.trialFeeCents ?? 0;
317
+ if (cents <= 0) return `${days}-day free trial`;
318
+ const amount = cents / 100;
319
+ let fee: string;
320
+ try {
321
+ fee = new Intl.NumberFormat("en-US", {
322
+ style: "currency",
323
+ currency: (offer.currency || "USD").toUpperCase(),
324
+ minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
325
+ }).format(amount);
326
+ } catch {
327
+ fee = `$${amount}`;
328
+ }
329
+ return `${days}-day trial · ${fee} today`;
330
+ }
331
+
310
332
  /**
311
333
  * The CTA for a card. `offer.ctaText` ("Start Free Trial") is the pitch to a
312
334
  * new buyer; someone already paying is upgraded in place, so they get
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ autoCheckoutOffer, billingReturnUrl, creditReturnUrls, creditSessionFrom, signUpReturnUrl, statusMessageFrom,
4
+ } from "./return-urls";
5
+ import type { ProductOffer } from "../types";
6
+
7
+ const offers = [{ slug: "pro" }, { slug: "lite" }] as unknown as ProductOffer[];
8
+
9
+ describe("return urls", () => {
10
+ it("prefers the public origin, trims slashes and adds a leading slash", () => {
11
+ expect(billingReturnUrl("/x", "http://localhost:3000", "https://app.example/")).toBe("https://app.example/x");
12
+ expect(billingReturnUrl("x", "http://localhost:3000")).toBe("http://localhost:3000/x");
13
+ });
14
+ it("builds the sign-up return with plan, auto and optional next", () => {
15
+ expect(signUpReturnUrl("https://a.b", "pro", null)).toBe("https://a.b/pricing?plan=pro&checkout=auto");
16
+ expect(signUpReturnUrl("https://a.b", "pro", "/go", "/plans")).toBe("https://a.b/plans?plan=pro&checkout=auto&next=%2Fgo");
17
+ });
18
+ it("picks the auto-checkout offer only when asked and known", () => {
19
+ expect(autoCheckoutOffer(offers, new URLSearchParams("checkout=auto&plan=lite"))?.slug).toBe("lite");
20
+ expect(autoCheckoutOffer(offers, new URLSearchParams("plan=lite"))).toBeNull();
21
+ expect(autoCheckoutOffer(offers, new URLSearchParams("checkout=auto&plan=zzz"))).toBeNull();
22
+ expect(autoCheckoutOffer(offers, new URLSearchParams("checkout=auto"))).toBeNull();
23
+ });
24
+ it("credit return urls carry the session placeholder, unencoded", () => {
25
+ const abs = (p: string) => `https://a.b${p}`;
26
+ expect(creditReturnUrls(abs)).toEqual({
27
+ successUrl: "https://a.b/settings/billing/credits/success?session_id={CHECKOUT_SESSION_ID}",
28
+ cancelUrl: "https://a.b/settings/billing/credits/cancelled",
29
+ });
30
+ expect(creditReturnUrls(abs, { successPath: "/ok", cancelledPath: "/no" }).cancelUrl).toBe("https://a.b/no");
31
+ });
32
+ it("reads only real session ids and known statuses", () => {
33
+ expect(creditSessionFrom(new URLSearchParams("session_id=cs_test_1"))).toBe("cs_test_1");
34
+ expect(creditSessionFrom(new URLSearchParams("session_id={CHECKOUT_SESSION_ID}"))).toBeNull();
35
+ expect(statusMessageFrom(new URLSearchParams("status=scheduled"))).toBe("scheduled");
36
+ expect(statusMessageFrom(new URLSearchParams("status=hax"))).toBeNull();
37
+ });
38
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Return-URL and hand-off helpers around checkout: where Stripe (or central
3
+ * sign-up) sends the browser back to, and how a pricing page picks up an
4
+ * "auto checkout" request. Pure: no React, no window.
5
+ */
6
+
7
+ import type { ProductOffer } from "../types";
8
+ import { checkoutSessionIdFrom, withSessionPlaceholder } from "./checkout-flow";
9
+
10
+ /**
11
+ * The absolute URL Stripe (or central auth) sends the browser back to. The
12
+ * configured public origin wins so a build served behind a tunnel or proxy
13
+ * still returns to the public site; else the origin the page is on.
14
+ */
15
+ export function billingReturnUrl(path: string, currentOrigin?: string, publicOrigin?: string): string {
16
+ const origin = (publicOrigin || currentOrigin || "").replace(/\/+$/, "");
17
+ const normalized = path.startsWith("/") ? path : `/${path}`;
18
+ return `${origin}${normalized}`;
19
+ }
20
+
21
+ /**
22
+ * Where sign-up sends the person back to: the pricing page with the plan they
23
+ * picked, so checkout starts without a second click.
24
+ */
25
+ export function signUpReturnUrl(
26
+ origin: string,
27
+ planSlug: string,
28
+ nextPath: string | null,
29
+ pricingPath = "/pricing",
30
+ ): string {
31
+ const q = new URLSearchParams({ plan: planSlug, checkout: "auto" });
32
+ if (nextPath) q.set("next", nextPath);
33
+ return `${origin}${pricingPath}?${q.toString()}`;
34
+ }
35
+
36
+ /** The offer to check out straight away: only when ?checkout=auto&plan=<slug>. */
37
+ export function autoCheckoutOffer(offers: ProductOffer[], params: URLSearchParams): ProductOffer | null {
38
+ if (params.get("checkout") !== "auto") return null;
39
+ const plan = params.get("plan");
40
+ if (!plan) return null;
41
+ return offers.find((o) => o.slug === plan) ?? null;
42
+ }
43
+
44
+ export interface CreditReturnPaths {
45
+ successPath: string;
46
+ cancelledPath: string;
47
+ }
48
+
49
+ /** Where Stripe sends the browser after a credit pack: the return pages. */
50
+ export function creditReturnUrls(
51
+ absolute: (path: string) => string,
52
+ paths: CreditReturnPaths = {
53
+ successPath: "/settings/billing/credits/success",
54
+ cancelledPath: "/settings/billing/credits/cancelled",
55
+ },
56
+ ): { successUrl: string; cancelUrl: string } {
57
+ return {
58
+ successUrl: withSessionPlaceholder(absolute(paths.successPath)),
59
+ cancelUrl: absolute(paths.cancelledPath),
60
+ };
61
+ }
62
+
63
+ /** The Stripe session to sync from the success page's query, or null. */
64
+ export function creditSessionFrom(params: URLSearchParams): string | null {
65
+ return checkoutSessionIdFrom(params.get("session_id"));
66
+ }
67
+
68
+ export type PlanStatusMessage = "success" | "cancelled" | "scheduled";
69
+
70
+ /** ?status= on billing settings after a plan checkout or change. */
71
+ export function statusMessageFrom(params: URLSearchParams): PlanStatusMessage | null {
72
+ const s = params.get("status");
73
+ return s === "success" || s === "cancelled" || s === "scheduled" ? s : null;
74
+ }
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { meterRows } from "./usage-meters";
3
+
4
+ describe("meterRows", () => {
5
+ it("skips unlimited and flags, states by fill, humanizes missing labels", () => {
6
+ const rows = meterRows({
7
+ ai_designs: { used: 2, limit: 10 },
8
+ near: { used: 8, limit: 10 },
9
+ full: { used: 12, limit: 10 },
10
+ unlimited: { used: 5, limit: null },
11
+ zero: { used: 0, limit: 0 },
12
+ }, { near: "Near" });
13
+ expect(rows.map((r) => [r.key, r.state, r.label, r.left])).toEqual([
14
+ ["ai_designs", "ok", "Ai designs", 8],
15
+ ["near", "approaching", "Near", 2],
16
+ ["full", "at-cap", "Full", 0],
17
+ ["zero", "at-cap", "Zero", 0],
18
+ ]);
19
+ });
20
+ });
@@ -0,0 +1,35 @@
1
+ import type { UsageMap } from "../types";
2
+
3
+ export type MeterState = "ok" | "approaching" | "at-cap";
4
+
5
+ export interface MeterRow {
6
+ key: string;
7
+ label: string;
8
+ used: number;
9
+ limit: number;
10
+ pct: number;
11
+ left: number;
12
+ state: MeterState;
13
+ }
14
+
15
+ const humanize = (key: string): string => {
16
+ const words = key.replace(/[_-]+/g, " ").trim();
17
+ return words.charAt(0).toUpperCase() + words.slice(1);
18
+ };
19
+
20
+ /**
21
+ * One meter per feature with a real numeric cap. Unlimited (-1 / null) and
22
+ * flags render nothing: a paying person is not shown a bar for what they have
23
+ * no cap on. Labels come from the plan's own feature names.
24
+ */
25
+ export function meterRows(usage: UsageMap, labels: Record<string, string> = {}): MeterRow[] {
26
+ return Object.entries(usage ?? {})
27
+ .filter(([, u]) => u && typeof u.limit === "number" && u.limit >= 0)
28
+ .map(([key, u]) => {
29
+ const limit = u.limit as number;
30
+ const used = Math.max(0, u.used);
31
+ const pct = limit === 0 ? 100 : Math.min(100, Math.round((used / limit) * 100));
32
+ const state: MeterState = used >= limit ? "at-cap" : pct >= 80 ? "approaching" : "ok";
33
+ return { key, label: labels[key] ?? humanize(key), used, limit, pct, left: Math.max(0, limit - used), state };
34
+ });
35
+ }