Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions apps/api/src/app/services/__tests__/stripe.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
5 changes: 5 additions & 0 deletions apps/api/src/app/services/stripe.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ export function convertCustomerWithSubscriptionsToUserFacing(stripeCustomer: Str
canceled_at,
// current_period_end,
// current_period_start,
discount,
discounts,
ended_at,
items,
start_date,
Expand All @@ -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<Stripe.Subscription.Status>,
// 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 }) => ({
Comment thread
paustint marked this conversation as resolved.
id,
priceId: price.id,
Expand Down
11 changes: 9 additions & 2 deletions apps/jetstream/src/app/components/billing/Billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Comment thread
paustint marked this conversation as resolved.
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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ANALYTICS_KEYS } from '@jetstream/shared/constants';
import { JetstreamPricesByLookupKey, StripeUserFacingCustomer } from '@jetstream/types';
import { JetstreamPricesByLookupKey, StripeUserFacingCustomer, StripeUserFacingSubscriptionItem } from '@jetstream/types';
Comment thread
paustint marked this conversation as resolved.
import { useAmplitude } from '@jetstream/ui-core';
import { useState } from 'react';
import { EnhancedBillingCard } from './EnhancedBillingCard';
Expand All @@ -18,28 +18,135 @@ 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<string | null>(() => {
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 = () => {
trackEvent(ANALYTICS_KEYS.billing_session, { action: 'enterprise_contact' });
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 ? <span className="slds-text-color_weak"> (before discounts)</span> : null;

return (
<div className="slds-box slds-box_x-small slds-m-bottom_medium slds-text-align_center">
<div className="slds-text-heading_small">
Your current plan: <strong>{planLabel}</strong>
{badge && (
<span
className="slds-badge slds-m-left_x-small"
style={{ backgroundColor: hasManualBilling ? '#0176d3' : '#706e6b', color: 'white' }}
>
{badge}
</span>
)}
</div>
{isTeamSubscription ? (
<p className="slds-text-body_small slds-m-top_x-small">
{activeItem.quantity} {activeItem.quantity === 1 ? 'seat' : 'seats'}
{hasUsablePrice && (
<>
{' '}
× {perSeat}/seat/{interval} ={' '}
<strong>
{total}/{interval}
</strong>
{discountQualifier}
</>
)}
</p>
) : (
hasUsablePrice && (
<p className="slds-text-body_small slds-m-top_x-small">
<strong>
{perSeat}/{interval}
</strong>
{discountQualifier}
</p>
)
)}
{hasManualBilling && (
<p className="slds-text-body_small slds-text-color_weak slds-m-top_x-small">
You have a custom billing arrangement. Contact support for plan changes.
</p>
)}
</div>
);
};

return (
<div>
{renderCurrentPlanSummary()}

{!hasManualBilling && (
<div className="slds-text-align_center slds-m-bottom_medium">
<p className="slds-text-color_weak">Visit the billing portal to make changes to your plan</p>
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down
Loading
Loading