Skip to content
Closed
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
194 changes: 193 additions & 1 deletion apps/api/src/app/services/__tests__/stripe.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { formatISO, fromUnixTime } from 'date-fns';
import type Stripe from 'stripe';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { convertCustomerWithSubscriptionsToUserFacing } from '../stripe.service';

// stripe.service instantiates a Stripe client and pulls in db/email modules at import — stub the config
// (no STRIPE_API_KEY → the client becomes an empty object) and email so the module loads in isolation.
Expand All @@ -14,7 +16,7 @@ vi.mock('@jetstream/email', () => ({
}));

// The db modules pull in the full data layer (and controllers that instantiate caches at import). Stub
// them — the pure resolver under test never touches them — so the module loads without that graph.
// them — the pure functions under test never touch them — so the module loads without that graph.
vi.mock('../../db/subscription.db', () => ({}));
vi.mock('../../db/team.db', () => ({}));
vi.mock('../../db/user.db', () => ({}));
Expand Down Expand Up @@ -63,3 +65,193 @@ describe('resolveEntitlementAccessFromStripe', () => {
expect(result).toEqual({ ...NONE, chromeExtension: true, googleDrive: true, analysisTools: true });
});
});

// `stripe` v22 ships its types inline per module format, so `import type Stripe from 'stripe'` resolves
// to the ESM declarations here while the service (compiled as CommonJS) sees the CJS ones. The two are
// not assignable to each other, so derive the customer type from the function under test instead.
type StripeCustomer = Parameters<typeof convertCustomerWithSubscriptionsToUserFacing>[0];

const START_DATE = 1_717_200_000;
const BILLING_CYCLE_ANCHOR = 1_718_000_000;
const CURRENT_PERIOD_START = BILLING_CYCLE_ANCHOR;
const CURRENT_PERIOD_END = 1_749_536_000;

function buildCustomer({
balance = 0,
customerDiscount = null as { id: string } | null,
subscriptionOverrides = {},
priceOverrides = {},
quantity = 1 as number | null,
} = {}): StripeCustomer {
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,
discounts: [],
ended_at: null,
start_date: START_DATE,
status: 'active',
items: {
data: [
{
id: 'si_123',
quantity,
current_period_start: CURRENT_PERIOD_START,
current_period_end: CURRENT_PERIOD_END,
price: {
id: 'price_pro_annual',
active: true,
product: 'prod_pro',
lookup_key: 'PRO_ANNUAL',
unit_amount: 25000,
billing_scheme: 'per_unit',
recurring: { interval: 'year', interval_count: 1 },
...priceOverrides,
},
},
],
},
...subscriptionOverrides,
},
],
},
} as unknown as StripeCustomer;
}

/**
* convertCustomerWithSubscriptionsToUserFacing is 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 derives what the customer pays from billingScheme + tiers instead.
* Stripe omits `tiers` from the price embedded in a subscription item, so this pure converter
* passes through whatever is present and getUserFacingStripeCustomer fills the rest in.
* - hasDiscount is true for either discount source: a customer-level discount, or entries in the
* subscription discounts array (which are unexpanded id strings by default). Stripe v22 removed
* the singular subscription-level discount, so it is no longer a source.
*/
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,
currentPeriodStart: formatISO(fromUnixTime(CURRENT_PERIOD_START)),
currentPeriodEnd: formatISO(fromUnixTime(CURRENT_PERIOD_END)),
product: 'prod_pro',
lookupKey: 'PRO_ANNUAL',
unitAmount: 250,
billingScheme: 'per_unit',
tiersMode: null,
tiers: null,
recurringInterval: 'YEAR',
recurringIntervalCount: 1,
quantity: 1,
},
],
},
],
});
});

it('maps tiered prices (no top-level unit_amount) to unitAmount 0 and carries the billing scheme so the client knows to use tiers', () => {
const result = convertCustomerWithSubscriptionsToUserFacing(
buildCustomer({
priceOverrides: {
lookup_key: 'TEAM_MONTHLY',
unit_amount: null,
billing_scheme: 'tiered',
tiers_mode: 'volume',
recurring: { interval: 'month', interval_count: 1 },
},
quantity: 6,
}),
);

expect(result.subscriptions[0].items[0]).toEqual(
expect.objectContaining({
lookupKey: 'TEAM_MONTHLY',
unitAmount: 0,
billingScheme: 'tiered',
tiersMode: 'volume',
tiers: null,
recurringInterval: 'MONTH',
quantity: 6,
}),
);
});

it('converts embedded price tiers from cents to dollars, keeping flat and per-unit amounts distinct', () => {
const result = convertCustomerWithSubscriptionsToUserFacing(
buildCustomer({
priceOverrides: {
unit_amount: null,
billing_scheme: 'tiered',
tiers_mode: 'graduated',
tiers: [
{ up_to: 5, flat_amount: 110000, unit_amount: null },
{ up_to: null, flat_amount: null, unit_amount: 22000 },
],
},
}),
);

expect(result.subscriptions[0].items[0].tiers).toEqual([
{ upTo: 5, flatAmount: 1100, unitAmount: null },
{ upTo: null, flatAmount: null, unitAmount: 220 },
]);
});

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 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);
});
});
74 changes: 63 additions & 11 deletions apps/api/src/app/services/stripe.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,58 @@ async function fetchCustomerWithSubscriptionsById({
});
}

/** Stripe amounts are in cents; every amount leaves this module in dollars. */
function centsToDollars(cents: number | null | undefined): number | null {
return typeof cents === 'number' ? cents / 100 : null;
}

function convertPriceTiers(tiers: Stripe.Price.Tier[] | undefined): JetstreamPriceTier[] | null {
if (!tiers) {
return null;
}
return tiers.map((tier) => ({
flatAmount: centsToDollars(tier.flat_amount),
unitAmount: centsToDollars(tier.unit_amount),
upTo: tier.up_to,
}));
}

const priceTiersCache = new Map<string, { tiers: JetstreamPriceTier[] | null; expiresAt: number }>();
const PRICE_TIERS_CACHE_TTL_MS = 1000 * 60 * 60 * 6;

/**
* Stripe omits `tiers` from the price embedded in a subscription item, and the expansion path through
* the customer is deeper than Stripe allows, so tiered prices are fetched individually. A price's tier
* table cannot change after creation, so a long cache is safe.
*/
async function fetchPriceTiers(priceId: string): Promise<JetstreamPriceTier[] | null> {
const cached = priceTiersCache.get(priceId);
if (cached && cached.expiresAt > Date.now()) {
return cached.tiers;
}
const price = await stripe.prices.retrieve(priceId, { expand: ['tiers'] });
const tiers = convertPriceTiers(price.tiers);
priceTiersCache.set(priceId, { tiers, expiresAt: Date.now() + PRICE_TIERS_CACHE_TTL_MS });
return tiers;
}

/**
* Fill in the tier table for tiered subscription items so the client can show what the customer
* actually pays. A failed lookup leaves `tiers` null rather than failing the whole billing page.
*/
async function attachTiersToTieredItems(customer: StripeUserFacingCustomer): Promise<void> {
const tieredItems = customer.subscriptions.flatMap(({ items }) => items).filter((item) => item.billingScheme === 'tiered' && !item.tiers);
await Promise.all(
tieredItems.map(async (item) => {
try {
item.tiers = await fetchPriceTiers(item.priceId);
} catch (ex) {
logger.warn({ priceId: item.priceId, ...getErrorMessageAndStackObj(ex) }, 'Unable to fetch price tiers for subscription item');
}
}),
);
}

