Admin/team billing downgrader (#2040)

* downgrade func

* fix security hole lol

* fix query conditions

* - set to trial not free
- auto create stripe customer if missing
- fix production check

* Extracts downgrade logic to script, fixes ts-node conflicts with prisma

* Adds trialEndsAt field to users

* Updates trial/downgrade logic

* Typo

* Legibility fixes

* Update team-billing.ts

* Legibility improvements

Co-authored-by: Jamie <ijamespine@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Omar López 2022-03-03 12:29:19 -07:00 committed by GitHub
parent f4b6a16a9e
commit 0a8509d721
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 174 additions and 60 deletions

View File

@ -1 +0,0 @@
export * from "@calcom/prisma/client";

View File

@ -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 (
<div

View File

@ -1,11 +1,12 @@
import { Prisma } from "@prisma/client";
import stripe from "@ee/lib/stripe/server";
import prisma from "@calcom/prisma";
import { HttpError as HttpCode } from "@lib/core/http/error";
import { prisma } from "@lib/prisma";
export async function getStripeCustomerFromUser(userId: number) {
import stripe from "./server";
export async function getStripeCustomerIdFromUserId(userId: number) {
// Get user
const user = await prisma.user.findUnique({
where: {

View File

@ -0,0 +1,90 @@
#!/usr/bin/env ts-node
// To run this script: `yarn downgrade 2>&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);
});

View File

@ -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";

View File

@ -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";
}

View File

@ -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" });

View File

@ -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";

View File

@ -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"
}
}

View File

@ -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 });

View File

@ -67,6 +67,7 @@ async function getUserFromSession({
destinationCalendar: true,
locale: true,
timeFormat: true,
trialEndsAt: true,
},
});

View File

@ -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,

View File

@ -6,7 +6,8 @@
"@components/*": ["components/*"],
"@lib/*": ["lib/*"],
"@server/*": ["server/*"],
"@ee/*": ["ee/*"]
"@ee/*": ["ee/*"],
"@prisma/client/*": ["@calcom/prisma/client/*"]
},
"typeRoots": ["./types"],
"types": ["@types/jest"]

View File

@ -1,7 +1,7 @@
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient;
var prisma: PrismaClient | undefined;
}
export const prisma =

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "trialEndsAt" TIMESTAMP(3);

View File

@ -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"
}

View File

@ -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)
}

View File

@ -18,6 +18,9 @@
},
"exclude": ["node_modules"],
"ts-node": {
"files": true,
"require": ["tsconfig-paths/register"],
"experimentalResolverFeatures": true,
"compilerOptions": {
"module": "CommonJS",
"types": ["node"]

View File

@ -7,5 +7,8 @@
"base.json",
"nextjs.json",
"react-library.json"
]
],
"devDependencies": {
"tsconfig-paths": "^3.12.0"
}
}

View File

@ -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"