Skip to content
Merged
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
52 changes: 31 additions & 21 deletions .specs/team-enterprise-seat-billing.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,18 @@ supports only USD for seat billing.
monthly, Teams annual, Enterprise monthly, Enterprise annual).
This set is maintained in configuration and MUST be updated
whenever pricing tiers are added or removed.
- **Free-seat line item**: A subscription line item whose price is
not one of the known paid seat prices. These represent
promotional or complimentary seats and are excluded when
determining the resubscribe quantity.
- **Seat metadata**: A subscription line item is considered a seat
line item when the associated subscription's metadata `type`
field equals `seats`, or when the line item's price ID is one of
the known paid seat prices.
- **Seat line item**: A subscription line item whose associated
payment processor price product is the Teams or Enterprise seat
product. Paid seat line items use known paid seat prices.
Free-seat line items use another price under a seat product.
- **Free-seat line item**: A seat line item whose price is not one
of the known paid seat prices. These represent promotional or
complimentary seats and are excluded when determining the
resubscribe quantity.
- **Seat metadata**: Payment processor metadata used to classify
subscriptions or invoices as seat-related. Subscription metadata
`type` field value `seats` identifies seat subscriptions; known
paid seat price IDs can also identify seat invoice lines.
- **Self-service creation flow**: The user-initiated flow for
creating a new organization through the standard web UI, as
opposed to administrative creation (admin panel, scripts, or
Expand Down Expand Up @@ -265,17 +269,14 @@ grants billing access without consuming a seat.
8. The system MUST NOT update the organization's seat count for
subscriptions in non-active statuses (e.g., incomplete, past
due); the purchase record MUST still be created.
9. The system MUST sum quantities across all line items in a
9. The system MUST sum quantities across all seat line items in a
subscription to compute the total seat count (to support
subscriptions with multiple price tiers). Currently, seat
subscriptions contain only seat-type line items (paid and
free); if non-seat line items are introduced in the future,
the counting logic MUST be updated to filter by seat-type
items only.
10. The system MUST record the subscription amount as the gross total
(list-price unit amount times quantity) across all line items.
This value does not reflect discounts, promotion codes, or
coupons.
subscriptions with multiple seat price tiers), and MUST exclude
non-seat line items.
10. The system MUST record the seat subscription amount as the gross
total (list-price unit amount times quantity) across all seat
line items. This value does not reflect discounts, promotion
codes, coupons, or non-seat line items.
11. The system SHOULD record the net amount actually charged (after
discounts) rather than the gross list price. This is SHOULD
rather than MUST because the current payment processor API does
Expand Down Expand Up @@ -520,9 +521,9 @@ grants billing access without consuming a seat.

## Error Handling

1. When a subscription event has no line items or the first line
item lacks a period end date, the system MUST reject the event
with an error.
1. When a subscription event has no seat line items or no seat line
item has a period end date, the system MUST reject the event with
an error.
2. When subscription metadata is missing or has invalid required
fields (type, user ID, organization ID, or non-numeric seat
value), the system MUST reject the event with a validation error.
Expand Down Expand Up @@ -559,6 +560,15 @@ in the current codebase:

## Changelog

### 2026-05-05 -- Mixed subscription line-item filtering

- Updated seat line item definitions and Seat Count Updates rules 9-10
to require filtering subscription events to seat product line items
only, excluding non-seat add-ons from seat counts and recorded seat
subscription amount.
- Updated Error Handling rule 1 to validate seat line item periods
rather than the first unfiltered line item.

### 2026-03-28/29 -- Spec audit and billing compliance

- Added Definitions: non-ended subscription, active subscription,
Expand Down
34 changes: 19 additions & 15 deletions apps/web/src/lib/organizations/organization-seats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
billingCycleToDb,
} from '@/lib/organizations/organization-types';
import { client as stripeClient } from '@/lib/stripe-client';
import { isSeatLineItem } from '@/lib/organizations/stripe-seat-line-items';

const sentryError = sentryLogger('organization_seats', 'error');

Expand Down Expand Up @@ -209,32 +210,35 @@ async function handleSubscriptionEventInternal(
}

const lineItems = subscription.items.data ?? [];
const firstLineItem = lineItems[0];
if (!firstLineItem?.current_period_end) {
throw new Error(`No period end found in invoice line items subscription ${subscription.id}`);
}

