@startsimpli/billing 0.4.32 → 0.4.33

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/billing",
3
- "version": "0.4.32",
3
+ "version": "0.4.33",
4
4
  "description": "Universal billing integration for StartSimpli Next.js apps",
5
5
  "license": "MIT",
6
6
  "homepage": "https://startsimpli.com",
@@ -20,6 +20,7 @@ import {
20
20
  planDirection,
21
21
  resolveCurrentOffer,
22
22
  trialApplies,
23
+ trialLabel,
23
24
  type CurrentPlanState,
24
25
  } from "../utils/plan";
25
26
  import { usePlanChange } from "../hooks/usePlanChange";
@@ -330,7 +331,7 @@ function DetailCard({
330
331
  <div className="mb-5 min-h-8">
331
332
  {trial ? (
332
333
  <span data-testid={`trial-pill-${offer.slug}`} className="inline-block bg-[var(--billing-accent-soft,#eff6ff)] text-[color:var(--billing-accent-text,#1d4ed8)] text-sm font-semibold px-3 py-1 rounded-lg w-fit">
333
- {offer.trialDays}-day free trial
334
+ {trialLabel(offer)}
334
335
  </span>
335
336
  ) : yourPlanNote ? (
336
337
  <span data-testid={`your-plan-note-${offer.slug}`} className="inline-block bg-gray-100 text-gray-700 text-sm font-semibold px-3 py-1 rounded-lg w-fit">
@@ -13,7 +13,7 @@ import { useProduct } from "../hooks/useProduct";
13
13
  import type { ProductOffer } from "../types";
14
14
  import { FeatureIcon } from "./FeatureIcon";
15
15
  import { formatFeatureLabel } from "../utils/format";
16
- import { resolveCurrentOfferId, type CurrentPlanState } from "../utils/plan";
16
+ import { resolveCurrentOfferId, trialLabel, type CurrentPlanState } from "../utils/plan";
17
17
  import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers, type IntervalEntry } from "./IntervalToggle";
18
18
 
19
19
  export interface PricingPageProps {
@@ -136,7 +136,7 @@ function PricingCard({ entry, onSelect, isCurrent = false }: PricingCardProps) {
136
136
  data-testid="trial-badge"
137
137
  className="inline-block bg-blue-50 text-blue-700 text-sm font-semibold px-3 py-1 rounded-lg mb-5 w-fit"
138
138
  >
139
- {offer.trialDays}-day free trial
139
+ {trialLabel(offer)}
140
140
  </span>
141
141
  )}
142
142
 
@@ -14,7 +14,7 @@ import type { OfferFeature, ProductOffer } from "../types";
14
14
  import { cardFeatures, cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
15
15
  import { FeatureIcon } from "./FeatureIcon";
16
16
  import { formatFeatureLabel } from "../utils/format";
17
- import { resolveCurrentOfferId, type CurrentPlanState } from "../utils/plan";
17
+ import { resolveCurrentOfferId, trialLabel, type CurrentPlanState } from "../utils/plan";
18
18
  import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers, type IntervalEntry } from "./IntervalToggle";
19
19
 
20
20
  export interface PricingSectionProps {
@@ -152,7 +152,7 @@ function SectionCard({ entry, onSelect, maxFeatures, buildsOn, inherited, isCurr
152
152
  data-testid="trial-badge"
153
153
  className="inline-block bg-[var(--billing-accent-soft,#eff6ff)] text-[color:var(--billing-accent-text,#1d4ed8)] text-xs font-semibold px-2 py-0.5 rounded-md w-fit"
154
154
  >
155
- {offer.trialDays}-day free trial
155
+ {trialLabel(offer)}
156
156
  </span>
157
157
  )}
158
158
  </div>
@@ -29,6 +29,7 @@ import {
29
29
  planCtaLabel,
30
30
  planDirection,
31
31
  trialApplies,
32
+ trialLabel,
32
33
  TRIAL_NOTE_FEATURE_KEY,
33
34
  type PlanDirection,
34
35
  } from "../utils/plan";
@@ -95,11 +96,11 @@ function PlanFeatureList({ features, showTrialNote = true }: { features: OfferFe
95
96
  * startsim-pih — so users know "Start Free Trial" actually IS a free trial.
96
97
  * No "no card required": checkout collects a card up front and the offer
97
98
  * carries nothing that says otherwise (crochet_patterns-34n7). */
98
- function TrialCopy({ trialDays }: { trialDays: number }) {
99
- if (!trialDays || trialDays <= 0) return null;
99
+ function TrialCopy({ offer }: { offer: ProductOffer }) {
100
+ if (!offer.trialDays || offer.trialDays <= 0) return null;
100
101
  return (
101
102
  <p data-testid="trial-copy" className="-mt-1 mb-3 text-xs text-gray-500">
102
- {trialDays}-day free trial
103
+ {trialLabel(offer)}
103
104
  </p>
104
105
  );
105
106
  }
@@ -566,7 +567,7 @@ export function SubscriptionManager({
566
567
  {/* Trial copy is for prospective trialers only — once the
567
568
  user is on this offer or moving downward, drop the
568
569
  "14-day free trial" sales pitch. */}
569
- {showTrial && <TrialCopy trialDays={offer.trialDays} />}
570
+ {showTrial && <TrialCopy offer={offer} />}
570
571
  <PlanFeatureList features={offer.features} showTrialNote={showTrial || !(offer.trialDays > 0)} />
571
572
  <button
572
573
  data-testid={`upgrade-cta-${offer.slug}`}
@@ -21,6 +21,7 @@ import {
21
21
  planDirection,
22
22
  resolveCurrentOffer,
23
23
  trialApplies,
24
+ trialLabel,
24
25
  TRIAL_NOTE_FEATURE_KEY,
25
26
  type CurrentPlanState,
26
27
  } from "../utils/plan";
@@ -201,7 +202,7 @@ export function UpgradeModal({
201
202
 
202
203
  {trial && (
203
204
  <span data-testid={`trial-pill-${offer.slug}`} className="inline-block bg-blue-50 text-blue-700 text-xs font-semibold px-2.5 py-0.5 rounded-md mb-3 w-fit">
204
- {offer.trialDays}-day free trial
205
+ {trialLabel(offer)}
205
206
  </span>
206
207
  )}
207
208
 
package/src/index.ts CHANGED
@@ -63,6 +63,7 @@ export { BillingApiClient } from "./utils/api";
63
63
  export {
64
64
  isPaidSubscription,
65
65
  isPayingSubscription,
66
+ trialLabel,
66
67
  isUnlistedOffer,
67
68
  getFeatureLimit,
68
69
  resolvePlanEntitlement,
@@ -21,6 +21,8 @@ export interface ProductOffer {
21
21
  isActive: boolean;
22
22
  isFeatured: boolean;
23
23
  trialDays: number;
24
+ /** Charged at checkout to start the trial (cents); 0/absent = a free trial. */
25
+ trialFeeCents?: number;
24
26
  sortOrder: number;
25
27
  ctaText: string;
26
28
  /** Plan tier shared by an offer's monthly and annual prices ("lite",
@@ -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