1211 lines
39 KiB
TypeScript
1211 lines
39 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
|
import dayjs from "@calcom/dayjs";
|
|
import * as EmailManager from "@calcom/emails/billing-email-service";
|
|
import { CreditsRepository } from "@calcom/features/credits/repositories/CreditsRepository";
|
|
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
|
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
|
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
|
import { CreditType } from "@calcom/prisma/enums";
|
|
|
|
import { CreditService } from "./credit-service";
|
|
import { SubscriptionStatus } from "./repository/billing/IBillingRepository";
|
|
|
|
const MOCK_TX = {
|
|
team: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
findFirst: vi.fn().mockResolvedValue(null),
|
|
},
|
|
};
|
|
|
|
vi.mock("@calcom/prisma", async (importOriginal) => {
|
|
const actual = await importOriginal();
|
|
return {
|
|
...actual,
|
|
default: {
|
|
$transaction: vi.fn((fn) => fn(MOCK_TX)),
|
|
},
|
|
prisma: {
|
|
$transaction: vi.fn((fn) => fn(MOCK_TX)),
|
|
},
|
|
};
|
|
});
|
|
|
|
const mockStripe = vi.hoisted(() => ({
|
|
prices: {
|
|
list: vi.fn().mockResolvedValue({ data: [] }),
|
|
retrieve: vi.fn().mockResolvedValue({ id: "price_123", unit_amount: 1000 }),
|
|
},
|
|
customers: {
|
|
create: vi.fn().mockResolvedValue({ id: "cus_123" }),
|
|
},
|
|
subscriptions: {
|
|
cancel: vi.fn(),
|
|
retrieve: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
checkout: {
|
|
sessions: {
|
|
retrieve: vi.fn(),
|
|
},
|
|
},
|
|
paymentIntents: {
|
|
create: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@calcom/features/ee/payments/server/stripe", () => ({
|
|
default: mockStripe,
|
|
}));
|
|
|
|
vi.mock("@calcom/i18n/server", () => {
|
|
return {
|
|
getTranslation: async (locale: string, namespace: string) => {
|
|
const t = (key: string) => key;
|
|
t.locale = locale;
|
|
t.namespace = namespace;
|
|
return t;
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("@calcom/lib/constants", async () => {
|
|
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
|
|
return {
|
|
...actual,
|
|
IS_SMS_CREDITS_ENABLED: true,
|
|
};
|
|
});
|
|
|
|
vi.mock("@calcom/prisma/enums", async (importOriginal) => {
|
|
const actual = await importOriginal();
|
|
return {
|
|
...actual,
|
|
CreditType: {
|
|
MONTHLY: "MONTHLY",
|
|
ADDITIONAL: "ADDITIONAL",
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("@calcom/lib/server/repository/credits");
|
|
vi.mock("@calcom/features/membership/repositories/MembershipRepository");
|
|
vi.mock("@calcom/features/ee/teams/repositories/TeamRepository");
|
|
vi.mock("@calcom/features/credits/repositories/CreditsRepository");
|
|
vi.mock("@calcom/emails/billing-email-service", () => ({
|
|
sendCreditBalanceLimitReachedEmails: vi.fn().mockResolvedValue(undefined),
|
|
sendCreditBalanceLowWarningEmails: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
vi.mock("../workflows/lib/reminders/reminderScheduler", () => ({
|
|
cancelScheduledMessagesAndScheduleEmails: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({
|
|
default: vi.fn().mockResolvedValue(null),
|
|
}));
|
|
|
|
vi.mock("@calcom/ee/billing/di/containers/Billing", () => ({
|
|
getBillingProviderService: vi.fn(),
|
|
getTeamBillingServiceFactory: vi.fn(),
|
|
getTeamBillingDataRepository: vi.fn(),
|
|
}));
|
|
|
|
const creditService = new CreditService();
|
|
|
|
vi.spyOn(creditService, "_getAllCreditsForTeam").mockResolvedValue({
|
|
totalMonthlyCredits: 10,
|
|
totalRemainingMonthlyCredits: 5,
|
|
additionalCredits: 0,
|
|
totalCreditsUsedThisMonth: 5,
|
|
});
|
|
|
|
vi.spyOn(creditService, "_getTeamWithAvailableCredits").mockResolvedValue({
|
|
availableCredits: 3,
|
|
});
|
|
|
|
vi.spyOn(creditService, "_getAllCredits").mockResolvedValue({
|
|
additionalCredits: 1,
|
|
});
|
|
|
|
describe("CreditService", () => {
|
|
let creditService: CreditService;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetAllMocks();
|
|
|
|
mockStripe.prices.retrieve.mockResolvedValue({ id: "price_123", unit_amount: 1000 });
|
|
mockStripe.customers.create.mockResolvedValue({ id: "cus_123" });
|
|
|
|
creditService = new CreditService();
|
|
|
|
vi.mocked(CreditsRepository.findCreditExpenseLogByExternalRef).mockResolvedValue(null);
|
|
|
|
const { getBillingProviderService, getTeamBillingServiceFactory } = await import(
|
|
"@calcom/ee/billing/di/containers/Billing"
|
|
);
|
|
|
|
const mockBillingProviderService = {
|
|
getPrice: vi.fn().mockResolvedValue({ unit_amount: 1500 }),
|
|
};
|
|
vi.mocked(getBillingProviderService).mockReturnValue(mockBillingProviderService);
|
|
|
|
const mockTeamBillingService = {
|
|
getSubscriptionStatus: vi.fn().mockResolvedValue("active"),
|
|
};
|
|
const mockTeamBillingServiceFactory = {
|
|
init: vi.fn().mockReturnValue(mockTeamBillingService),
|
|
findAndInit: vi.fn().mockResolvedValue(mockTeamBillingService),
|
|
findAndInitMany: vi.fn().mockResolvedValue([mockTeamBillingService]),
|
|
};
|
|
vi.mocked(getTeamBillingServiceFactory).mockReturnValue(mockTeamBillingServiceFactory);
|
|
});
|
|
|
|
describe("Team credits", () => {
|
|
describe("hasAvailableCredits", () => {
|
|
it("should return true if team has not yet reached limit", async () => {
|
|
vi.mocked(getOrgIdFromMemberOrTeamId).mockResolvedValue(null);
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 0,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
});
|
|
|
|
const noLimitReached = await creditService.hasAvailableCredits({ teamId: 1 });
|
|
expect(noLimitReached).toBe(true);
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 0,
|
|
limitReachedAt: dayjs().subtract(1, "month").toDate(),
|
|
warningSentAt: null,
|
|
});
|
|
|
|
const limitReachedLastMonth = await creditService.hasAvailableCredits({ teamId: 1 });
|
|
expect(limitReachedLastMonth).toBe(true);
|
|
});
|
|
|
|
it("should return false if team limit reached this month", async () => {
|
|
vi.setSystemTime(new Date("2024-06-20T11:59:59Z"));
|
|
|
|
vi.mocked(getOrgIdFromMemberOrTeamId).mockResolvedValue(null);
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 0,
|
|
limitReachedAt: dayjs().subtract(1, "week").toDate(),
|
|
warningSentAt: null,
|
|
});
|
|
|
|
const result = await creditService.hasAvailableCredits({ teamId: 1 });
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("getTeamWithAvailableCredits", () => {
|
|
it("should return team with available credits", async () => {
|
|
vi.mocked(MembershipRepository.findAllAcceptedPublishedTeamMemberships).mockResolvedValue([
|
|
{
|
|
id: 1,
|
|
teamId: 1,
|
|
userId: 1,
|
|
role: "MEMBER",
|
|
accepted: true,
|
|
},
|
|
]);
|
|
|
|
vi.spyOn(TeamRepository.prototype, "findTeamsForCreditCheck").mockResolvedValue([
|
|
{ id: 1, isOrganization: false, parentId: null, parent: null },
|
|
]);
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 0,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
});
|
|
|
|
const result = await creditService.getTeamWithAvailableCredits(1);
|
|
expect(result).toEqual({
|
|
teamId: 1,
|
|
availableCredits: 0,
|
|
creditType: CreditType.ADDITIONAL,
|
|
});
|
|
});
|
|
|
|
it("should return first team if no team has available credits", async () => {
|
|
vi.mocked(MembershipRepository.findAllAcceptedPublishedTeamMemberships).mockResolvedValue([
|
|
{
|
|
id: 1,
|
|
teamId: 1,
|
|
userId: 1,
|
|
role: "MEMBER",
|
|
accepted: true,
|
|
},
|
|
]);
|
|
|
|
vi.spyOn(TeamRepository.prototype, "findTeamsForCreditCheck").mockResolvedValue([
|
|
{ id: 1, isOrganization: false, parentId: null, parent: null },
|
|
]);
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 0,
|
|
limitReachedAt: new Date(),
|
|
warningSentAt: null,
|
|
});
|
|
|
|
const result = await creditService.getTeamWithAvailableCredits(1);
|
|
expect(result).toEqual({
|
|
teamId: 1,
|
|
availableCredits: 0,
|
|
creditType: CreditType.ADDITIONAL,
|
|
limitReached: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("chargeCredits", () => {
|
|
it("should create expense log and send low balance warning email", async () => {
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 10,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
});
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalanceWithTeamOrUser).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 10,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
team: {
|
|
id: 1,
|
|
name: "team-name",
|
|
members: [
|
|
{
|
|
user: {
|
|
id: 1,
|
|
name: "admin",
|
|
email: "admin@example.com",
|
|
locale: "en",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
user: null,
|
|
});
|
|
|
|
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
|
|
|
|
vi.spyOn(EmailManager, "sendCreditBalanceLowWarningEmails").mockResolvedValue();
|
|
|
|
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
|
|
totalMonthlyCredits: 500,
|
|
totalRemainingMonthlyCredits: 20,
|
|
additionalCredits: 60,
|
|
totalCreditsUsedThisMonth: 480,
|
|
});
|
|
|
|
await creditService.chargeCredits({
|
|
teamId: 1,
|
|
credits: 5,
|
|
bookingUid: "booking-123",
|
|
smsSid: "sms-123",
|
|
});
|
|
|
|
expect(CreditsRepository.createCreditExpenseLog).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
bookingUid: "booking-123",
|
|
creditBalanceId: "1",
|
|
creditType: CreditType.MONTHLY,
|
|
credits: 5,
|
|
smsSid: "sms-123",
|
|
}),
|
|
MOCK_TX
|
|
);
|
|
|
|
expect(EmailManager.sendCreditBalanceLowWarningEmails).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should create expense log and send limit reached email", async () => {
|
|
vi.mocked(CreditsRepository.findCreditBalanceWithTeamOrUser).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 10,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
team: {
|
|
id: 1,
|
|
name: "team-name",
|
|
members: [
|
|
{
|
|
user: {
|
|
id: 1,
|
|
name: "admin",
|
|
email: "admin@example.com",
|
|
locale: "en",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
user: null,
|
|
});
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 0,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
});
|
|
|
|
vi.spyOn(EmailManager, "sendCreditBalanceLimitReachedEmails").mockResolvedValue();
|
|
|
|
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
|
|
totalMonthlyCredits: 500,
|
|
totalRemainingMonthlyCredits: -1,
|
|
additionalCredits: 0,
|
|
totalCreditsUsedThisMonth: 501,
|
|
});
|
|
|
|
await creditService.chargeCredits({
|
|
teamId: 1,
|
|
credits: 5,
|
|
bookingUid: "booking-123",
|
|
smsSid: "sms-123",
|
|
});
|
|
|
|
expect(CreditsRepository.createCreditExpenseLog).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
bookingUid: "booking-123",
|
|
creditBalanceId: "1",
|
|
creditType: CreditType.ADDITIONAL,
|
|
credits: 5,
|
|
smsSid: "sms-123",
|
|
}),
|
|
MOCK_TX
|
|
);
|
|
|
|
expect(EmailManager.sendCreditBalanceLimitReachedEmails).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("getUserOrTeamToCharge", () => {
|
|
it("should return team with remaining credits when teamId is provided", async () => {
|
|
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
|
|
totalMonthlyCredits: 500,
|
|
totalRemainingMonthlyCredits: 100,
|
|
additionalCredits: 50,
|
|
totalCreditsUsedThisMonth: 400,
|
|
});
|
|
|
|
const result = await creditService.getUserOrTeamToCharge({
|
|
credits: 50,
|
|
teamId: 1,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
teamId: 1,
|
|
remainingCredits: 100,
|
|
creditType: CreditType.MONTHLY,
|
|
});
|
|
});
|
|
|
|
it("should use additional credits when monthly credits are out", async () => {
|
|
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
|
|
totalMonthlyCredits: 500,
|
|
totalRemainingMonthlyCredits: 0,
|
|
additionalCredits: 50,
|
|
totalCreditsUsedThisMonth: 500,
|
|
});
|
|
|
|
const result = await creditService.getUserOrTeamToCharge({
|
|
credits: 30,
|
|
teamId: 1,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
teamId: 1,
|
|
remainingCredits: 20,
|
|
creditType: CreditType.ADDITIONAL,
|
|
});
|
|
});
|
|
|
|
it("should return team with available credits when userId is provided", async () => {
|
|
vi.mocked(MembershipRepository.findAllAcceptedPublishedTeamMemberships).mockResolvedValue([
|
|
{ teamId: 1 },
|
|
]);
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 0,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
});
|
|
|
|
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue({
|
|
teamId: 1,
|
|
availableCredits: 150,
|
|
creditType: CreditType.MONTHLY,
|
|
});
|
|
|
|
const result = await creditService.getUserOrTeamToCharge({
|
|
credits: 50,
|
|
userId: 1,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
teamId: 1,
|
|
availableCredits: 150,
|
|
creditType: CreditType.MONTHLY,
|
|
remainingCredits: 100,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getMonthlyCredits", () => {
|
|
it("should return 0 if subscription is not active", async () => {
|
|
const mockTeamRepo = {
|
|
findTeamWithMembers: vi.fn().mockResolvedValue({
|
|
id: 1,
|
|
members: [{ accepted: true }],
|
|
}),
|
|
};
|
|
vi.mocked(TeamRepository).mockImplementation(function () {
|
|
return mockTeamRepo as unknown as TeamRepository;
|
|
});
|
|
|
|
const mockTeamBillingService = {
|
|
getSubscriptionStatus: vi.fn().mockResolvedValue(SubscriptionStatus.TRIALING),
|
|
};
|
|
const { getTeamBillingServiceFactory } = await import("@calcom/ee/billing/di/containers/Billing");
|
|
vi.mocked(getTeamBillingServiceFactory).mockReturnValue({
|
|
init: vi.fn().mockReturnValue(mockTeamBillingService),
|
|
findAndInit: vi.fn().mockResolvedValue(mockTeamBillingService),
|
|
findAndInitMany: vi.fn().mockResolvedValue([mockTeamBillingService]),
|
|
});
|
|
|
|
const result = await creditService.getMonthlyCredits(1);
|
|
expect(result).toBe(0);
|
|
});
|
|
|
|
it("should calculate credits based on active members and price", async () => {
|
|
vi.stubEnv("STRIPE_TEAM_MONTHLY_PRICE_ID", "price_team_monthly");
|
|
const mockTeamRepo = {
|
|
findTeamWithMembers: vi.fn().mockResolvedValue({
|
|
id: 1,
|
|
members: [{ accepted: true }, { accepted: true }, { accepted: true }],
|
|
}),
|
|
};
|
|
vi.mocked(TeamRepository).mockImplementation(function () {
|
|
return mockTeamRepo as unknown as TeamRepository;
|
|
});
|
|
|
|
const mockTeamBillingService = {
|
|
getSubscriptionStatus: vi.fn().mockResolvedValue(SubscriptionStatus.ACTIVE),
|
|
};
|
|
const mockBillingProviderService = {
|
|
getPrice: vi.fn().mockResolvedValue({ unit_amount: 1000 }),
|
|
};
|
|
const { getBillingProviderService, getTeamBillingServiceFactory } = await import(
|
|
"@calcom/ee/billing/di/containers/Billing"
|
|
);
|
|
vi.mocked(getBillingProviderService).mockReturnValue(mockBillingProviderService);
|
|
vi.mocked(getTeamBillingServiceFactory).mockReturnValue({
|
|
init: vi.fn().mockReturnValue(mockTeamBillingService),
|
|
findAndInit: vi.fn().mockResolvedValue(mockTeamBillingService),
|
|
findAndInitMany: vi.fn().mockResolvedValue([mockTeamBillingService]),
|
|
});
|
|
|
|
const result = await creditService.getMonthlyCredits(1);
|
|
expect(result).toBe(1500); // (3 members * 1000 price) / 2
|
|
});
|
|
|
|
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,
|
|
isOrganization: true,
|
|
members: [{ accepted: true }, { accepted: true }],
|
|
}),
|
|
};
|
|
vi.mocked(TeamRepository).mockImplementation(function () {
|
|
return mockTeamRepo as unknown as TeamRepository;
|
|
});
|
|
|
|
const mockTeamBillingService = {
|
|
getSubscriptionStatus: vi.fn().mockResolvedValue(SubscriptionStatus.ACTIVE),
|
|
};
|
|
const { getTeamBillingServiceFactory } = await import("@calcom/ee/billing/di/containers/Billing");
|
|
vi.mocked(getTeamBillingServiceFactory).mockReturnValue({
|
|
init: vi.fn().mockReturnValue(mockTeamBillingService),
|
|
findAndInit: vi.fn().mockResolvedValue(mockTeamBillingService),
|
|
findAndInitMany: vi.fn().mockResolvedValue([mockTeamBillingService]),
|
|
});
|
|
|
|
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 () => {
|
|
vi.stubEnv("ORG_MONTHLY_CREDITS", undefined);
|
|
const mockTeamRepo = {
|
|
findTeamWithMembers: vi.fn().mockResolvedValue({
|
|
id: 1,
|
|
isOrganization: true,
|
|
members: [{ accepted: true }, { accepted: true }, { accepted: true }],
|
|
}),
|
|
};
|
|
vi.mocked(TeamRepository).mockImplementation(function () {
|
|
return mockTeamRepo as unknown as TeamRepository;
|
|
});
|
|
|
|
const mockTeamBillingService = {
|
|
getSubscriptionStatus: vi.fn().mockResolvedValue(SubscriptionStatus.ACTIVE),
|
|
};
|
|
const { getTeamBillingServiceFactory } = await import("@calcom/ee/billing/di/containers/Billing");
|
|
vi.mocked(getTeamBillingServiceFactory).mockReturnValue({
|
|
init: vi.fn().mockReturnValue(mockTeamBillingService),
|
|
findAndInit: vi.fn().mockResolvedValue(mockTeamBillingService),
|
|
findAndInitMany: vi.fn().mockResolvedValue([mockTeamBillingService]),
|
|
});
|
|
|
|
const result = await creditService.getMonthlyCredits(1);
|
|
expect(result).toBe(3000); // 3 members * 1000 credits per seat (default)
|
|
});
|
|
});
|
|
|
|
describe("getAllCreditsForTeam", () => {
|
|
it("should calculate total and remaining credits correctly", async () => {
|
|
vi.mocked(CreditsRepository.findCreditBalanceWithExpenseLogs)
|
|
.mockResolvedValueOnce({
|
|
additionalCredits: 100,
|
|
expenseLogs: [
|
|
{ credits: 50, date: new Date() },
|
|
{ credits: 30, date: new Date() },
|
|
],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
additionalCredits: 100,
|
|
expenseLogs: [{ credits: 80, date: new Date() }],
|
|
});
|
|
|
|
vi.spyOn(CreditService.prototype, "getMonthlyCredits").mockResolvedValue(500);
|
|
|
|
const result = await creditService.getAllCreditsForTeam(1);
|
|
expect(result).toEqual({
|
|
totalMonthlyCredits: 500,
|
|
totalRemainingMonthlyCredits: 420, // 500 - (50 + 30)
|
|
additionalCredits: 100,
|
|
totalCreditsUsedThisMonth: 80, // 50 + 30
|
|
});
|
|
});
|
|
|
|
it("should handle no expense logs", async () => {
|
|
vi.mocked(CreditsRepository.findCreditBalanceWithExpenseLogs)
|
|
.mockResolvedValueOnce({
|
|
additionalCredits: 100,
|
|
expenseLogs: [],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
additionalCredits: 100,
|
|
expenseLogs: [],
|
|
});
|
|
|
|
vi.spyOn(CreditService.prototype, "getMonthlyCredits").mockResolvedValue(500);
|
|
|
|
const result = await creditService.getAllCreditsForTeam(1);
|
|
expect(result).toEqual({
|
|
totalMonthlyCredits: 500,
|
|
totalRemainingMonthlyCredits: 500,
|
|
additionalCredits: 100,
|
|
totalCreditsUsedThisMonth: 0, // no expenses
|
|
});
|
|
});
|
|
|
|
it("should calculate total credits including additional credits for the month", async () => {
|
|
vi.mocked(CreditsRepository.findCreditBalanceWithExpenseLogs)
|
|
.mockResolvedValueOnce({
|
|
additionalCredits: 150,
|
|
expenseLogs: [
|
|
{ credits: 80, date: new Date() },
|
|
{ credits: 40, date: new Date() },
|
|
],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
additionalCredits: 150,
|
|
expenseLogs: [
|
|
{ credits: 25, date: new Date() },
|
|
{ credits: 15, date: new Date() },
|
|
],
|
|
});
|
|
|
|
vi.spyOn(CreditService.prototype, "getMonthlyCredits").mockResolvedValue(500);
|
|
|
|
const result = await creditService.getAllCreditsForTeam(1);
|
|
expect(result).toEqual({
|
|
totalMonthlyCredits: 500,
|
|
totalRemainingMonthlyCredits: 380, // 500 - (80 + 40)
|
|
additionalCredits: 150,
|
|
totalCreditsUsedThisMonth: 120, // 80 + 40
|
|
});
|
|
});
|
|
|
|
it("should handle zero additional credits", async () => {
|
|
vi.mocked(CreditsRepository.findCreditBalanceWithExpenseLogs)
|
|
.mockResolvedValueOnce({
|
|
additionalCredits: 0,
|
|
expenseLogs: [{ credits: 100, date: new Date() }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
additionalCredits: 0,
|
|
expenseLogs: [],
|
|
});
|
|
|
|
vi.spyOn(CreditService.prototype, "getMonthlyCredits").mockResolvedValue(500);
|
|
|
|
const result = await creditService.getAllCreditsForTeam(1);
|
|
expect(result).toEqual({
|
|
totalMonthlyCredits: 500,
|
|
totalRemainingMonthlyCredits: 400, // 500 - 100
|
|
additionalCredits: 0,
|
|
totalCreditsUsedThisMonth: 100,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("User credits", () => {
|
|
describe("hasAvailableCredits", () => {
|
|
it("should return true if user has not yet reached limit", async () => {
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 10,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
});
|
|
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
|
|
|
|
const hasAvailableCredits = await creditService.hasAvailableCredits({ userId: 1 });
|
|
expect(hasAvailableCredits).toBe(true);
|
|
});
|
|
|
|
it("should return false if user limit reached this month", async () => {
|
|
vi.setSystemTime(new Date("2024-06-20T11:59:59Z"));
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 0,
|
|
limitReachedAt: dayjs().subtract(1, "week").toDate(),
|
|
warningSentAt: null,
|
|
});
|
|
|
|
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
|
|
|
|
const result = await creditService.hasAvailableCredits({ userId: 1 });
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("chargeCredits", () => {
|
|
it("should create expense log and send low balance warning email for user", async () => {
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 10,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
});
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalanceWithTeamOrUser).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 10,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
user: {
|
|
id: 1,
|
|
name: "user-name",
|
|
email: "user@example.com",
|
|
locale: "en",
|
|
},
|
|
team: null,
|
|
});
|
|
|
|
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
|
|
|
|
vi.spyOn(EmailManager, "sendCreditBalanceLowWarningEmails").mockResolvedValue();
|
|
|
|
vi.spyOn(CreditService.prototype, "_getAllCredits").mockResolvedValue({
|
|
totalMonthlyCredits: 0,
|
|
totalRemainingMonthlyCredits: 0,
|
|
additionalCredits: 10,
|
|
});
|
|
|
|
await creditService.chargeCredits({
|
|
userId: 1,
|
|
credits: 5,
|
|
bookingUid: "booking-123",
|
|
smsSid: "sms-123",
|
|
});
|
|
|
|
expect(CreditsRepository.createCreditExpenseLog).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
bookingUid: "booking-123",
|
|
creditBalanceId: "1",
|
|
creditType: CreditType.ADDITIONAL,
|
|
credits: 5,
|
|
smsSid: "sms-123",
|
|
}),
|
|
MOCK_TX
|
|
);
|
|
|
|
expect(EmailManager.sendCreditBalanceLowWarningEmails).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should create expense log and send limit reached email for user", async () => {
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 0,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
});
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalanceWithTeamOrUser).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 0,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
team: {
|
|
id: 1,
|
|
name: "team-name",
|
|
members: [
|
|
{
|
|
user: {
|
|
id: 1,
|
|
name: "admin",
|
|
email: "admin@example.com",
|
|
locale: "en",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
user: null,
|
|
});
|
|
|
|
vi.spyOn(EmailManager, "sendCreditBalanceLimitReachedEmails").mockResolvedValue();
|
|
|
|
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
|
|
|
|
vi.spyOn(CreditService.prototype, "_getAllCredits").mockResolvedValue({
|
|
totalMonthlyCredits: 0,
|
|
totalRemainingMonthlyCredits: 0,
|
|
additionalCredits: 2,
|
|
});
|
|
|
|
await creditService.chargeCredits({
|
|
userId: 1,
|
|
credits: 5,
|
|
bookingUid: "booking-123",
|
|
smsSid: "sms-123",
|
|
});
|
|
|
|
expect(CreditsRepository.createCreditExpenseLog).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
bookingUid: "booking-123",
|
|
creditBalanceId: "1",
|
|
creditType: CreditType.ADDITIONAL,
|
|
credits: 5,
|
|
smsSid: "sms-123",
|
|
}),
|
|
MOCK_TX
|
|
);
|
|
|
|
expect(EmailManager.sendCreditBalanceLimitReachedEmails).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("getUserOrTeamToCharge", () => {
|
|
it("should return user with remaining credits when userId is provided", async () => {
|
|
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
|
|
|
|
vi.spyOn(CreditService.prototype, "_getAllCredits").mockResolvedValue({
|
|
totalMonthlyCredits: 0,
|
|
totalRemainingMonthlyCredits: 0,
|
|
additionalCredits: 10,
|
|
});
|
|
const result = await creditService.getUserOrTeamToCharge({
|
|
credits: 2,
|
|
userId: 1,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
userId: 1,
|
|
remainingCredits: 8,
|
|
creditType: CreditType.ADDITIONAL,
|
|
});
|
|
});
|
|
|
|
it("should fall back to user credits when team has limitReached", async () => {
|
|
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue({
|
|
teamId: 1,
|
|
availableCredits: 0,
|
|
creditType: CreditType.ADDITIONAL,
|
|
limitReached: true,
|
|
});
|
|
|
|
vi.spyOn(CreditService.prototype, "_getAllCredits").mockResolvedValue({
|
|
totalMonthlyCredits: 0,
|
|
totalRemainingMonthlyCredits: 0,
|
|
additionalCredits: 50,
|
|
});
|
|
|
|
const result = await creditService.getUserOrTeamToCharge({
|
|
credits: 10,
|
|
userId: 1,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
userId: 1,
|
|
remainingCredits: 40,
|
|
creditType: CreditType.ADDITIONAL,
|
|
});
|
|
});
|
|
|
|
it("should return team when both team and user have no credits", async () => {
|
|
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue({
|
|
teamId: 1,
|
|
availableCredits: 0,
|
|
creditType: CreditType.ADDITIONAL,
|
|
limitReached: true,
|
|
});
|
|
|
|
vi.spyOn(CreditService.prototype, "_getAllCredits").mockResolvedValue({
|
|
totalMonthlyCredits: 0,
|
|
totalRemainingMonthlyCredits: 0,
|
|
additionalCredits: 0,
|
|
});
|
|
|
|
const result = await creditService.getUserOrTeamToCharge({
|
|
credits: 10,
|
|
userId: 1,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
teamId: 1,
|
|
availableCredits: 0,
|
|
creditType: CreditType.ADDITIONAL,
|
|
limitReached: true,
|
|
remainingCredits: -10,
|
|
});
|
|
});
|
|
});
|
|
|
|
it("should skip unpublished platform organizations and return regular team with credits", async () => {
|
|
vi.mocked(MembershipRepository.findAllAcceptedPublishedTeamMemberships).mockResolvedValue([
|
|
{ teamId: 2 },
|
|
]);
|
|
|
|
const mockTeamRepoInstance = {
|
|
findTeamsForCreditCheck: vi
|
|
.fn()
|
|
.mockResolvedValue([{ id: 2, isOrganization: false, parentId: null, parent: null }]),
|
|
};
|
|
vi.mocked(TeamRepository).mockImplementation(function () {
|
|
return mockTeamRepoInstance as unknown as TeamRepository;
|
|
});
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "2",
|
|
additionalCredits: 100,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
});
|
|
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
|
|
totalMonthlyCredits: 500,
|
|
totalRemainingMonthlyCredits: 200,
|
|
additionalCredits: 100,
|
|
totalCreditsUsedThisMonth: 300,
|
|
});
|
|
const result = await creditService.getTeamWithAvailableCredits(1);
|
|
expect(result).toEqual({
|
|
teamId: 2,
|
|
availableCredits: 300,
|
|
creditType: CreditType.MONTHLY,
|
|
});
|
|
|
|
expect(MembershipRepository.findAllAcceptedPublishedTeamMemberships).toHaveBeenCalledWith(1, MOCK_TX);
|
|
expect(CreditsRepository.findCreditBalance).toHaveBeenCalledTimes(1);
|
|
expect(CreditsRepository.findCreditBalance).toHaveBeenCalledWith({ teamId: 2 }, MOCK_TX);
|
|
});
|
|
|
|
describe("Organization priority", () => {
|
|
it("should use organization credits when user belongs to org, ignoring team memberships", async () => {
|
|
vi.mocked(MembershipRepository.findAllAcceptedPublishedTeamMemberships).mockResolvedValue([
|
|
{ teamId: 1 },
|
|
{ teamId: 2 },
|
|
]);
|
|
|
|
const mockTeamRepoInstance = {
|
|
findTeamsForCreditCheck: vi.fn().mockResolvedValue([
|
|
{ id: 1, isOrganization: true, parentId: null, parent: null },
|
|
{ id: 2, isOrganization: false, parentId: null, parent: null },
|
|
]),
|
|
};
|
|
vi.mocked(TeamRepository).mockImplementation(function () {
|
|
return mockTeamRepoInstance as unknown as TeamRepository;
|
|
});
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 50,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
});
|
|
|
|
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
|
|
totalMonthlyCredits: 1000,
|
|
totalRemainingMonthlyCredits: 800,
|
|
additionalCredits: 50,
|
|
totalCreditsUsedThisMonth: 200,
|
|
});
|
|
|
|
const result = await creditService.getTeamWithAvailableCredits(1);
|
|
|
|
expect(result).toEqual({
|
|
teamId: 1,
|
|
availableCredits: 850,
|
|
creditType: CreditType.MONTHLY,
|
|
});
|
|
});
|
|
|
|
it("should return org with limitReached when org has no credits, ignoring teams", async () => {
|
|
vi.mocked(MembershipRepository.findAllAcceptedPublishedTeamMemberships).mockResolvedValue([
|
|
{ teamId: 1 },
|
|
{ teamId: 2 },
|
|
]);
|
|
|
|
const mockTeamRepoInstance = {
|
|
findTeamsForCreditCheck: vi.fn().mockResolvedValue([
|
|
{ id: 1, isOrganization: true, parentId: null, parent: null },
|
|
{ id: 2, isOrganization: false, parentId: null, parent: null },
|
|
]),
|
|
};
|
|
vi.mocked(TeamRepository).mockImplementation(function () {
|
|
return mockTeamRepoInstance as unknown as TeamRepository;
|
|
});
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 0,
|
|
limitReachedAt: new Date(),
|
|
warningSentAt: null,
|
|
});
|
|
|
|
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
|
|
totalMonthlyCredits: 1000,
|
|
totalRemainingMonthlyCredits: 0,
|
|
additionalCredits: 0,
|
|
totalCreditsUsedThisMonth: 1000,
|
|
});
|
|
|
|
const result = await creditService.getTeamWithAvailableCredits(1);
|
|
|
|
expect(result).toEqual({
|
|
teamId: 1,
|
|
availableCredits: 0,
|
|
creditType: CreditType.ADDITIONAL,
|
|
limitReached: true,
|
|
});
|
|
});
|
|
|
|
it("should check teams when user has no org membership", async () => {
|
|
vi.mocked(MembershipRepository.findAllAcceptedPublishedTeamMemberships).mockResolvedValue([
|
|
{ teamId: 2 },
|
|
]);
|
|
|
|
const mockTeamRepoInstance = {
|
|
findTeamsForCreditCheck: vi
|
|
.fn()
|
|
.mockResolvedValue([{ id: 2, isOrganization: false, parentId: null, parent: null }]),
|
|
};
|
|
vi.mocked(TeamRepository).mockImplementation(function () {
|
|
return mockTeamRepoInstance as unknown as TeamRepository;
|
|
});
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "2",
|
|
additionalCredits: 100,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
});
|
|
|
|
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
|
|
totalMonthlyCredits: 500,
|
|
totalRemainingMonthlyCredits: 300,
|
|
additionalCredits: 100,
|
|
totalCreditsUsedThisMonth: 200,
|
|
});
|
|
|
|
const result = await creditService.getTeamWithAvailableCredits(1);
|
|
|
|
expect(result).toEqual({
|
|
teamId: 2,
|
|
availableCredits: 400,
|
|
creditType: CreditType.MONTHLY,
|
|
});
|
|
});
|
|
|
|
it("should use parent org credits when teamId belongs to org", async () => {
|
|
vi.mocked(getOrgIdFromMemberOrTeamId).mockResolvedValue(100);
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "100",
|
|
additionalCredits: 200,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
});
|
|
|
|
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
|
|
totalMonthlyCredits: 2000,
|
|
totalRemainingMonthlyCredits: 1500,
|
|
additionalCredits: 200,
|
|
totalCreditsUsedThisMonth: 500,
|
|
});
|
|
|
|
const result = await creditService.hasAvailableCredits({ teamId: 50 });
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Idempotency", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("should detect duplicate charges by externalRef", async () => {
|
|
vi.mocked(CreditsRepository.findCreditExpenseLogByExternalRef).mockResolvedValue({
|
|
id: "existing-log-id",
|
|
credits: 10,
|
|
creditType: CreditType.ADDITIONAL,
|
|
date: new Date(),
|
|
bookingUid: "booking-123",
|
|
});
|
|
|
|
const result = await creditService.chargeCredits({
|
|
userId: 1,
|
|
credits: 10,
|
|
externalRef: "retell:duplicate-call-123",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
bookingUid: "booking-123",
|
|
duplicate: true,
|
|
teamId: undefined,
|
|
userId: 1,
|
|
});
|
|
|
|
expect(CreditsRepository.findCreditExpenseLogByExternalRef).toHaveBeenCalledWith(
|
|
"retell:duplicate-call-123"
|
|
);
|
|
expect(CreditsRepository.createCreditExpenseLog).not.toHaveBeenCalled();
|
|
expect(CreditsRepository.updateCreditBalance).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should create expense log with externalRef when not duplicate", async () => {
|
|
vi.mocked(CreditsRepository.findCreditExpenseLogByExternalRef).mockResolvedValue(null);
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalanceWithTeamOrUser).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 100,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
user: {
|
|
id: 1,
|
|
name: "Test User",
|
|
email: "test@example.com",
|
|
locale: "en",
|
|
},
|
|
team: null,
|
|
});
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 100,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
});
|
|
|
|
vi.spyOn(CreditService.prototype, "_getUserOrTeamToCharge").mockResolvedValue({
|
|
userId: 1,
|
|
remainingCredits: 90,
|
|
creditType: CreditType.ADDITIONAL,
|
|
});
|
|
|
|
await creditService.chargeCredits({
|
|
userId: 1,
|
|
credits: 10,
|
|
bookingUid: "booking-456",
|
|
externalRef: "retell:new-call-456",
|
|
});
|
|
|
|
expect(CreditsRepository.findCreditExpenseLogByExternalRef).toHaveBeenCalledWith("retell:new-call-456");
|
|
|
|
expect(CreditsRepository.createCreditExpenseLog).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
credits: 10,
|
|
creditType: CreditType.ADDITIONAL,
|
|
bookingUid: "booking-456",
|
|
externalRef: "retell:new-call-456",
|
|
}),
|
|
MOCK_TX
|
|
);
|
|
});
|
|
|
|
it("should work normally without externalRef", async () => {
|
|
vi.mocked(CreditsRepository.findCreditBalanceWithTeamOrUser).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 100,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
team: {
|
|
id: 1,
|
|
name: "Test Team",
|
|
members: [],
|
|
},
|
|
user: null,
|
|
});
|
|
|
|
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
|
id: "1",
|
|
additionalCredits: 100,
|
|
limitReachedAt: null,
|
|
warningSentAt: null,
|
|
});
|
|
|
|
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
|
|
totalMonthlyCredits: 500,
|
|
totalRemainingMonthlyCredits: 100,
|
|
additionalCredits: 100,
|
|
totalCreditsUsedThisMonth: 400,
|
|
});
|
|
|
|
await creditService.chargeCredits({
|
|
teamId: 1,
|
|
credits: 10,
|
|
bookingUid: "booking-789",
|
|
});
|
|
|
|
expect(CreditsRepository.findCreditExpenseLogByExternalRef).not.toHaveBeenCalled();
|
|
|
|
expect(CreditsRepository.createCreditExpenseLog).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
credits: 10,
|
|
bookingUid: "booking-789",
|
|
externalRef: undefined,
|
|
}),
|
|
MOCK_TX
|
|
);
|
|
});
|
|
});
|
|
});
|