diff --git a/apps/api/src/app/services/__tests__/stripe.service.spec.ts b/apps/api/src/app/services/__tests__/stripe.service.spec.ts new file mode 100644 index 000000000..cc62b1abc --- /dev/null +++ b/apps/api/src/app/services/__tests__/stripe.service.spec.ts @@ -0,0 +1,170 @@ +/** + * Focused unit tests for convertCustomerWithSubscriptionsToUserFacing — the boundary where raw + * Stripe API objects become the user-facing billing shapes rendered by the web app. + * + * The contracts locked in here: + * - Monetary amounts (balance, unitAmount) are converted from Stripe's cents to dollars HERE, + * and only here — the client must not divide again (a double-conversion previously shipped + * a "$2.50/year" display bug). + * - Tiered prices (e.g. TEAM volume pricing) have no top-level unit_amount, which maps to + * unitAmount 0; the client relies on that sentinel to suppress the price breakdown. + * - hasDiscount is true for any of the three discount sources: a customer-level discount, the + * deprecated subscription-level discount, or entries in the subscription discounts array + * (which are unexpanded id strings by default). + */ +import { formatISO, fromUnixTime } from 'date-fns'; +import type Stripe from 'stripe'; +import { describe, expect, it, vi } from 'vitest'; +import { convertCustomerWithSubscriptionsToUserFacing } from '../stripe.service'; + +// Mock the transitive import chain down to the minimum needed to load the module — the +// conversion under test is a pure function and never touches the DB, email, or Stripe client +// (the module-level `new Stripe()` is skipped because the mocked ENV has no STRIPE_API_KEY). +vi.mock('@jetstream/api-config', () => ({ + ENV: {}, + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); +vi.mock('@jetstream/email', () => ({ sendWelcomeToProEmail: vi.fn() })); +vi.mock('../../db/subscription.db', () => ({})); +vi.mock('../../db/team.db', () => ({})); +vi.mock('../../db/user.db', () => ({})); + +const START_DATE = 1_717_200_000; +const BILLING_CYCLE_ANCHOR = 1_718_000_000; + +function buildCustomer({ + balance = 0, + customerDiscount = null, + subscriptionOverrides = {}, + priceOverrides = {}, + quantity = 1 as number | null, +} = {}): Stripe.Customer { + return { + id: 'cus_123', + balance, + delinquent: false, + discount: customerDiscount, + subscriptions: { + data: [ + { + id: 'sub_123', + billing_cycle_anchor: BILLING_CYCLE_ANCHOR, + cancel_at: null, + cancel_at_period_end: false, + canceled_at: null, + discount: null, + discounts: [], + ended_at: null, + start_date: START_DATE, + status: 'active', + items: { + data: [ + { + id: 'si_123', + quantity, + price: { + id: 'price_pro_annual', + active: true, + product: 'prod_pro', + lookup_key: 'PRO_ANNUAL', + unit_amount: 25000, + recurring: { interval: 'year', interval_count: 1 }, + ...priceOverrides, + }, + }, + ], + }, + ...subscriptionOverrides, + }, + ], + }, + } as unknown as Stripe.Customer; +} + +describe('convertCustomerWithSubscriptionsToUserFacing', () => { + it('converts cents to dollars, uppercases status, and converts unix dates to ISO strings', () => { + const result = convertCustomerWithSubscriptionsToUserFacing(buildCustomer({ balance: -5000 })); + + expect(result).toEqual({ + id: 'cus_123', + balance: -50, + delinquent: false, + subscriptions: [ + { + id: 'sub_123', + billingCycleAnchor: formatISO(fromUnixTime(BILLING_CYCLE_ANCHOR)), + cancelAt: null, + cancelAtPeriodEnd: false, + canceledAt: null, + endedAt: null, + startDate: formatISO(fromUnixTime(START_DATE)), + status: 'ACTIVE', + hasDiscount: false, + items: [ + { + id: 'si_123', + priceId: 'price_pro_annual', + active: true, + product: 'prod_pro', + lookupKey: 'PRO_ANNUAL', + unitAmount: 250, + recurringInterval: 'YEAR', + recurringIntervalCount: 1, + quantity: 1, + }, + ], + }, + ], + }); + }); + + it('maps tiered prices (no top-level unit_amount) to unitAmount 0 so the client can suppress the price display', () => { + const result = convertCustomerWithSubscriptionsToUserFacing( + buildCustomer({ + priceOverrides: { lookup_key: 'TEAM_MONTHLY', unit_amount: null, recurring: { interval: 'month', interval_count: 1 } }, + quantity: 6, + }), + ); + + expect(result.subscriptions[0].items[0]).toEqual( + expect.objectContaining({ + lookupKey: 'TEAM_MONTHLY', + unitAmount: 0, + recurringInterval: 'MONTH', + quantity: 6, + }), + ); + }); + + it('defaults a missing item quantity to 1', () => { + const result = convertCustomerWithSubscriptionsToUserFacing(buildCustomer({ quantity: null })); + + expect(result.subscriptions[0].items[0].quantity).toBe(1); + }); + + it('sets hasDiscount when the customer has a customer-level discount', () => { + const result = convertCustomerWithSubscriptionsToUserFacing(buildCustomer({ customerDiscount: { id: 'di_customer' } })); + + expect(result.subscriptions[0].hasDiscount).toBe(true); + }); + + it('sets hasDiscount when the subscription has a legacy singular discount', () => { + const result = convertCustomerWithSubscriptionsToUserFacing( + buildCustomer({ subscriptionOverrides: { discount: { id: 'di_legacy' } } }), + ); + + expect(result.subscriptions[0].hasDiscount).toBe(true); + }); + + it('sets hasDiscount when the subscription discounts array contains unexpanded ids', () => { + const result = convertCustomerWithSubscriptionsToUserFacing(buildCustomer({ subscriptionOverrides: { discounts: ['di_abc'] } })); + + expect(result.subscriptions[0].hasDiscount).toBe(true); + }); + + it('does not set hasDiscount when no discount source is present', () => { + const result = convertCustomerWithSubscriptionsToUserFacing(buildCustomer()); + + expect(result.subscriptions[0].hasDiscount).toBe(false); + }); +}); diff --git a/apps/api/src/app/services/stripe.service.ts b/apps/api/src/app/services/stripe.service.ts index e4d1310bb..26dcc83f7 100644 --- a/apps/api/src/app/services/stripe.service.ts +++ b/apps/api/src/app/services/stripe.service.ts @@ -197,6 +197,8 @@ export function convertCustomerWithSubscriptionsToUserFacing(stripeCustomer: Str canceled_at, // current_period_end, // current_period_start, + discount, + discounts, ended_at, items, start_date, @@ -214,6 +216,9 @@ export function convertCustomerWithSubscriptionsToUserFacing(stripeCustomer: Str endedAt: ended_at ? formatISO(fromUnixTime(ended_at)) : null, startDate: formatISO(fromUnixTime(start_date)), status: status.toUpperCase() as Uppercase, + // Existence check only — `discounts` entries are unexpanded ids, and a customer-level + // coupon discounts this subscription's invoices the same as a subscription-level one + hasDiscount: Boolean(stripeCustomer.discount || discount || discounts.length > 0), items: items.data.map(({ id, price, quantity }) => ({ id, priceId: price.id, diff --git a/apps/jetstream/src/app/components/billing/Billing.tsx b/apps/jetstream/src/app/components/billing/Billing.tsx index b78bc5660..7cd69350d 100644 --- a/apps/jetstream/src/app/components/billing/Billing.tsx +++ b/apps/jetstream/src/app/components/billing/Billing.tsx @@ -260,7 +260,9 @@ export const Billing = () => { priceSubtext={ isAnnual ? PLAN_DESCRIPTIONS[PRO_ANNUAL_KEY].priceSubtext : PLAN_DESCRIPTIONS[PRO_MONTHLY_KEY].priceSubtext } - description={PLAN_DESCRIPTIONS[PRO_MONTHLY_KEY].description} + description={ + isAnnual ? PLAN_DESCRIPTIONS[PRO_ANNUAL_KEY].description : PLAN_DESCRIPTIONS[PRO_MONTHLY_KEY].description + } features={PLAN_DESCRIPTIONS[PRO_MONTHLY_KEY].features} checked={ selectedPlan === (isAnnual ? PLAN_DESCRIPTIONS[PRO_ANNUAL_KEY].key : PLAN_DESCRIPTIONS[PRO_MONTHLY_KEY].key) @@ -276,8 +278,13 @@ export const Billing = () => { priceSubtext={ isAnnual ? PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].priceSubtext : PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].priceSubtext } - description={PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].description} + description={ + isAnnual ? PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].description : PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].description + } features={PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].features} + pricingTiers={ + isAnnual ? PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].pricingTiers : PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].pricingTiers + } checked={ selectedPlan === (isAnnual ? PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].key : PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].key) } diff --git a/apps/jetstream/src/app/components/billing/BillingExistingSubscriptions.tsx b/apps/jetstream/src/app/components/billing/BillingExistingSubscriptions.tsx index d0bc7f8ba..164ef7d8a 100644 --- a/apps/jetstream/src/app/components/billing/BillingExistingSubscriptions.tsx +++ b/apps/jetstream/src/app/components/billing/BillingExistingSubscriptions.tsx @@ -1,5 +1,5 @@ import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; -import { JetstreamPricesByLookupKey, StripeUserFacingCustomer } from '@jetstream/types'; +import { JetstreamPricesByLookupKey, StripeUserFacingCustomer, StripeUserFacingSubscriptionItem } from '@jetstream/types'; import { useAmplitude } from '@jetstream/ui-core'; import { useState } from 'react'; import { EnhancedBillingCard } from './EnhancedBillingCard'; @@ -18,19 +18,49 @@ interface BillingExistingSubscriptionsProps { hasManualBilling: boolean; } +// unitAmount is already converted from cents to dollars by the API (stripe.service.ts) +const formatUsd = (amountInDollars: number) => `$${amountInDollars.toFixed(2).replace(/\.00$/, '')}`; + +const intervalLabel = (interval: StripeUserFacingSubscriptionItem['recurringInterval']) => { + switch (interval) { + case 'MONTH': + return 'month'; + case 'YEAR': + return 'year'; + case 'WEEK': + return 'week'; + case 'DAY': + return 'day'; + default: + return 'period'; + } +}; + export const BillingExistingSubscriptions = ({ customerWithSubscriptions, pricesByLookupKey, hasManualBilling, }: BillingExistingSubscriptionsProps) => { const { trackEvent } = useAmplitude(); + + const activeSubscription = customerWithSubscriptions.subscriptions.find(({ status }) => ACTIVE_SUBSCRIPTION_STATUSES.has(status)); + const activeItem = activeSubscription?.items[0]; + + const teamProductId = pricesByLookupKey?.TEAM_MONTHLY?.product?.id; + const proProductId = pricesByLookupKey?.PRO_MONTHLY?.product?.id; + + const isTeamSubscription = !!activeItem && (activeItem.lookupKey?.startsWith('TEAM_') || activeItem.product === teamProductId); + const isProSubscription = !!activeItem && (activeItem.lookupKey?.startsWith('PRO_') || activeItem.product === proProductId); + + const matchesCurrentPrice = + !!activeItem && !!pricesByLookupKey && Object.values(pricesByLookupKey).some((price) => price.id === activeItem.priceId); + const isLegacyPlan = !!activeItem && !hasManualBilling && !matchesCurrentPrice && (isTeamSubscription || isProSubscription); + const [selectedPlan, setSelectedPlan] = useState(() => { - const priceId = - customerWithSubscriptions.subscriptions.find(({ status }) => ACTIVE_SUBSCRIPTION_STATUSES.has(status))?.items[0].priceId || null; - if (priceId) { - return Object.values(pricesByLookupKey || {}).find((price) => price.id === priceId)?.lookupKey || null; + if (!activeItem) { + return null; } - return null; + return Object.values(pricesByLookupKey || {}).find((price) => price.id === activeItem.priceId)?.lookupKey || null; }); const handleEnterpriseContact = () => { @@ -38,8 +68,85 @@ export const BillingExistingSubscriptions = ({ window.open('mailto:support@getjetstream.app?subject=Enterprise Plan Inquiry', '_blank'); }; + const renderCurrentPlanSummary = () => { + if (!activeItem) { + return null; + } + + let planLabel = 'Plan'; + if (isTeamSubscription) { + planLabel = 'Team'; + } else if (isProSubscription) { + planLabel = 'Professional'; + } + + let badge: string | null = null; + if (hasManualBilling) { + badge = 'Custom plan'; + } else if (isLegacyPlan) { + badge = 'Legacy plan'; + } + + // Tiered prices (e.g. TEAM volume pricing) have no top-level unit_amount on the subscription + // item, so unitAmount comes through as 0 and we cannot compute a real total client-side — + // omit the price breakdown rather than display "$0". + const hasUsablePrice = activeItem.unitAmount > 0; + const perSeat = formatUsd(activeItem.unitAmount); + const total = formatUsd(activeItem.unitAmount * activeItem.quantity); + const interval = intervalLabel(activeItem.recurringInterval); + // Amounts are list prices — qualify them when a coupon means the customer actually pays less + const discountQualifier = activeSubscription?.hasDiscount ? (before discounts) : null; + + return ( +
+
+ Your current plan: {planLabel} + {badge && ( + + {badge} + + )} +
+ {isTeamSubscription ? ( +

+ {activeItem.quantity} {activeItem.quantity === 1 ? 'seat' : 'seats'} + {hasUsablePrice && ( + <> + {' '} + × {perSeat}/seat/{interval} ={' '} + + {total}/{interval} + + {discountQualifier} + + )} +

+ ) : ( + hasUsablePrice && ( +

+ + {perSeat}/{interval} + + {discountQualifier} +

+ ) + )} + {hasManualBilling && ( +

+ You have a custom billing arrangement. Contact support for plan changes. +

+ )} +
+ ); + }; + return (
+ {renderCurrentPlanSummary()} + {!hasManualBilling && (

Visit the billing portal to make changes to your plan

@@ -84,6 +191,7 @@ export const BillingExistingSubscriptions = ({ description={PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].description} features={PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].features} comingSoonFeatures={PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].comingSoonFeatures} + pricingTiers={PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].pricingTiers} checked={!hasManualBilling && selectedPlan === TEAM_MONTHLY_KEY} disabled={hasManualBilling || selectedPlan !== TEAM_MONTHLY_KEY} value={PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].key} @@ -96,6 +204,7 @@ export const BillingExistingSubscriptions = ({ description={PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].description} features={PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].features} comingSoonFeatures={PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].comingSoonFeatures} + pricingTiers={PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].pricingTiers} checked={!hasManualBilling && selectedPlan === TEAM_ANNUAL_KEY} disabled={hasManualBilling || selectedPlan !== TEAM_ANNUAL_KEY} value={PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].key} diff --git a/apps/jetstream/src/app/components/billing/BillingPeriodToggle.tsx b/apps/jetstream/src/app/components/billing/BillingPeriodToggle.tsx index 264c046ee..ba2ef565f 100644 --- a/apps/jetstream/src/app/components/billing/BillingPeriodToggle.tsx +++ b/apps/jetstream/src/app/components/billing/BillingPeriodToggle.tsx @@ -14,21 +14,6 @@ const toggleStyles = css` gap: 8px; } - .savings-badge { - background: #2e844a; - color: white; - padding: 4px 12px; - border-radius: 12px; - font-size: 12px; - font-weight: 600; - opacity: 0; - transition: opacity 0.2s ease; - } - - .savings-badge.visible { - opacity: 1; - } - .billing-toggle-container { display: flex; align-items: center; @@ -85,7 +70,6 @@ export const BillingPeriodToggle = ({ isAnnual, onChange }: BillingPeriodToggleP return (
-
Get two months free
onChange(!isAnnual)}>
diff --git a/apps/jetstream/src/app/components/billing/EnhancedBillingCard.tsx b/apps/jetstream/src/app/components/billing/EnhancedBillingCard.tsx index 668795d80..37d26f27f 100644 --- a/apps/jetstream/src/app/components/billing/EnhancedBillingCard.tsx +++ b/apps/jetstream/src/app/components/billing/EnhancedBillingCard.tsx @@ -4,13 +4,19 @@ import { Icon } from '@jetstream/ui'; import classNames from 'classnames'; import { useId } from 'react'; +interface PricingTier { + seats: string; + perUser: string; +} + interface EnhancedBillingCardProps { planName: string; price: string; priceSubtext?: string; description?: string; - features: string[]; - comingSoonFeatures?: string[]; + features: readonly string[]; + comingSoonFeatures?: readonly string[]; + pricingTiers?: readonly PricingTier[]; isEnterprise?: boolean; disabled?: boolean; disabledReason?: string; @@ -199,6 +205,35 @@ const cardStyles = css` font-style: italic; position: relative; } + + .pricing-tiers { + margin: 12px auto 0; + max-width: 260px; + border: 1px solid #e5e5e5; + border-radius: 6px; + overflow: hidden; + } + + .pricing-tier-row { + display: flex; + justify-content: space-between; + font-size: 13px; + padding: 6px 12px; + background: #fafafa; + + &:not(:last-child) { + border-bottom: 1px solid #ececec; + } + } + + .pricing-tier-seats { + color: #706e6b; + font-weight: 600; + } + + .pricing-tier-amount { + color: #16325c; + } `; export const EnhancedBillingCard = ({ @@ -208,6 +243,7 @@ export const EnhancedBillingCard = ({ description, features, comingSoonFeatures, + pricingTiers, isEnterprise = false, disabled = false, disabledReason, @@ -256,6 +292,16 @@ export const EnhancedBillingCard = ({
{price}
{priceSubtext &&
{priceSubtext}
} {description &&
{description}
} + {pricingTiers && pricingTiers.length > 0 && ( +
+ {pricingTiers.map((tier) => ( +
+ {tier.seats} + {tier.perUser} +
+ ))} +
+ )}
diff --git a/apps/jetstream/src/app/components/billing/billing.constants.ts b/apps/jetstream/src/app/components/billing/billing.constants.ts index cc20a97b9..05edc5f8b 100644 --- a/apps/jetstream/src/app/components/billing/billing.constants.ts +++ b/apps/jetstream/src/app/components/billing/billing.constants.ts @@ -1,3 +1,4 @@ +import { PRICING_COPY } from '@jetstream/shared/constants'; import { StripeUserFacingSubscription } from '@jetstream/types'; export const ACTIVE_SUBSCRIPTION_STATUSES = new Set([ @@ -24,20 +25,14 @@ export const professionalFeatures = [ 'Priority support', ]; -export const teamFeatures = [ - 'Everything in Professional', - 'Manage team members', - 'Up to 20 team members', - 'SSO via OIDC and SAML', - 'View & Manage team member session activity', - 'Role-based access control', -]; +export const teamFeatures = PRICING_COPY.TEAM.features; -export const teamFeaturesComingSoon = ['SOC 2 (in progress)', 'Share orgs between team members', 'Audit logs']; +export const teamFeaturesComingSoon = PRICING_COPY.TEAM.comingSoonFeatures; export const enterpriseFeatures = [ 'Everything in Team', - 'Unlimited team members', + 'SOC 2 Type II compliance', + 'Audit logs', 'Single Sign-On (SSO)', 'Custom agreements and terms', 'Dedicated account manager', @@ -48,31 +43,33 @@ export const enterpriseFeatures = [ export const PLAN_DESCRIPTIONS = { [PRO_MONTHLY_KEY]: { key: 'PRO_MONTHLY', - price: '$25', + price: PRICING_COPY.PRO.monthly.pricePerMonth, priceSubtext: '/month', - description: 'Perfect for individual users', + description: PRICING_COPY.PRO.description, features: professionalFeatures, }, [PRO_ANNUAL_KEY]: { key: 'PRO_ANNUAL', - price: '$250', - priceSubtext: '/year', - description: 'Save 2 months with annual billing', + price: PRICING_COPY.PRO.annual.pricePerMonth, + priceSubtext: '/month, billed annually', + description: PRICING_COPY.PRO.annualDescription, features: professionalFeatures, }, [TEAM_MONTHLY_KEY]: { key: 'TEAM_MONTHLY', - price: '$125', - priceSubtext: '/month (includes 5 users)', - description: '$25/user/month with 5-user minimum', + price: PRICING_COPY.TEAM.monthly.pricePerUserMonth, + priceSubtext: '/user/month', + description: PRICING_COPY.TEAM.description, + pricingTiers: PRICING_COPY.TEAM.monthly.tiers, features: teamFeatures, comingSoonFeatures: teamFeaturesComingSoon, }, [TEAM_ANNUAL_KEY]: { key: 'TEAM_ANNUAL', - price: '$1,100', - priceSubtext: '/year (includes 5 users)', - description: '$220/user/year with 5-user minimum', + price: PRICING_COPY.TEAM.annual.pricePerUserMonth, + priceSubtext: '/user/month, billed annually', + description: PRICING_COPY.TEAM.description, + pricingTiers: PRICING_COPY.TEAM.annual.tiers, features: teamFeatures, comingSoonFeatures: teamFeaturesComingSoon, }, @@ -80,7 +77,7 @@ export const PLAN_DESCRIPTIONS = { key: 'CUSTOM', price: 'Custom', priceSubtext: 'Contact us', - description: 'Advanced features for large teams', + description: PRICING_COPY.ENTERPRISE.description, features: enterpriseFeatures, }, } as const; diff --git a/apps/landing/pages/pricing/index.tsx b/apps/landing/pages/pricing/index.tsx index 36a7914cb..312d05585 100644 --- a/apps/landing/pages/pricing/index.tsx +++ b/apps/landing/pages/pricing/index.tsx @@ -1,4 +1,5 @@ import { CheckIcon } from '@heroicons/react/20/solid'; +import { PRICING_COPY } from '@jetstream/shared/constants'; import classNames from 'classnames'; import Link from 'next/link'; import { useState } from 'react'; @@ -37,8 +38,11 @@ const tiers = [ name: 'Professional', id: 'tier-professional', href: '#', - price: { monthly: { price: '$25', suffix: 'month' }, annually: { price: '$250', suffix: 'year' } }, - description: 'Perfect for individual users', + price: { + monthly: { price: PRICING_COPY.PRO.monthly.pricePerMonth, suffix: '/month' }, + annually: { price: PRICING_COPY.PRO.annual.pricePerMonth, suffix: '/month', secondaryLabel: 'billed annually' }, + }, + description: PRICING_COPY.PRO.description, features: [ 'Everything in Free plan', @@ -58,17 +62,17 @@ const tiers = [ name: 'Team', id: 'tier-team', href: '#', - price: { monthly: { price: '$125', suffix: 'month' }, annually: { price: '$1,100', suffix: 'year' } }, - description: { monthly: `Includes 5 users ($25/user/month)`, annually: `Includes 5 users ($22/user/month)` }, - features: [ - 'Everything in Professional', - 'Manage team members', - 'Up to 20 team members', - 'SSO via OIDC and SAML', - 'View & Manage team member session activity', - 'Role-based access control', - ], - comingSoonFeatures: ['SOC 2 compliance (in-progress)', 'Share orgs between team members', 'Audit logs'], + price: { + monthly: { price: PRICING_COPY.TEAM.monthly.pricePerUserMonth, suffix: '/user/month' }, + annually: { price: PRICING_COPY.TEAM.annual.pricePerUserMonth, suffix: '/user/month', secondaryLabel: 'billed annually' }, + }, + description: PRICING_COPY.TEAM.description, + pricingTiers: { + monthly: PRICING_COPY.TEAM.monthly.tiers, + annually: PRICING_COPY.TEAM.annual.tiers, + }, + features: [...PRICING_COPY.TEAM.features], + comingSoonFeatures: [...PRICING_COPY.TEAM.comingSoonFeatures], mostPopular: false, }, { @@ -76,10 +80,11 @@ const tiers = [ id: 'tier-enterprise', href: 'mailto:sales@getjetstream.app?subject=Enterprise Plan Inquiry', price: { monthly: { price: 'Custom', suffix: null }, annually: { price: 'Custom', suffix: null } }, - description: 'Advanced features for large teams', + description: PRICING_COPY.ENTERPRISE.description, features: [ 'Everything in Team', - 'Unlimited team members', + 'SOC 2 Type II compliance', + 'Audit logs', 'Custom agreements and terms', 'Dedicated account manager', 'White-glove onboarding', @@ -136,15 +141,29 @@ export default function Page() { {tier.name}
-

- {typeof tier.description === 'string' ? tier.description : tier.description[frequency.value]} -

+

{tier.description}

{tier.price[frequency.value].price} {tier.price[frequency.value]?.suffix && ( {tier.price[frequency.value].suffix} )}

+ {tier.price[frequency.value]?.secondaryLabel && ( +

{tier.price[frequency.value].secondaryLabel}

+ )} + {tier.pricingTiers && ( +
+ {tier.pricingTiers[frequency.value].map((row: { seats: string; perUser: string }) => ( +
+ {row.seats} seats + {row.perUser} +
+ ))} +
+ )}
; + /** True when a coupon/promotion is active on the subscription or customer — displayed amounts are pre-discount list prices */ + hasDiscount: boolean; items: StripeUserFacingSubscriptionItem[]; } @@ -38,6 +40,7 @@ export interface StripeUserFacingSubscriptionItem { // currentPeriodEnd: string; product: string; lookupKey: string | null; + /** In dollars — already converted from Stripe's cents */ unitAmount: number; recurringInterval: 'DAY' | 'MONTH' | 'WEEK' | 'YEAR' | null; recurringIntervalCount: number | null;