From b312955838c8d369734b4895e1b2ad543b47cf0d Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Tue, 14 Oct 2025 21:12:31 -0400 Subject: [PATCH] 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 --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/app/api/teams/api/create/route.ts | 5 ++ apps/web/app/api/teams/create/route.ts | 3 ++ apps/web/lib/pages/auth/verify-email.test.ts | 2 +- apps/web/lib/pages/auth/verify-email.ts | 2 +- .../lib/getCustomerAndCheckoutSession.ts | 2 +- .../auth/signup/handlers/calcomHandler.ts | 2 +- .../billing/api/webhook/_invoice.paid.org.ts | 7 +++ .../ee/billing/billing-service-factory.ts | 2 +- .../ee/billing/credit-service.test.ts | 2 +- .../features/ee/billing/credit-service.ts | 2 +- .../billing/repository/IBillingRepository.ts | 7 +++ ...test.ts => stripe-billing-service.test.ts} | 2 +- ...g-service.ts => stripe-billing-service.ts} | 47 ++++++++++++++++++- .../lib/OrganizationPaymentService.test.ts | 2 +- .../lib/OrganizationPaymentService.ts | 2 +- .../migration.sql | 9 ++++ packages/prisma/schema.prisma | 26 ++++++---- .../loggedInViewer/stripeCustomer.handler.ts | 2 +- .../viewer/credits/buyCredits.handler.ts | 2 +- .../viewer/me/updateProfile.handler.ts | 4 +- 20 files changed, 107 insertions(+), 25 deletions(-) rename packages/features/ee/billing/{stripe-billling-service.test.ts => stripe-billing-service.test.ts} (97%) rename packages/features/ee/billing/{stripe-billling-service.ts => stripe-billing-service.ts} (76%) create mode 100644 packages/prisma/migrations/20251010135752_billing_tables_add_dates/migration.sql diff --git a/apps/web/app/api/teams/api/create/route.ts b/apps/web/app/api/teams/api/create/route.ts index 670fd044b5..9699897de7 100644 --- a/apps/web/app/api/teams/api/create/route.ts +++ b/apps/web/app/api/teams/api/create/route.ts @@ -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, }); } diff --git a/apps/web/app/api/teams/create/route.ts b/apps/web/app/api/teams/create/route.ts index 0099914ab5..3d936872ae 100644 --- a/apps/web/app/api/teams/create/route.ts +++ b/apps/web/app/api/teams/create/route.ts @@ -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, }); } diff --git a/apps/web/lib/pages/auth/verify-email.test.ts b/apps/web/lib/pages/auth/verify-email.test.ts index 244bb50f5f..ddd21bd9a6 100644 --- a/apps/web/lib/pages/auth/verify-email.test.ts +++ b/apps/web/lib/pages/auth/verify-email.test.ts @@ -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(), }; diff --git a/apps/web/lib/pages/auth/verify-email.ts b/apps/web/lib/pages/auth/verify-email.ts index f4b5920372..f2685246bf 100644 --- a/apps/web/lib/pages/auth/verify-email.ts +++ b/apps/web/lib/pages/auth/verify-email.ts @@ -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"; diff --git a/packages/app-store/stripepayment/lib/getCustomerAndCheckoutSession.ts b/packages/app-store/stripepayment/lib/getCustomerAndCheckoutSession.ts index 11f8ba10d1..81194473cd 100644 --- a/packages/app-store/stripepayment/lib/getCustomerAndCheckoutSession.ts +++ b/packages/app-store/stripepayment/lib/getCustomerAndCheckoutSession.ts @@ -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(); diff --git a/packages/features/auth/signup/handlers/calcomHandler.ts b/packages/features/auth/signup/handlers/calcomHandler.ts index eb73e3dcfe..c6c87c965e 100644 --- a/packages/features/auth/signup/handlers/calcomHandler.ts +++ b/packages/features/auth/signup/handlers/calcomHandler.ts @@ -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"; diff --git a/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts b/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts index 739b2a15dc..e32cef148f 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts @@ -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}`); diff --git a/packages/features/ee/billing/billing-service-factory.ts b/packages/features/ee/billing/billing-service-factory.ts index 16ae5bbee6..aa26ea520c 100644 --- a/packages/features/ee/billing/billing-service-factory.ts +++ b/packages/features/ee/billing/billing-service-factory.ts @@ -1,4 +1,4 @@ -import { StripeBillingService } from "./stripe-billling-service"; +import { StripeBillingService } from "./stripe-billing-service"; export class BillingFactory { constructor() { diff --git a/packages/features/ee/billing/credit-service.test.ts b/packages/features/ee/billing/credit-service.test.ts index 8f85624a8c..607bc286f4 100644 --- a/packages/features/ee/billing/credit-service.test.ts +++ b/packages/features/ee/billing/credit-service.test.ts @@ -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 = {}; diff --git a/packages/features/ee/billing/credit-service.ts b/packages/features/ee/billing/credit-service.ts index 4ae683abd7..bc850d0a30 100644 --- a/packages/features/ee/billing/credit-service.ts +++ b/packages/features/ee/billing/credit-service.ts @@ -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"; diff --git a/packages/features/ee/billing/repository/IBillingRepository.ts b/packages/features/ee/billing/repository/IBillingRepository.ts index 0d5e619178..9a8e44592c 100644 --- a/packages/features/ee/billing/repository/IBillingRepository.ts +++ b/packages/features/ee/billing/repository/IBillingRepository.ts @@ -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; } diff --git a/packages/features/ee/billing/stripe-billling-service.test.ts b/packages/features/ee/billing/stripe-billing-service.test.ts similarity index 97% rename from packages/features/ee/billing/stripe-billling-service.test.ts rename to packages/features/ee/billing/stripe-billing-service.test.ts index dd0384af24..b39db4e4bb 100644 --- a/packages/features/ee/billing/stripe-billling-service.test.ts +++ b/packages/features/ee/billing/stripe-billing-service.test.ts @@ -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"); diff --git a/packages/features/ee/billing/stripe-billling-service.ts b/packages/features/ee/billing/stripe-billing-service.ts similarity index 76% rename from packages/features/ee/billing/stripe-billling-service.ts rename to packages/features/ee/billing/stripe-billing-service.ts index a0be6aa29a..54c2a114ed 100644 --- a/packages/features/ee/billing/stripe-billling-service.ts +++ b/packages/features/ee/billing/stripe-billing-service.ts @@ -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 = { + 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; + }; } diff --git a/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts b/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts index b516ca3866..7806ecdbcc 100644 --- a/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts +++ b/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts @@ -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 }), diff --git a/packages/features/ee/organizations/lib/OrganizationPaymentService.ts b/packages/features/ee/organizations/lib/OrganizationPaymentService.ts index 9fea7eda3a..fea4d56b6e 100644 --- a/packages/features/ee/organizations/lib/OrganizationPaymentService.ts +++ b/packages/features/ee/organizations/lib/OrganizationPaymentService.ts @@ -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, diff --git a/packages/prisma/migrations/20251010135752_billing_tables_add_dates/migration.sql b/packages/prisma/migrations/20251010135752_billing_tables_add_dates/migration.sql new file mode 100644 index 0000000000..6385050c50 --- /dev/null +++ b/packages/prisma/migrations/20251010135752_billing_tables_add_dates/migration.sql @@ -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); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 173698f5f8..07c97750f2 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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 diff --git a/packages/trpc/server/routers/loggedInViewer/stripeCustomer.handler.ts b/packages/trpc/server/routers/loggedInViewer/stripeCustomer.handler.ts index b65e3f4b9a..c8bc004b47 100644 --- a/packages/trpc/server/routers/loggedInViewer/stripeCustomer.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/stripeCustomer.handler.ts @@ -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"; diff --git a/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts b/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts index 5d578dced6..c2c4f9522c 100644 --- a/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts +++ b/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts @@ -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"; diff --git a/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts b/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts index b7340e4366..2056a038dc 100644 --- a/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts +++ b/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts @@ -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";