From 074054512e42073062ab74c1ee1b7ba6051168bb Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 21:24:03 +0000 Subject: [PATCH 1/3] fix(billing): restore seat-filter regression overwritten by PR #1609 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-seat products (Kilo Pass, KiloClaw add-ons, etc.) are currently summed into the organization seat count because PR #1609 was squash-merged from a branch based on pre-fix code, silently reverting commit 55b8424d8 and its follow-up 5d21daaec. This causes double-counting for organizations with mixed product subscriptions — e.g., FSKAB (060090ad-e0ec-4612-a2d2-935b1c74a60f) shows 54 seats (27 seats + 27 Kilo Pass line items) when it should show 27. Re-apply the product-ID-based filter in handleSubscriptionEventInternal: only subscription items whose price.product matches the Teams or Enterprise seat product contribute to seat_count and amount_usd. Derive the billing period (starts_at / expires_at) from the first filtered seat line item so a non-seat add-on appearing first in the list cannot corrupt the period. Also restore the ~154 lines of tests that were removed, updating the base mock subscription to use STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID so existing tests continue to exercise the seat path, and update the two no-line-item error assertions to match the new, more specific error message. --- .../lib/organizations/organization-seats.ts | 49 ++++-- .../organization-subscription-event.test.ts | 158 +++++++++++++++++- 2 files changed, 187 insertions(+), 20 deletions(-) diff --git a/apps/web/src/lib/organizations/organization-seats.ts b/apps/web/src/lib/organizations/organization-seats.ts index f7dbff3301..11c96f7db6 100644 --- a/apps/web/src/lib/organizations/organization-seats.ts +++ b/apps/web/src/lib/organizations/organization-seats.ts @@ -19,7 +19,11 @@ import PostHogClient from '@/lib/posthog'; import { findUserById } from '@/lib/user'; import { after } from 'next/server'; import { sendOrgCancelledEmail, sendOrgRenewedEmail, sendOrgSubscriptionEmail } from '@/lib/email'; -import { IS_IN_AUTOMATED_TEST } from '@/lib/config.server'; +import { + IS_IN_AUTOMATED_TEST, + STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID, + STRIPE_ENTERPRISE_SUBSCRIPTION_PRODUCT_ID, +} from '@/lib/config.server'; import type { OrganizationPlan } from '@/lib/organizations/organization-types'; import { OrganizationPlanSchema, @@ -30,6 +34,16 @@ import { client as stripeClient } from '@/lib/stripe-client'; const sentryError = sentryLogger('organization_seats', 'error'); +const SEAT_PRODUCT_IDS = new Set( + [STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID, STRIPE_ENTERPRISE_SUBSCRIPTION_PRODUCT_ID].filter(Boolean) +); + +function isSeatLineItem(item: Stripe.SubscriptionItem): boolean { + const productId = item.price?.product; + if (typeof productId !== 'string') return false; + return SEAT_PRODUCT_IDS.has(productId); +} + const SubscriptionMetadataSchema = z.object({ type: z.string(), kiloUserId: z.string(), @@ -209,32 +223,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 f3a8877964..031d840a77 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,156 @@ 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 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; From b34ccf0d1a1fd2289d073247381494732e4ffc4e Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 19:20:51 +0200 Subject: [PATCH 2/3] fix(billing): harden mixed seat subscription handling --- .specs/team-enterprise-seat-billing.md | 52 ++++++++------- .../organization-subscription-event.test.ts | 63 +++++++++++++++++++ apps/web/src/lib/stripe-3ds.test.ts | 46 ++++++++++++++ apps/web/src/lib/stripe.ts | 18 +++++- 4 files changed, 156 insertions(+), 23 deletions(-) diff --git a/.specs/team-enterprise-seat-billing.md b/.specs/team-enterprise-seat-billing.md index 1f5f73011f..7d957912d7 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-subscription-event.test.ts b/apps/web/src/lib/organizations/organization-subscription-event.test.ts index 031d840a77..45e0a36653 100644 --- a/apps/web/src/lib/organizations/organization-subscription-event.test.ts +++ b/apps/web/src/lib/organizations/organization-subscription-event.test.ts @@ -1363,6 +1363,69 @@ describe('Non-seat product filtering', () => { 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]; diff --git a/apps/web/src/lib/stripe-3ds.test.ts b/apps/web/src/lib/stripe-3ds.test.ts index fb097a2804..52f07a712c 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 4152478ce5..7e3d62482a 100644 --- a/apps/web/src/lib/stripe.ts +++ b/apps/web/src/lib/stripe.ts @@ -61,6 +61,8 @@ import { STRIPE_TEAMS_ANNUAL_PRICE_ID, STRIPE_ENTERPRISE_MONTHLY_PRICE_ID, STRIPE_ENTERPRISE_ANNUAL_PRICE_ID, + STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID, + STRIPE_ENTERPRISE_SUBSCRIPTION_PRODUCT_ID, } from '@/lib/config.server'; import type { OrganizationPlan, BillingCycle } from '@/lib/organizations/organization-types'; import { successResult } from '@/lib/maybe-result'; @@ -1187,6 +1189,17 @@ export const KNOWN_SEAT_PRICE_IDS = new Set([ STRIPE_ENTERPRISE_ANNUAL_PRICE_ID, ]); +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() !== '' + ) +); + +function isSeatProductLineItem(item: Stripe.SubscriptionItem): boolean { + const productId = item.price.product; + return typeof productId === 'string' && SEAT_PRODUCT_IDS.has(productId); +} + /** Derive the plan tier from a Stripe price ID. Returns null for unknown prices. */ export function getPlanForPriceId(priceId: string): OrganizationPlan | null { if (priceId === STRIPE_TEAMS_MONTHLY_PRICE_ID || priceId === STRIPE_TEAMS_ANNUAL_PRICE_ID) { @@ -1382,9 +1395,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 => isSeatProductLineItem(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. From b52a9f7efe13c83ed54bba92345e29f690fa357d Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 19:36:20 +0200 Subject: [PATCH 3/3] refactor(billing): share Stripe seat line item helper --- .../src/lib/organizations/organization-seats.ts | 17 ++--------------- .../lib/organizations/stripe-seat-line-items.ts | 16 ++++++++++++++++ apps/web/src/lib/stripe.ts | 16 ++-------------- 3 files changed, 20 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/lib/organizations/stripe-seat-line-items.ts diff --git a/apps/web/src/lib/organizations/organization-seats.ts b/apps/web/src/lib/organizations/organization-seats.ts index 11c96f7db6..fec9045b46 100644 --- a/apps/web/src/lib/organizations/organization-seats.ts +++ b/apps/web/src/lib/organizations/organization-seats.ts @@ -19,11 +19,7 @@ import PostHogClient from '@/lib/posthog'; import { findUserById } from '@/lib/user'; import { after } from 'next/server'; import { sendOrgCancelledEmail, sendOrgRenewedEmail, sendOrgSubscriptionEmail } from '@/lib/email'; -import { - IS_IN_AUTOMATED_TEST, - STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID, - STRIPE_ENTERPRISE_SUBSCRIPTION_PRODUCT_ID, -} from '@/lib/config.server'; +import { IS_IN_AUTOMATED_TEST } from '@/lib/config.server'; import type { OrganizationPlan } from '@/lib/organizations/organization-types'; import { OrganizationPlanSchema, @@ -31,19 +27,10 @@ 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'); -const SEAT_PRODUCT_IDS = new Set( - [STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID, STRIPE_ENTERPRISE_SUBSCRIPTION_PRODUCT_ID].filter(Boolean) -); - -function isSeatLineItem(item: Stripe.SubscriptionItem): boolean { - const productId = item.price?.product; - if (typeof productId !== 'string') return false; - return SEAT_PRODUCT_IDS.has(productId); -} - const SubscriptionMetadataSchema = z.object({ type: z.string(), kiloUserId: z.string(), 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 0000000000..5fde382b48 --- /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.ts b/apps/web/src/lib/stripe.ts index 7e3d62482a..512d6744bb 100644 --- a/apps/web/src/lib/stripe.ts +++ b/apps/web/src/lib/stripe.ts @@ -61,10 +61,9 @@ import { STRIPE_TEAMS_ANNUAL_PRICE_ID, STRIPE_ENTERPRISE_MONTHLY_PRICE_ID, STRIPE_ENTERPRISE_ANNUAL_PRICE_ID, - STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID, - STRIPE_ENTERPRISE_SUBSCRIPTION_PRODUCT_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 { @@ -1189,17 +1188,6 @@ export const KNOWN_SEAT_PRICE_IDS = new Set([ STRIPE_ENTERPRISE_ANNUAL_PRICE_ID, ]); -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() !== '' - ) -); - -function isSeatProductLineItem(item: Stripe.SubscriptionItem): boolean { - const productId = item.price.product; - return typeof productId === 'string' && SEAT_PRODUCT_IDS.has(productId); -} - /** Derive the plan tier from a Stripe price ID. Returns null for unknown prices. */ export function getPlanForPriceId(priceId: string): OrganizationPlan | null { if (priceId === STRIPE_TEAMS_MONTHLY_PRICE_ID || priceId === STRIPE_TEAMS_ANNUAL_PRICE_ID) { @@ -1398,7 +1386,7 @@ export async function handleUpdateSeatCount( // 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 => isSeatProductLineItem(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.