Skip to content

fix(billing): filter non-seat products from seat count calculation#1060

Open
kilo-code-bot[bot] wants to merge 2 commits intomainfrom
fix/seat-count-exclude-non-seat-products
Open

fix(billing): filter non-seat products from seat count calculation#1060
kilo-code-bot[bot] wants to merge 2 commits intomainfrom
fix/seat-count-exclude-non-seat-products

Conversation

@kilo-code-bot
Copy link
Contributor

@kilo-code-bot kilo-code-bot bot commented Mar 12, 2026

Summary

  • Seat counting in handleSubscriptionEventInternal was summing quantities from ALL Stripe subscription line items, including non-seat products (KiloPass, KiloClaw, add-ons). This inflated seat counts when subscriptions contained mixed product types.
  • Added isSeatLineItem filter that checks each line item's price.product against the known seat product IDs (STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID and STRIPE_ENTERPRISE_SUBSCRIPTION_PRODUCT_ID). Both seatCount and amountUsd now only aggregate from seat-related line items.
  • Updated mock subscription in tests to use the real teams product ID, and added two new tests covering mixed seat/non-seat subscriptions and multi-price seat subscriptions with add-ons.

Verification

  • pnpm typecheck — passes with no errors
  • Existing tests verified to not regress (test failures are pre-existing DB connection issues in sandbox, identical before and after change)
  • Manual review of all callers of handleSubscriptionEvent and handleSubscriptionEventInternal to confirm no downstream impact

Visual Changes

N/A

Reviewer Notes

  • The SEAT_PRODUCT_IDS set is built at module load time from env vars, with .filter(Boolean) to handle cases where an env var might be empty.
  • isSeatLineItem checks typeof productId !== 'string' to handle the case where Stripe returns an expanded Product object rather than a string ID — in that case, the item is conservatively excluded. In practice, subscription items always have string product IDs unless explicitly expanded.
  • The firstLineItem (used for period start/end dates) still uses the first item from ALL line items, not just seat items. This is intentional — all items in a subscription share the same billing period.

Seat counting was summing quantities from ALL subscription line items,
including non-seat products like KiloPass and KiloClaw. This caused
inflated seat counts when subscriptions contained mixed product types.

Filter line items by product ID (teams/enterprise) before summing
quantities and amounts so only seat products contribute to the count.
// 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);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Billing period still comes from the unfiltered line items

After filtering out non-seat products here, starts_at and expires_at are still derived from lineItems[0] below. If a non-seat add-on is listed first, the purchase record can be written with the wrong period, which then breaks the later max starts_at logic and can apply an outdated seat count. Read the billing period from the filtered seat line items instead.

@kilo-code-bot
Copy link
Contributor Author

kilo-code-bot bot commented Mar 12, 2026

Code Review Summary

Status: 1 Issue Found | Recommendation: Address before merge

Fix these issues in Kilo Cloud

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

No new inline-commentable issues.

Other Observations (not in diff)

Issues found in unchanged code that cannot receive inline comments:

File Line Issue
src/lib/organizations/organization-subscription-event.test.ts 423 Error assertion still expects "No period end found in invoice line items", but handleSubscriptionEvent now throws "No seat line items with period end found in subscription ..."; this assertion and the matching one at line 443 will fail until updated.
Files Reviewed (2 files)
  • src/lib/organizations/organization-seats.ts - 0 issues
  • src/lib/organizations/organization-subscription-event.test.ts - 1 issue

Reviewed by gpt-5.4-20260305 · 369,158 tokens

…red items

The billing period (starts_at/expires_at) was still derived from
lineItems[0] after seat filtering was introduced. If a non-seat add-on
appeared first in the list, the purchase record could be written with
the wrong period, breaking the max-starts_at seat-count selection logic.

Now reads current_period_start/end from the first filtered seat line
item instead.
@kilo-code-bot
Copy link
Contributor Author

kilo-code-bot bot commented Mar 13, 2026

Review follow-up

Product ID question (item 1)

Confirmed: comparing against product IDs is correct. The Stripe API returns price.product as a string product ID (e.g., "prod_NcLYGKH0eY5b8s") on SubscriptionItem objects by default. It is only expanded into a full Product object if the caller explicitly uses expand[]. Since this codebase does not expand the product field, item.price.product will always be a string product ID, and the isSeatLineItem check (typeof productId !== 'string') is a correct defensive guard for the expanded-object edge case.

Reference: Stripe SubscriptionItem API docsprice.product is documented as string, expandable.

Billing period fix (item 2)

Addressed the review comment: starts_at/expires_at were still derived from lineItems[0] (unfiltered), meaning a non-seat add-on listed first could write incorrect billing period dates into the purchase record. This would break the downstream max starts_at logic that determines the current seat count.

Fix (5d21daa): Moved the firstLineItem reference to use seatLineItems[0] instead, so the billing period is always read from a seat product line item. Updated the error message accordingly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants