diff --git a/apps/web/@prisma/client.ts b/apps/web/@prisma/client.ts deleted file mode 100644 index 8391ec8e..00000000 --- a/apps/web/@prisma/client.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@calcom/prisma/client"; diff --git a/apps/web/ee/components/TrialBanner.tsx b/apps/web/ee/components/TrialBanner.tsx index bb4575af..ed83d826 100644 --- a/apps/web/ee/components/TrialBanner.tsx +++ b/apps/web/ee/components/TrialBanner.tsx @@ -13,9 +13,11 @@ const TrialBanner = () => { if (!user || user.plan !== "TRIAL") return null; - const trialDaysLeft = dayjs(user.createdDate) - .add(TRIAL_LIMIT_DAYS + 1, "day") - .diff(dayjs(), "day"); + const trialDaysLeft = user.trialEndsAt + ? dayjs(user.trialEndsAt).add(1, "day").diff(dayjs(), "day") + : dayjs(user.createdDate) + .add(TRIAL_LIMIT_DAYS + 1, "day") + .diff(dayjs(), "day"); return (
&1 | tee result.log` +import { MembershipRole, Prisma, UserPlan } from "@prisma/client"; +import dayjs from "dayjs"; + +import prisma from "@calcom/prisma"; + +import { TRIAL_LIMIT_DAYS } from "@lib/config/constants"; + +import { getStripeCustomerIdFromUserId } from "./customer"; +import stripe from "./server"; +import { getPremiumPlanPrice, getProPlanPrice } from "./team-billing"; + +export async function downgradeIllegalProUsers() { + const usersDowngraded: string[] = []; + const illegalProUsers = await prisma.membership.findMany({ + where: { + role: { + not: MembershipRole.OWNER, + }, + user: { + plan: { + not: UserPlan.PRO, + }, + }, + }, + include: { + user: true, + }, + }); + const downgrade = async (member: typeof illegalProUsers[number]) => { + console.log(`Downgrading: ${member.user.email}`); + await prisma.user.update({ + where: { id: member.user.id }, + data: { + plan: UserPlan.TRIAL, + trialEndsAt: dayjs().add(TRIAL_LIMIT_DAYS, "day").toDate(), + }, + }); + console.log(`Downgraded: ${member.user.email}`); + usersDowngraded.push(member.user.username || `${member.user.id}`); + }; + for (const member of illegalProUsers) { + const metadata = (member.user.metadata as Prisma.JsonObject) ?? {}; + // if their pro is already sponsored by a team, do not downgrade + if (metadata.proPaidForTeamId !== undefined) continue; + + const stripeCustomerId = await getStripeCustomerIdFromUserId(member.user.id); + if (!stripeCustomerId) { + await downgrade(member); + continue; + } + + const customer = await stripe.customers.retrieve(stripeCustomerId, { + expand: ["subscriptions.data.plan"], + }); + if (!customer || customer.deleted) { + await downgrade(member); + continue; + } + + const subscription = customer.subscriptions?.data[0]; + if (!subscription) { + await downgrade(member); + continue; + } + + const hasProPlan = !!subscription.items.data.find( + (item) => item.plan.id === getProPlanPrice() || item.plan.id === getPremiumPlanPrice() + ); + // if they're pro, do not downgrade + if (hasProPlan) continue; + + await downgrade(member); + } + return { + usersDowngraded, + usersDowngradedAmount: usersDowngraded.length, + }; +} + +downgradeIllegalProUsers() + .then(({ usersDowngraded, usersDowngradedAmount }) => { + console.log(`Downgraded ${usersDowngradedAmount} illegal pro users`); + console.table(usersDowngraded); + }) + .catch((e) => { + console.error(e); + process.exit(1); + }); diff --git a/apps/web/ee/lib/stripe/server.ts b/apps/web/ee/lib/stripe/server.ts index eb762be1..41ba27b8 100644 --- a/apps/web/ee/lib/stripe/server.ts +++ b/apps/web/ee/lib/stripe/server.ts @@ -2,10 +2,11 @@ import { PaymentType, Prisma } from "@prisma/client"; import Stripe from "stripe"; import { v4 as uuidv4 } from "uuid"; +import prisma from "@calcom/prisma"; + import { sendAwaitingPaymentEmail, sendOrganizerPaymentRefundFailedEmail } from "@lib/emails/email-manager"; import { getErrorFromUnknown } from "@lib/errors"; import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar"; -import prisma from "@lib/prisma"; import { createPaymentLink } from "./client"; diff --git a/apps/web/ee/lib/stripe/team-billing.ts b/apps/web/ee/lib/stripe/team-billing.ts index c36f7298..caeee245 100644 --- a/apps/web/ee/lib/stripe/team-billing.ts +++ b/apps/web/ee/lib/stripe/team-billing.ts @@ -1,17 +1,17 @@ import { MembershipRole, Prisma, UserPlan } from "@prisma/client"; import Stripe from "stripe"; -import { getStripeCustomerFromUser } from "@ee/lib/stripe/customer"; +import prisma from "@calcom/prisma"; +import { getStripeCustomerIdFromUserId } from "@ee/lib/stripe/customer"; import { HOSTED_CAL_FEATURES } from "@lib/config/constants"; import { HttpError } from "@lib/core/http/error"; -import prisma from "@lib/prisma"; import stripe from "./server"; // get team owner's Pro Plan subscription from Cal userId export async function getProPlanSubscription(userId: number) { - const stripeCustomerId = await getStripeCustomerFromUser(userId); + const stripeCustomerId = await getStripeCustomerIdFromUserId(userId); if (!stripeCustomerId) return null; const customer = await stripe.customers.retrieve(stripeCustomerId, { @@ -82,11 +82,17 @@ export async function upgradeTeam(userId: number, teamId: number) { const { membersMissingSeats, ownerIsMissingSeat } = await getMembersMissingSeats(teamId); if (!subscription) { - const customer = await getStripeCustomerFromUser(userId); - if (!customer) throw new HttpError({ statusCode: 400, message: "User has no Stripe customer" }); + let customerId = await getStripeCustomerIdFromUserId(userId); + if (!customerId) { + // create stripe customer if it doesn't already exist + const res = await stripe.customers.create({ + email: ownerUser.user.email, + }); + customerId = res.id; + } // create a checkout session with the quantity of missing seats const session = await createCheckoutSession( - customer, + customerId, membersMissingSeats.length, teamId, ownerIsMissingSeat @@ -257,19 +263,20 @@ export async function ensureSubscriptionQuantityCorrectness(userId: number, team } } +const isProductionSite = + process.env.NEXT_PUBLIC_BASE_URL === "https://app.cal.com" && process.env.VERCEL_ENV === "production"; + // TODO: these should be moved to env vars export function getPerSeatProPlanPrice(): string { - return process.env.NODE_ENV === "production" - ? "price_1KHkoeH8UDiwIftkkUbiggsM" - : "price_1KLD4GH8UDiwIftkWQfsh1Vh"; + return isProductionSite ? "price_1KHkoeH8UDiwIftkkUbiggsM" : "price_1KLD4GH8UDiwIftkWQfsh1Vh"; } export function getProPlanPrice(): string { - return process.env.NODE_ENV === "production" - ? "price_1KHkoeH8UDiwIftkkUbiggsM" - : "price_1JZ0J3H8UDiwIftk0YIHYKr8"; + return isProductionSite ? "price_1KHkoeH8UDiwIftkkUbiggsM" : "price_1JZ0J3H8UDiwIftk0YIHYKr8"; } export function getPremiumPlanPrice(): string { - return process.env.NODE_ENV === "production" - ? "price_1Jv3CMH8UDiwIftkFgyXbcHN" - : "price_1Jv3CMH8UDiwIftkFgyXbcHN"; + return isProductionSite ? "price_1Jv3CMH8UDiwIftkFgyXbcHN" : "price_1Jv3CMH8UDiwIftkFgyXbcHN"; +} + +export function getProPlanProduct(): string { + return isProductionSite ? "prod_JVxwoOF5odFiZ8" : "prod_KDRBg0E4HyVZee"; } diff --git a/apps/web/ee/pages/api/integrations/stripepayment/portal.ts b/apps/web/ee/pages/api/integrations/stripepayment/portal.ts index 3234bb2a..d1ba8682 100644 --- a/apps/web/ee/pages/api/integrations/stripepayment/portal.ts +++ b/apps/web/ee/pages/api/integrations/stripepayment/portal.ts @@ -1,6 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; -import { getStripeCustomerFromUser } from "@ee/lib/stripe/customer"; +import { getStripeCustomerIdFromUserId } from "@ee/lib/stripe/customer"; import stripe from "@ee/lib/stripe/server"; import { getSession } from "@lib/auth"; @@ -15,7 +15,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return; } - const customerId = await getStripeCustomerFromUser(session.user.id); + const customerId = await getStripeCustomerIdFromUserId(session.user.id); if (!customerId) { res.status(500).json({ message: "Missing customer id" }); diff --git a/apps/web/lib/prisma.ts b/apps/web/lib/prisma.ts index ea146d1a..3f0ea376 100644 --- a/apps/web/lib/prisma.ts +++ b/apps/web/lib/prisma.ts @@ -1,20 +1 @@ -import { PrismaClient } from "@prisma/client"; - -import { IS_PRODUCTION } from "@lib/config/constants"; - -declare global { - // eslint-disable-next-line no-var - var prisma: PrismaClient | undefined; -} - -export const prisma = - globalThis.prisma || - new PrismaClient({ - // log: ["query", "error", "warn"], - }); - -if (!IS_PRODUCTION) { - globalThis.prisma = prisma; -} - -export default prisma; +export { default } from "@calcom/prisma"; diff --git a/apps/web/package.json b/apps/web/package.json index 3e6a1148..5f224785 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,7 +18,8 @@ "start": "next start", "lint": "next lint", "lint:fix": "next lint . --ext .ts,.js,.tsx,.jsx --fix", - "check-changed-files": "ts-node scripts/ts-check-changed-files.ts" + "check-changed-files": "ts-node scripts/ts-check-changed-files.ts", + "downgrade": "ts-node ee/lib/stripe/downgrade.ts" }, "engines": { "node": ">=14.x", @@ -135,7 +136,7 @@ "eslint": "^8.9.0", "tailwindcss": "^3.0.0", "ts-jest": "^26.0.0", - "ts-node": "^10.2.1", + "ts-node": "^10.6.0", "typescript": "^4.5.3" } } diff --git a/apps/web/pages/api/cron/downgradeUsers.ts b/apps/web/pages/api/cron/downgradeUsers.ts index fd60c8d2..1a0fd303 100644 --- a/apps/web/pages/api/cron/downgradeUsers.ts +++ b/apps/web/pages/api/cron/downgradeUsers.ts @@ -27,9 +27,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, where: { plan: "TRIAL", - createdDate: { - lt: dayjs().subtract(TRIAL_LIMIT_DAYS, "day").toDate(), - }, + OR: [ + /** + * If the user doesn't have a trial end date, + * use the default 14 day trial from creation. + */ + { + createdDate: { + lt: dayjs().subtract(TRIAL_LIMIT_DAYS, "day").toDate(), + }, + trialEndsAt: null, + }, + /** If it does, then honor the trial end date. */ + { + trialEndsAt: { + lt: dayjs().toDate(), + }, + }, + ], }, }); res.json({ ok: true }); diff --git a/apps/web/server/createContext.ts b/apps/web/server/createContext.ts index 60544411..b3cabdfb 100644 --- a/apps/web/server/createContext.ts +++ b/apps/web/server/createContext.ts @@ -67,6 +67,7 @@ async function getUserFromSession({ destinationCalendar: true, locale: true, timeFormat: true, + trialEndsAt: true, }, }); diff --git a/apps/web/server/routers/viewer.tsx b/apps/web/server/routers/viewer.tsx index de0be464..76cc6c85 100644 --- a/apps/web/server/routers/viewer.tsx +++ b/apps/web/server/routers/viewer.tsx @@ -78,6 +78,7 @@ const loggedInViewerRouter = createProtectedRouter() timeFormat: user.timeFormat, avatar: user.avatar, createdDate: user.createdDate, + trialEndsAt: user.trialEndsAt, completedOnboarding: user.completedOnboarding, twoFactorEnabled: user.twoFactorEnabled, identityProvider: user.identityProvider, diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index a8423c25..53588175 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -6,7 +6,8 @@ "@components/*": ["components/*"], "@lib/*": ["lib/*"], "@server/*": ["server/*"], - "@ee/*": ["ee/*"] + "@ee/*": ["ee/*"], + "@prisma/client/*": ["@calcom/prisma/client/*"] }, "typeRoots": ["./types"], "types": ["@types/jest"] diff --git a/packages/prisma/index.ts b/packages/prisma/index.ts index 4b242287..70180d60 100644 --- a/packages/prisma/index.ts +++ b/packages/prisma/index.ts @@ -1,7 +1,7 @@ import { PrismaClient } from "@prisma/client"; declare global { - var prisma: PrismaClient; + var prisma: PrismaClient | undefined; } export const prisma = diff --git a/packages/prisma/migrations/20220303171305_adds_user_trial_ends_at/migration.sql b/packages/prisma/migrations/20220303171305_adds_user_trial_ends_at/migration.sql new file mode 100644 index 00000000..7e411999 --- /dev/null +++ b/packages/prisma/migrations/20220303171305_adds_user_trial_ends_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "trialEndsAt" TIMESTAMP(3); diff --git a/packages/prisma/package.json b/packages/prisma/package.json index d63d0814..8abdc277 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -20,7 +20,7 @@ "devDependencies": { "npm-run-all": "^4.1.5", "prisma": "3.9.2", - "ts-node": "^10.2.1", + "ts-node": "^10.6.0", "zod-prisma": "^0.5.4" }, "dependencies": { @@ -29,6 +29,11 @@ }, "main": "index.ts", "types": "index.d.ts", + "files": [ + "client", + "zod", + "zod-utils.ts" + ], "prisma": { "seed": "ts-node ./seed.ts" } diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 9f38dd52..2fefa2b7 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -121,6 +121,7 @@ model User { hideBranding Boolean @default(false) theme String? createdDate DateTime @default(now()) @map(name: "created") + trialEndsAt DateTime? eventTypes EventType[] @relation("user_eventtype") credentials Credential[] teams Membership[] @@ -353,6 +354,6 @@ model Webhook { createdAt DateTime @default(now()) active Boolean @default(true) eventTriggers WebhookTriggerEvents[] - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) } diff --git a/packages/tsconfig/base.json b/packages/tsconfig/base.json index 0fcd67ee..26743a80 100644 --- a/packages/tsconfig/base.json +++ b/packages/tsconfig/base.json @@ -18,6 +18,9 @@ }, "exclude": ["node_modules"], "ts-node": { + "files": true, + "require": ["tsconfig-paths/register"], + "experimentalResolverFeatures": true, "compilerOptions": { "module": "CommonJS", "types": ["node"] diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index 61926e7f..0b6f2ee0 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -7,5 +7,8 @@ "base.json", "nextjs.json", "react-library.json" - ] + ], + "devDependencies": { + "tsconfig-paths": "^3.12.0" + } } diff --git a/yarn.lock b/yarn.lock index 0a8fe979..ab67685c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13900,10 +13900,10 @@ ts-morph@^13.0.2: "@ts-morph/common" "~0.12.3" code-block-writer "^11.0.0" -ts-node@^10.2.1: - version "10.5.0" - resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.5.0.tgz#618bef5854c1fbbedf5e31465cbb224a1d524ef9" - integrity sha512-6kEJKwVxAJ35W4akuiysfKwKmjkbYxwQMTBaAxo9KKAx/Yd26mPUyhGz3ji+EsJoAgrLqVsYHNuuYwQe22lbtw== +ts-node@^10.6.0: + version "10.6.0" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.6.0.tgz#c3f4195d5173ce3affdc8f2fd2e9a7ac8de5376a" + integrity sha512-CJen6+dfOXolxudBQXnVjRVvYTmTWbyz7cn+xq2XTsvnaXbHqr4gXSCNbS2Jj8yTZMuGwUoBESLaOkLascVVvg== dependencies: "@cspotcode/source-map-support" "0.7.0" "@tsconfig/node10" "^1.0.7"