feat: Add subscription start, trial end, and dates to billing tables (#24408)

* Add subscription start, trial end, and end dates to db

* Add subscription start, trial end, and end date to db

* Write subscription start date on new team subscriptions

* Write subscription start date for new orgs

* Fix typo in stripe billing service file (billling -> billing)

* Use `StripeBillingService.extractSubscriptionDates`

* Remove comments

* Address comment

* Fix typo in file import

* Fix typo in file import

* Add missing SubscriptionStatus enum values

- Add INCOMPLETE, INCOMPLETE_EXPIRED, UNPAID, PAUSED enum values
- These values are referenced in stripe-billing-service.ts status mapping
- Fixes type errors in billing-related code

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Joe Au-Yeung
2025-10-14 21:12:31 -04:00
committed by GitHub
co-authored by joe@cal.com Devin AI
parent 003aaaa18b
commit b312955838
20 changed files with 107 additions and 25 deletions
@@ -5,6 +5,7 @@ import type Stripe from "stripe";
import { z } from "zod";
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { HttpError } from "@calcom/lib/http-error";
@@ -57,6 +58,9 @@ async function handler(request: NextRequest) {
});
if (checkoutSessionSubscription) {
const { subscriptionStart } =
StripeBillingService.extractSubscriptionDates(checkoutSessionSubscription);
const internalBillingService = new InternalTeamBilling(finalizedTeam);
await internalBillingService.saveTeamBilling({
teamId: finalizedTeam.id,
@@ -66,6 +70,7 @@ async function handler(request: NextRequest) {
// TODO: Implement true subscription status when webhook events are implemented
status: SubscriptionStatus.ACTIVE,
planName: Plan.TEAM,
subscriptionStart,
});
}
+3
View File
@@ -5,6 +5,7 @@ import type Stripe from "stripe";
import { z } from "zod";
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { HttpError } from "@calcom/lib/http-error";
@@ -87,6 +88,7 @@ async function getHandler(req: NextRequest) {
});
if (checkoutSession && subscription) {
const { subscriptionStart } = StripeBillingService.extractSubscriptionDates(subscription);
const internalBillingService = new InternalTeamBilling(team);
await internalBillingService.saveTeamBilling({
teamId: team.id,
@@ -96,6 +98,7 @@ async function getHandler(req: NextRequest) {
// TODO: Implement true subscription status when webhook events are implemented
status: SubscriptionStatus.ACTIVE,
planName: Plan.TEAM,
subscriptionStart,
});
}
+1 -1
View File
@@ -17,7 +17,7 @@ vi.mock("@calcom/prisma", () => {
};
});
vi.mock("@calcom/features/ee/billing/stripe-billling-service", () => {
vi.mock("@calcom/features/ee/billing/stripe-billing-service", () => {
return {
StripeBillingService: vi.fn(),
};
+1 -1
View File
@@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
@@ -1,4 +1,4 @@
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
export async function getCustomerAndCheckoutSession(checkoutSessionId: string) {
const billingService = new StripeBillingService();
@@ -6,7 +6,7 @@ import { getLocaleFromRequest } from "@calcom/features/auth/lib/getLocaleFromReq
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { prefillAvatar } from "@calcom/features/auth/signup/utils/prefillAvatar";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
@@ -1,8 +1,10 @@
import { z } from "zod";
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
import { createOrganizationFromOnboarding } from "@calcom/features/ee/organizations/lib/server/createOrganizationFromOnboarding";
import stripe from "@calcom/features/ee/payments/server/stripe";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
@@ -96,6 +98,10 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => {
paymentSubscriptionItemId,
});
// Get the Stripe subscription object
const stripeSubscription = await stripe.subscriptions.retrieve(paymentSubscriptionId);
const { subscriptionStart } = StripeBillingService.extractSubscriptionDates(stripeSubscription);
const internalTeamBillingService = new InternalTeamBilling(organization);
await internalTeamBillingService.saveTeamBilling({
teamId: organization.id,
@@ -105,6 +111,7 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => {
// TODO: Write actual status when webhook events are added
status: SubscriptionStatus.ACTIVE,
planName: Plan.ORGANIZATION,
subscriptionStart,
});
logger.debug(`Marking onboarding as complete for organization ${organization.id}`);
@@ -1,4 +1,4 @@
import { StripeBillingService } from "./stripe-billling-service";
import { StripeBillingService } from "./stripe-billing-service";
export class BillingFactory {
constructor() {
@@ -9,7 +9,7 @@ import { TeamRepository } from "@calcom/lib/server/repository/team";
import { CreditType } from "@calcom/prisma/enums";
import { CreditService } from "./credit-service";
import { StripeBillingService } from "./stripe-billling-service";
import { StripeBillingService } from "./stripe-billing-service";
import { InternalTeamBilling } from "./teams/internal-team-billing";
const MOCK_TX = {};
@@ -5,7 +5,7 @@ import {
sendCreditBalanceLimitReachedEmails,
sendCreditBalanceLowWarningEmails,
} from "@calcom/emails/email-manager";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
import { cancelScheduledMessagesAndScheduleEmails } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
@@ -9,6 +9,10 @@ export enum SubscriptionStatus {
CANCELLED = "CANCELLED",
PAST_DUE = "PAST_DUE",
TRIALING = "TRIALING",
INCOMPLETE = "INCOMPLETE",
INCOMPLETE_EXPIRED = "INCOMPLETE_EXPIRED",
UNPAID = "UNPAID",
PAUSED = "PAUSED",
}
export interface BillingRecord {
@@ -37,4 +41,7 @@ export interface IBillingRepositoryCreateArgs {
customerId: string;
planName: Plan;
status: SubscriptionStatus;
subscriptionStart?: Date;
subscriptionTrialEnd?: Date;
subscriptionEnd?: Date;
}
@@ -1,7 +1,7 @@
import Stripe from "stripe";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { StripeBillingService } from "./stripe-billling-service";
import { StripeBillingService } from "./stripe-billing-service";
vi.mock("stripe");
@@ -1,6 +1,9 @@
import Stripe from "stripe";
import logger from "@calcom/lib/logger";
import type { BillingService } from "./billing-service";
import { SubscriptionStatus } from "./repository/IBillingRepository";
export class StripeBillingService implements BillingService {
private stripe: Stripe;
@@ -58,6 +61,7 @@ export class StripeBillingService implements BillingService {
invoice_creation: {
enabled: true,
},
// eslint-disable-next-line
} as any);
return {
@@ -127,7 +131,7 @@ export class StripeBillingService implements BillingService {
}
async handleSubscriptionCreation(subscriptionId: string) {
throw new Error("Method not implemented.");
throw new Error(`Method not implemented for subscription id ${subscriptionId}`);
}
async handleSubscriptionCancel(subscriptionId: string) {
@@ -196,4 +200,45 @@ export class StripeBillingService implements BillingService {
const price = await this.stripe.prices.retrieve(priceId);
return price;
}
static extractSubscriptionDates(subscription: {
start_date: number;
trial_end?: number | null;
cancel_at?: number | null;
}) {
// Stripe returns dates as unix time in seconds but Date() expects milliseconds
const subscriptionStart = new Date(subscription.start_date * 1000);
const subscriptionTrialEnd = subscription?.trial_end ? new Date(subscription.trial_end * 1000) : null;
const subscriptionEnd = subscription?.cancel_at ? new Date(subscription.cancel_at * 1000) : null;
return { subscriptionStart, subscriptionTrialEnd, subscriptionEnd };
}
static mapStripeStatusToCalStatus = ({
stripeStatus,
subscriptionId,
}: {
stripeStatus: string;
subscriptionId: string;
}) => {
const log = logger.getSubLogger({ prefix: ["mapStripeStatusToCalStatus"] });
const statusMap: Record<string, SubscriptionStatus> = {
active: SubscriptionStatus.ACTIVE,
past_due: SubscriptionStatus.PAST_DUE,
canceled: SubscriptionStatus.CANCELLED,
cancelled: SubscriptionStatus.CANCELLED,
trialing: SubscriptionStatus.TRIALING,
incomplete: SubscriptionStatus.INCOMPLETE,
incomplete_expired: SubscriptionStatus.INCOMPLETE_EXPIRED,
unpaid: SubscriptionStatus.UNPAID,
paused: SubscriptionStatus.PAUSED,
};
const status = statusMap[stripeStatus];
if (!status) {
log.warn(`Unhandled status for ${stripeStatus} and sub id ${subscriptionId}`);
}
return status || SubscriptionStatus.ACTIVE;
};
}
@@ -42,7 +42,7 @@ vi.mock("@calcom/prisma", () => {
};
});
vi.mock("@calcom/features/ee/billing/stripe-billling-service", () => ({
vi.mock("@calcom/features/ee/billing/stripe-billing-service", () => ({
StripeBillingService: vi.fn().mockImplementation(() => ({
createCustomer: vi.fn().mockResolvedValue({ id: "mock_customer_id" }),
createPrice: vi.fn().mockResolvedValue({ id: "mock_price_id", isCustom: false }),
@@ -1,4 +1,4 @@
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import {
ORGANIZATION_SELF_SERVE_MIN_SEATS,
ORGANIZATION_SELF_SERVE_PRICE,
@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "OrganizationBilling" ADD COLUMN "subscriptionEnd" TIMESTAMP(3),
ADD COLUMN "subscriptionStart" TIMESTAMP(3),
ADD COLUMN "subscriptionTrialEnd" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "TeamBilling" ADD COLUMN "subscriptionEnd" TIMESTAMP(3),
ADD COLUMN "subscriptionStart" TIMESTAMP(3),
ADD COLUMN "subscriptionTrialEnd" TIMESTAMP(3);
+16 -10
View File
@@ -2670,11 +2670,14 @@ model TeamBilling {
teamId Int @unique
team Team? @relation("TeamBilling", fields: [teamId], references: [id], onDelete: Cascade)
subscriptionId String @unique
subscriptionItemId String
customerId String
status String
planName String
subscriptionId String @unique
subscriptionItemId String
customerId String
status String
planName String
subscriptionStart DateTime?
subscriptionTrialEnd DateTime?
subscriptionEnd DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2685,11 +2688,14 @@ model OrganizationBilling {
teamId Int @unique
team Team @relation("OrganizationBilling", fields: [teamId], references: [id], onDelete: Cascade)
subscriptionId String @unique
subscriptionItemId String
customerId String
status String
planName String
subscriptionId String @unique
subscriptionItemId String
customerId String
status String
planName String
subscriptionStart DateTime?
subscriptionTrialEnd DateTime?
subscriptionEnd DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1,4 +1,4 @@
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { prisma } from "@calcom/prisma";
import { userMetadata } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -1,4 +1,4 @@
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
@@ -4,7 +4,8 @@ import type { GetServerSidePropsContext, NextApiResponse } from "next";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { sendChangeOfEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { HttpError } from "@calcom/lib/http-error";
@@ -12,7 +13,6 @@ import logger from "@calcom/lib/logger";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { checkUsername } from "@calcom/features/profile/lib/checkUsername";
import { getTranslation } from "@calcom/lib/server/i18n";
import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import slugify from "@calcom/lib/slugify";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";