// Sum quantities from ALL line items in the subscription.
// Filter to only seat-related line items, excluding non-seat products (KiloPass, KiloClaw, etc.).
// When a subscription has multiple prices for Kilo Teams (e.g., paid seats at one price
// and free seats at another), Stripe stores them as separate line items.
const seatCount = lineItems.reduce((total, item) => total + (item.quantity ?? 0), 0);
const seatLineItems = lineItems.filter(isSeatLineItem);

const firstSeatLineItem = seatLineItems[0];
if (!firstSeatLineItem?.current_period_end) {
throw new Error(`No seat line items with period end found in subscription ${subscription.id}`);
}

const seatCount = seatLineItems.reduce((total, item) => total + (item.quantity ?? 0), 0);

// Calculate total amount from all line items (stripe amounts are in cents)
const amountUsd = lineItems.reduce((total, item) => {
// Calculate total amount from seat line items only (stripe amounts are in cents)
const amountUsd = seatLineItems.reduce((total, item) => {
const itemQuantity = item.quantity ?? 0;
const unitAmount = item.price?.unit_amount ?? 0;
return total + (unitAmount / 100) * itemQuantity;
}, 0);

// use the start & end date of the line item (which is in seconds, not millis)
const startDate = new Date(firstLineItem.current_period_start * 1000);
const endDate = new Date(firstLineItem.current_period_end * 1000);
// Use the billing period from the first seat line item (in seconds, not millis)
const startDate = new Date(firstSeatLineItem.current_period_start * 1000);
const endDate = new Date(firstSeatLineItem.current_period_end * 1000);

// Extract billing cycle from the paid seat item's recurring interval.
// In mixed subscriptions, items[0] can be a free promotional item with a
// different cadence, so we prefer the first item with unit_amount > 0.
const paidLineItem = lineItems.find(item => (item.price?.unit_amount ?? 0) > 0);
const billingCycleItem = paidLineItem ?? firstLineItem;
// In mixed subscriptions, seatLineItems[0] can be a free promotional seat with a
// different cadence, so we prefer the first seat item with unit_amount > 0.
const paidLineItem = seatLineItems.find(item => (item.price?.unit_amount ?? 0) > 0);
const billingCycleItem = paidLineItem ?? firstSeatLineItem;
const stripeInterval = billingCycleItem.price?.recurring?.interval;
let billingCycleDb: 'monthly' | 'yearly';
if (stripeInterval === 'month' || stripeInterval === 'year') {
Expand Down
221 changes: 217 additions & 4 deletions apps/web/src/lib/organizations/organization-subscription-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ function createMockSubscription(overrides: Partial<Stripe.Subscription> = {}): S
metadata: {},
meter: null,
nickname: null,
product: 'prod_test',
product: STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID,
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
Expand All @@ -115,7 +115,7 @@ function createMockSubscription(overrides: Partial<Stripe.Subscription> = {}): S
lookup_key: null,
metadata: {},
nickname: null,
product: 'prod_test',
product: STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID,
recurring: {
interval: 'month',
interval_count: 1,
Expand Down Expand Up @@ -359,7 +359,7 @@ describe('handleSubscriptionEvent', () => {
});

await expect(handleSubscriptionEvent(subscription, 'test-no-items')).rejects.toThrow(
'No period end found in invoice line items'
'No seat line items with period end found'
);
});

Expand All @@ -385,7 +385,7 @@ describe('handleSubscriptionEvent', () => {
});

await expect(handleSubscriptionEvent(subscription, 'test-no-period-end')).rejects.toThrow(
'No period end found in invoice line items'
'No seat line items with period end found'
);
});

Expand Down Expand Up @@ -1287,6 +1287,219 @@ describe('Organization seat count tracking', () => {
});
});

