Files
sean-brydonGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
d52e2d9fad feat: active user billing (#27867)
* factory and statergie

* chore: use correct method of DI

* feat: add onchagne

* add logic to HWM stat

* add webhook resolver methods to each statergy

* move seat tracking + webhooks over to own statergy

* Move to factory base approach

* move logic to correct class

* rename create -> createByTeamId

* fix: remove debug `true ||` overrides from IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED

Remove accidentally committed debug overrides that short-circuited
IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED to always be true,
bypassing Stripe credential checks. This would break self-hosted
instances without Stripe configured.

Identified by cubic (https://cubic.dev)

Co-Authored-By: unknown <>

* feat: active user billing

* add tests

* UI side for users on active billing

* use correct period of stripe sub

* feat: claude feedback

* fix: skip Stripe sync for canceled/expired subscriptions to prevent repeated API calls

Co-Authored-By: unknown <>

* feat: feedback

* feat: only render when active users mode is set

* fix type error

* fix: constants + feature flags

* fix: default to null in tests

* chore: use seats in test

* fix: use node:crypto protocol for Node.js builtin imports

Co-Authored-By: [email protected] <[email protected]>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-12 13:57:32 +00:00

65 lines
2.4 KiB
TypeScript

import logger from "@calcom/lib/logger";
import type { ActiveUserBillingService } from "../../active-user/services/ActiveUserBillingService";
import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository";
import type { IBillingProviderService } from "../billingProvider/IBillingProviderService";
import { BaseSeatBillingStrategy } from "./ISeatBillingStrategy";
import type { SeatChangeContext } from "./ISeatBillingStrategy";
const log = logger.getSubLogger({ prefix: ["ActiveUserBillingStrategy"] });
export interface IActiveUserBillingStrategyDeps {
activeUserBillingService: ActiveUserBillingService;
billingProviderService: IBillingProviderService;
teamBillingDataRepository: ITeamBillingDataRepository;
}
export class ActiveUserBillingStrategy extends BaseSeatBillingStrategy {
constructor(private readonly deps: IActiveUserBillingStrategyDeps) {
super();
}
async onSeatChange(_context: SeatChangeContext): Promise<void> {
// No-op: active user count is determined at billing time, not on seat changes
}
override async onInvoiceUpcoming(subscriptionId: string): Promise<{ applied: boolean }> {
const team = await this.deps.teamBillingDataRepository.findBySubscriptionId(subscriptionId);
if (!team) {
log.warn(`No team found for subscription ${subscriptionId}`);
return { applied: false };
}
const subscription = await this.deps.billingProviderService.getSubscription(subscriptionId);
if (!subscription) {
log.warn(`No subscription found for ${subscriptionId}`);
return { applied: false };
}
const periodStart = new Date(subscription.current_period_start * 1000);
const periodEnd = new Date(subscription.current_period_end * 1000);
const activeUserCount = await this.deps.activeUserBillingService.getActiveUserCountForOrg(
team.id,
periodStart,
periodEnd
);
const subscriptionItem = subscription.items[0];
if (!subscriptionItem) {
log.warn(`No subscription item found for ${subscriptionId}`);
return { applied: false };
}
await this.deps.billingProviderService.handleSubscriptionUpdate({
subscriptionId,
subscriptionItemId: subscriptionItem.id,
membershipCount: activeUserCount,
prorationBehavior: "none",
});
log.info(`Updated subscription ${subscriptionId} to ${activeUserCount} active users for team ${team.id}`);
return { applied: true };
}
}