diff --git a/.specs/team-enterprise-seat-billing.md b/.specs/team-enterprise-seat-billing.md index 1f5f73011..7d957912d 100644 --- a/.specs/team-enterprise-seat-billing.md +++ b/.specs/team-enterprise-seat-billing.md @@ -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 @@ -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 @@ -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. @@ -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, diff --git a/apps/web/src/lib/organizations/organization-seats.ts b/apps/web/src/lib/organizations/organization-seats.ts index f7dbff330..fec9045b4 100644 --- a/apps/web/src/lib/organizations/organization-seats.ts +++ b/apps/web/src/lib/organizations/organization-seats.ts @@ -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'); @@ -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') { diff --git a/apps/web/src/lib/organizations/organization-subscription-event.test.ts b/apps/web/src/lib/organizations/organization-subscription-event.test.ts index f3a887796..45e0a3665 100644 --- a/apps/web/src/lib/organizations/organization-subscription-event.test.ts +++ b/apps/web/src/lib/organizations/organization-subscription-event.test.ts @@ -97,7 +97,7 @@ function createMockSubscription(overrides: Partial = {}): 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, @@ -115,7 +115,7 @@ function createMockSubscription(overrides: Partial = {}): S lookup_key: null, metadata: {}, nickname: null, - product: 'prod_test', + product: STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID, recurring: { interval: 'month', interval_count: 1, @@ -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' ); }); @@ -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' ); }); @@ -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; diff --git a/apps/web/src/lib/organizations/stripe-seat-line-items.ts b/apps/web/src/lib/organizations/stripe-seat-line-items.ts new file mode 100644 index 000000000..5fde382b4 --- /dev/null +++ b/apps/web/src/lib/organizations/stripe-seat-line-items.ts @@ -0,0 +1,16 @@ +import type Stripe from 'stripe'; +import { + STRIPE_ENTERPRISE_SUBSCRIPTION_PRODUCT_ID, + STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID, +} from '@/lib/config.server'; + +export const SEAT_PRODUCT_IDS = new Set( + [STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID, STRIPE_ENTERPRISE_SUBSCRIPTION_PRODUCT_ID].filter( + (productId): productId is string => productId != null && productId.trim() !== '' + ) +); + +export function isSeatLineItem(item: Stripe.SubscriptionItem): boolean { + const productId = item.price.product; + return typeof productId === 'string' && SEAT_PRODUCT_IDS.has(productId); +} diff --git a/apps/web/src/lib/stripe-3ds.test.ts b/apps/web/src/lib/stripe-3ds.test.ts index fb097a280..52f07a712 100644 --- a/apps/web/src/lib/stripe-3ds.test.ts +++ b/apps/web/src/lib/stripe-3ds.test.ts @@ -21,6 +21,7 @@ jest.mock('@/lib/organizations/organization-seats', () => ({ import { handleUpdateSeatCount, KNOWN_SEAT_PRICE_IDS } from './stripe'; import { client } from '@/lib/stripe-client'; +import { STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID } from '@/lib/config.server'; // Get references to the mocked functions after import const mockSubscriptionsRetrieve = client.subscriptions.retrieve as jest.Mock; @@ -198,6 +199,51 @@ describe('handleUpdateSeatCount with 3DS', () => { ); }); + it('ignores non-seat add-ons when preserving free seat items', async () => { + const mockSubscription = { + id: mockSubscriptionId, + items: { + data: [ + { + id: mockItemId, + quantity: 5, + price: { id: 'price_test_seat', product: STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID }, + }, + { + id: 'si_free_seats', + quantity: 2, + price: { + id: 'price_free_seats', + product: STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID, + }, + }, + { + id: 'si_kilo_pass', + quantity: 27, + price: { id: 'price_kilo_pass', product: 'prod_kilo_pass_not_seats' }, + }, + ], + }, + latest_invoice: 'inv_test_789', + }; + + mockSubscriptionsRetrieve.mockResolvedValue(mockSubscription); + mockSubscriptionsUpdate.mockResolvedValue({ + ...mockSubscription, + items: { data: [{ id: mockItemId, quantity: 6, price: { id: 'price_test_seat' } }] }, + }); + + await handleUpdateSeatCount(mockSubscriptionId, 8, 7); + + expect(mockSubscriptionsUpdate).toHaveBeenCalledWith( + mockSubscriptionId, + expect.objectContaining({ + items: [{ id: mockItemId, quantity: 6 }], + }), + expect.any(Object) + ); + }); + it('throws when subscription has no items', async () => { const mockSubscription = { id: mockSubscriptionId, diff --git a/apps/web/src/lib/stripe.ts b/apps/web/src/lib/stripe.ts index 4152478ce..512d6744b 100644 --- a/apps/web/src/lib/stripe.ts +++ b/apps/web/src/lib/stripe.ts @@ -63,6 +63,7 @@ import { STRIPE_ENTERPRISE_ANNUAL_PRICE_ID, } from '@/lib/config.server'; import type { OrganizationPlan, BillingCycle } from '@/lib/organizations/organization-types'; +import { isSeatLineItem } from '@/lib/organizations/stripe-seat-line-items'; import { successResult } from '@/lib/maybe-result'; async function isKiloClawCharge(chargeId: string): Promise { @@ -1382,9 +1383,10 @@ export async function handleUpdateSeatCount( throw new Error(`No recognized paid seat item found in subscription ${subscriptionStripeId}`); } - // Calculate the free seat count from non-paid items to preserve them + // Calculate the free seat count from non-paid seat-product items to preserve them. + // Non-seat add-ons can share the subscription but must not reduce the paid seat quantity. const freeSeatCount = subscription.items.data - .filter(item => !KNOWN_SEAT_PRICE_IDS.has(item.price.id)) + .filter(item => isSeatLineItem(item) && !KNOWN_SEAT_PRICE_IDS.has(item.price.id)) .reduce((total, item) => total + (item.quantity ?? 0), 0); // The requested newSeatCount is the desired total. Deduct free seats to get paid quantity.