describe('Non-seat product filtering', () => {
let testUser: User;
let testOrganization: Organization;

beforeEach(async () => {
testUser = await insertTestUser();
testOrganization = await createOrganization('Test Organization', testUser.id);
});

test('should only count seat product line items, ignoring non-seat products', async () => {
const base = createMockSubscription();
const baseItem = base.items.data[0];

const subscription = createMockSubscription({
metadata: {
type: 'organization_seats',
kiloUserId: testUser.id,
organizationId: testOrganization.id,
seats: '5',
},
items: {
object: 'list',
data: [
{
...baseItem,
id: 'si_seat_item',
quantity: 5,
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 2592000,
price: {
...baseItem.price,
product: STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID,
unit_amount: 1000, // $10/seat
},
},
{
...baseItem,
id: 'si_non_seat_item',
quantity: 1,
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 2592000,
price: {
...baseItem.price,
id: 'price_kilopass',
product: 'prod_kilopass_not_seats',
unit_amount: 4900, // $49/month non-seat product
},
},
],
has_more: false,
url: '/v1/subscription_items',
},
});

await handleSubscriptionEvent(subscription, 'test-non-seat-filter');

const purchases = await db
.select()
.from(organization_seats_purchases)
.where(eq(organization_seats_purchases.idempotency_key, 'test-non-seat-filter'));

expect(purchases).toHaveLength(1);
// Should only count the 5 seats from the seat product, not the 1 from KiloPass
expect(purchases[0].seat_count).toBe(5);
// Amount should only include seat product: 5 * $10 = $50, not $50 + $49
expect(purchases[0].amount_usd).toBe(50);

// Verify organization seat_count is correct
const org = await db
.select()
.from(organizations)
.where(eq(organizations.id, testOrganization.id))
.then(rows => rows[0]);
expect(org.seat_count).toBe(5);
});

test('should derive billing period from seat line item when non-seat product appears first', async () => {
const base = createMockSubscription();
const baseItem = base.items.data[0];
const nonSeatStart = 1_800_000_000;
const nonSeatEnd = nonSeatStart + 2_592_000;
const seatStart = 1_700_000_000;
const seatEnd = seatStart + 2_592_000;

const subscription = createMockSubscription({
metadata: {
type: 'organization_seats',
kiloUserId: testUser.id,
organizationId: testOrganization.id,
seats: '5',
},
items: {
object: 'list',
data: [
{
...baseItem,
id: 'si_non_seat_first',
quantity: 27,
current_period_start: nonSeatStart,
current_period_end: nonSeatEnd,
price: {
...baseItem.price,
id: 'price_kilopass_first',
product: 'prod_kilopass_not_seats',
unit_amount: 1900,
},
},
{
...baseItem,
id: 'si_seat_second',
quantity: 5,
current_period_start: seatStart,
current_period_end: seatEnd,
price: {
...baseItem.price,
product: STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID,
unit_amount: 1000,
},
},
],
has_more: false,
url: '/v1/subscription_items',
},
});

await handleSubscriptionEvent(subscription, 'test-seat-period-from-filtered-item');

const purchases = await db
.select()
.from(organization_seats_purchases)
.where(
eq(organization_seats_purchases.idempotency_key, 'test-seat-period-from-filtered-item')
);

expect(purchases).toHaveLength(1);
expect(new Date(purchases[0].starts_at).getTime()).toBe(seatStart * 1000);
expect(new Date(purchases[0].expires_at).getTime()).toBe(seatEnd * 1000);
});

test('should sum quantities across multiple seat product line items at different prices', async () => {
const base = createMockSubscription();
const baseItem = base.items.data[0];

const subscription = createMockSubscription({
metadata: {
type: 'organization_seats',
kiloUserId: testUser.id,
organizationId: testOrganization.id,
seats: '8',
},
items: {
object: 'list',
data: [
{
...baseItem,
id: 'si_paid_seats',
quantity: 5,
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 2592000,
price: {
...baseItem.price,
id: 'price_paid_seats',
product: STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID,
unit_amount: 1000, // $10/seat
},
},
{
...baseItem,
id: 'si_free_seats',
quantity: 3,
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 2592000,
price: {
...baseItem.price,
id: 'price_free_seats',
product: STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID,
unit_amount: 0, // free seats
},
},
{
...baseItem,
id: 'si_addon_product',
quantity: 2,
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 2592000,
price: {
...baseItem.price,
id: 'price_addon',
product: 'prod_addon_not_seats',
unit_amount: 2000, // $20/unit non-seat add-on
},
},
],
has_more: false,
url: '/v1/subscription_items',
},
});

await handleSubscriptionEvent(subscription, 'test-multi-price-seats');

const purchases = await db
.select()
.from(organization_seats_purchases)
.where(eq(organization_seats_purchases.idempotency_key, 'test-multi-price-seats'));

expect(purchases).toHaveLength(1);
// Should sum 5 + 3 = 8 seats (both seat products), excluding the 2 add-on units
expect(purchases[0].seat_count).toBe(8);
// Amount: (5 * $10) + (3 * $0) = $50, not including (2 * $20) = $40 from add-on
expect(purchases[0].amount_usd).toBe(50);
});
});

describe('billing cycle tracking', () => {
let testUser: User;
let testOrganization: Organization;
Expand Down
Loading