export async function fetchPrices({ lookupKeys }: { lookupKeys: typeof STRIPE_PRICE_KEYS }): Promise<JetstreamPricesByLookupKey> {
const cache = priceCache.get(lookupKeys);
if (cache?.expiresAt && cache.expiresAt > Date.now()) {
Expand Down Expand Up @@ -158,15 +210,7 @@ export async function fetchPrices({ lookupKeys }: { lookupKeys: typeof STRIPE_PR
interval: price.recurring?.interval === 'year' ? 'ANNUAL' : 'MONTHLY',
amount: (price.unit_amount || 0) / 100,
tiersMode: price.tiers_mode || null,
tiers:
price.tiers?.map(
(tier) =>
({
flatAmount: tier.flat_amount ? tier.flat_amount / 100 : null,
unitAmount: tier.unit_amount ? tier.unit_amount / 100 : null,
upTo: tier.up_to,
}) as JetstreamPriceTier,
) || null,
tiers: convertPriceTiers(price.tiers),
product: {
id: product.id,
name: product.name,
Expand Down Expand Up @@ -212,7 +256,9 @@ export async function getUserFacingStripeCustomer({ customerId }: { customerId:
if (stripeCustomer.deleted) {
return null;
}
return convertCustomerWithSubscriptionsToUserFacing(stripeCustomer);
const customer = convertCustomerWithSubscriptionsToUserFacing(stripeCustomer);
await attachTiersToTieredItems(customer);
return customer;
} catch (ex) {
logger.warn({ customerId, ...getErrorMessageAndStackObj(ex) }, 'Unable to fetch or convert customer to user facing');
return null;
Expand All @@ -226,7 +272,7 @@ export function convertCustomerWithSubscriptionsToUserFacing(stripeCustomer: Str
delinquent: !!stripeCustomer.delinquent,
subscriptions:
stripeCustomer.subscriptions?.data.map(
({ id, billing_cycle_anchor, cancel_at, cancel_at_period_end, canceled_at, ended_at, items, start_date, status }) => ({
({ id, billing_cycle_anchor, cancel_at, cancel_at_period_end, canceled_at, discounts, ended_at, items, start_date, status }) => ({
id,
// TODO: validate that the dates are correct (should be if server is on UTC I think?)
billingCycleAnchor: formatISO(fromUnixTime(billing_cycle_anchor)),
Expand All @@ -236,6 +282,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 || discounts.length > 0),
items: items.data.map(({ id, price, quantity, current_period_start, current_period_end }) => ({
id,
priceId: price.id,
Expand All @@ -245,6 +294,9 @@ export function convertCustomerWithSubscriptionsToUserFacing(stripeCustomer: Str
product: price.product as string,
lookupKey: price.lookup_key,
unitAmount: (price.unit_amount || 0) / 100,
billingScheme: price.billing_scheme,
tiersMode: price.tiers_mode || null,
tiers: convertPriceTiers(price.tiers),
recurringInterval: (price.recurring?.interval?.toUpperCase() || null) as StripeUserFacingSubscriptionItem['recurringInterval'],
recurringIntervalCount: price.recurring?.interval_count || null,
quantity: quantity ?? 1,
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/docs/team-management/team-management.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,9 @@ If your team has a past-due invoice, a warning will appear at the top of the Tea

### How many team members can I add?

Our Team plan allows you to add up to 20 team members, while the Enterprise plan supports larger teams with custom limits.
There is no fixed limit on the Team plan. Pricing is per active user, with a lower per-user rate once your team has 6 or more seats.

If you are billed using a purchase order, please contact support to increase your license count. Otherwise your license count will be based on the number of users that are active and will be prorated as you add and remove users.
If you are billed using a purchase order, your license count is set by your agreement, so please contact support to increase it. Otherwise your license count will be based on the number of users that are active and will be prorated as you add and remove users.

Billing role users do not consume a license.

Expand Down
12 changes: 9 additions & 3 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,13 +278,17 @@ 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.
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)
}
value={isAnnual ? PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].key : PLAN_DESCRIPTIONS[TEAM_MONTHLY_KEY].key}
comingSoonFeatures={PLAN_DESCRIPTIONS[TEAM_ANNUAL_KEY].comingSoonFeatures}
onChange={setSelectedPlan}
/>
<EnhancedBillingCard
Expand Down
Loading
Loading