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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
STRIPE_TEAM_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID=
ORG_MONTHLY_CREDITS=
STRIPE_TEAM_PRODUCT_ID=
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
STRIPE_ORG_MONTHLY_PRICE_ID=
Expand Down
30 changes: 23 additions & 7 deletions packages/features/ee/billing/credit-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,8 +435,8 @@ describe("CreditService", () => {
expect(result).toBe(1500); // (3 members * 1000 price) / 2
});

it("should calculate credits with 20% multiplier for organizations", async () => {
vi.stubEnv("STRIPE_ORG_MONTHLY_PRICE_ID", "price_org_monthly");
it("should calculate credits for organizations using ORG_MONTHLY_CREDITS", async () => {
vi.stubEnv("ORG_MONTHLY_CREDITS", "1500");
const mockTeamRepo = {
findTeamWithMembers: vi.fn().mockResolvedValue({
id: 1,
Expand All @@ -453,15 +453,31 @@ describe("CreditService", () => {
mockTeamBillingService.getSubscriptionStatus
);

const mockStripeBillingService = {
getPrice: vi.fn().mockResolvedValue({ unit_amount: 3700 }),
const result = await creditService.getMonthlyCredits(1);
expect(result).toBe(3000); // 2 members * 1500 credits per seat
});

it("should calculate credits for organizations with default 1000 credits per seat", async () => {
// Clear ORG_MONTHLY_CREDITS to test default behavior
vi.stubEnv("ORG_MONTHLY_CREDITS", undefined);
const mockTeamRepo = {
findTeamWithMembers: vi.fn().mockResolvedValue({
id: 1,
isOrganization: true,
members: [{ accepted: true }, { accepted: true }, { accepted: true }],
}),
};
vi.spyOn(StripeBillingService.prototype, "getPrice").mockImplementation(
mockStripeBillingService.getPrice
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any);

const mockTeamBillingService = {
getSubscriptionStatus: vi.fn().mockResolvedValue("active"),
};
vi.spyOn(InternalTeamBilling.prototype, "getSubscriptionStatus").mockImplementation(
mockTeamBillingService.getSubscriptionStatus
);

const result = await creditService.getMonthlyCredits(1);
expect(result).toBe(1480); // (2 members * 3700 price) * 0.2
expect(result).toBe(3000); // 3 members * 1000 credits per seat (default)
});
Comment on lines +460 to 481
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix env cleanup: use vi.unstubEnv instead of passing undefined

vi.stubEnv expects a string; passing undefined can throw or leak prior value. Unset the var explicitly.

 it("should calculate credits for organizations with default 1000 credits per seat", async () => {
   // Clear ORG_MONTHLY_CREDITS to test default behavior
-  vi.stubEnv("ORG_MONTHLY_CREDITS", undefined);
+  vi.unstubEnv("ORG_MONTHLY_CREDITS");

Consider also asserting Stripe is not called here, mirroring the previous test.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("should calculate credits for organizations with default 1000 credits per seat", async () => {
// Clear ORG_MONTHLY_CREDITS to test default behavior
vi.stubEnv("ORG_MONTHLY_CREDITS", undefined);
const mockTeamRepo = {
findTeamWithMembers: vi.fn().mockResolvedValue({
id: 1,
isOrganization: true,
members: [{ accepted: true }, { accepted: true }, { accepted: true }],
}),
};
vi.spyOn(StripeBillingService.prototype, "getPrice").mockImplementation(
mockStripeBillingService.getPrice
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any);
const mockTeamBillingService = {
getSubscriptionStatus: vi.fn().mockResolvedValue("active"),
};
vi.spyOn(InternalTeamBilling.prototype, "getSubscriptionStatus").mockImplementation(
mockTeamBillingService.getSubscriptionStatus
);
const result = await creditService.getMonthlyCredits(1);
expect(result).toBe(1480); // (2 members * 3700 price) * 0.2
expect(result).toBe(3000); // 3 members * 1000 credits per seat (default)
});
it("should calculate credits for organizations with default 1000 credits per seat", async () => {
// Clear ORG_MONTHLY_CREDITS to test default behavior
vi.unstubEnv("ORG_MONTHLY_CREDITS");
const mockTeamRepo = {
findTeamWithMembers: vi.fn().mockResolvedValue({
id: 1,
isOrganization: true,
members: [{ accepted: true }, { accepted: true }, { accepted: true }],
}),
};
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any);
const mockTeamBillingService = {
getSubscriptionStatus: vi.fn().mockResolvedValue("active"),
};
vi.spyOn(InternalTeamBilling.prototype, "getSubscriptionStatus").mockImplementation(
mockTeamBillingService.getSubscriptionStatus
);
const result = await creditService.getMonthlyCredits(1);
expect(result).toBe(3000); // 3 members * 1000 credits per seat (default)
});
🤖 Prompt for AI Agents
In packages/features/ee/billing/credit-service.test.ts around lines 460 to 481,
replace the incorrect vi.stubEnv("ORG_MONTHLY_CREDITS", undefined) with
vi.unstubEnv("ORG_MONTHLY_CREDITS") to properly remove the env var (stubEnv
expects a string and passing undefined can leak prior values); additionally,
mirror the previous test by asserting the Stripe client (or the mocked Stripe
call used in other tests) was not invoked in this case to ensure no external
calls occur when using defaults.

});

Expand Down
24 changes: 11 additions & 13 deletions packages/features/ee/billing/credit-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,8 +588,6 @@ export class CreditService {

if (!team) return 0;

let totalMonthlyCredits = 0;

const teamBillingService = new InternalTeamBilling(team);
const subscriptionStatus = await teamBillingService.getSubscriptionStatus();

Expand All @@ -599,25 +597,25 @@ export class CreditService {

const activeMembers = team.members.filter((member) => member.accepted).length;

const billingService = new StripeBillingService();
if (team.isOrganization) {
const orgMonthlyCredits = process.env.ORG_MONTHLY_CREDITS;
const creditsPerSeat = orgMonthlyCredits ? parseInt(orgMonthlyCredits) : 1000;
return activeMembers * creditsPerSeat;
}
Comment on lines +600 to +604
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Robust parsing of ORG_MONTHLY_CREDITS (0 treated as falsy today).

Current code ignores an explicit 0 and lacks NaN/range handling; also no radix. Parse safely, accept 0, clamp to >= 0.

-  const orgMonthlyCredits = process.env.ORG_MONTHLY_CREDITS;
-  const creditsPerSeat = orgMonthlyCredits ? parseInt(orgMonthlyCredits) : 1000;
+  const rawOrgMonthlyCredits = process.env.ORG_MONTHLY_CREDITS;
+  const parsedOrgMonthlyCredits =
+    rawOrgMonthlyCredits != null ? Number(rawOrgMonthlyCredits) : NaN;
+  const creditsPerSeat = Number.isFinite(parsedOrgMonthlyCredits)
+    ? Math.max(0, Math.floor(parsedOrgMonthlyCredits))
+    : 1000;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (team.isOrganization) {
const orgMonthlyCredits = process.env.ORG_MONTHLY_CREDITS;
const creditsPerSeat = orgMonthlyCredits ? parseInt(orgMonthlyCredits) : 1000;
return activeMembers * creditsPerSeat;
}
if (team.isOrganization) {
const rawOrgMonthlyCredits = process.env.ORG_MONTHLY_CREDITS;
const parsedOrgMonthlyCredits =
rawOrgMonthlyCredits != null ? Number(rawOrgMonthlyCredits) : NaN;
const creditsPerSeat = Number.isFinite(parsedOrgMonthlyCredits)
? Math.max(0, Math.floor(parsedOrgMonthlyCredits))
: 1000;
return activeMembers * creditsPerSeat;
}
🤖 Prompt for AI Agents
In packages/features/ee/billing/credit-service.ts around lines 600 to 604, the
environment value ORG_MONTHLY_CREDITS is currently treated as falsy (so "0" is
ignored) and lacks NaN/radix/range handling; change parsing to use
parseInt(orgMonthlyCredits, 10), then test the result for NaN and clamp it with
Math.max(0, parsed) so 0 is accepted but negative numbers become 0, and fall
back to the default (1000) only when parsing yields NaN or an invalid value; use
the clamped value as creditsPerSeat and return activeMembers * creditsPerSeat.


const priceId = team.isOrganization
? process.env.STRIPE_ORG_MONTHLY_PRICE_ID
: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID;
const billingService = new StripeBillingService();
const priceId = process.env.STRIPE_TEAM_MONTHLY_PRICE_ID;

if (!priceId) {
log.warn("Monthly price ID not configured", { teamId, isOrganization: team.isOrganization });
log.warn("Monthly price ID not configured", { teamId });
return 0;
}

const monthlyPrice = await billingService.getPrice(priceId || "");
const monthlyPrice = await billingService.getPrice(priceId);
const pricePerSeat = monthlyPrice.unit_amount ?? 0;
const creditsPerSeat = pricePerSeat * 0.5;

// Teams get 50% of the price as credits, organizations get 20%
const creditMultiplier = team.isOrganization ? 0.2 : 0.5;
totalMonthlyCredits = activeMembers * pricePerSeat * creditMultiplier;

return totalMonthlyCredits;
return activeMembers * creditsPerSeat;
}

calculateCreditsFromPrice(price: number) {
Expand Down
3 changes: 3 additions & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
"NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY",
"NEXT_PUBLIC_STRIPE_PRICING_TABLE_PUBLISHABLE_KEY",
"NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID",
"ORG_MONTHLY_CREDITS",
Copy link
Contributor

Choose a reason for hiding this comment

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

NIT: turbo json has env variables in alphabetical order

Copy link
Member Author

Choose a reason for hiding this comment

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

doesn't really look sorted to me 🤔

"NEXT_PUBLIC_PLAIN_CHAT_ID",
"NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS",
"NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD",
Expand Down Expand Up @@ -187,6 +188,7 @@
"STRIPE_TEAM_MONTHLY_PRICE_ID",
"STRIPE_TEAM_PRODUCT_ID",
"STRIPE_ORG_MONTHLY_PRICE_ID",
"ORG_MONTHLY_CREDITS",
"TANDEM_BASE_URL",
"TANDEM_CLIENT_ID",
"TANDEM_CLIENT_SECRET",
Expand Down Expand Up @@ -331,6 +333,7 @@
"STRIPE_TEAM_MONTHLY_PRICE_ID",
"NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID",
"STRIPE_TEAM_PRODUCT_ID",
"ORG_MONTHLY_CREDITS",
"STRIPE_ORG_MONTHLY_PRICE_ID",
"STRIPE_ORG_PRODUCT_ID",
"NEXT_PUBLIC_API_V2_URL",
Expand Down
Loading