feat: add annual plans for teams/organizations (#27896)
* wip: add annual plan priceId support * fix type errors * type cast
This commit is contained in:
@@ -209,11 +209,13 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
||||
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||
STRIPE_TEAM_ANNUAL_PRICE_ID=
|
||||
NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID=
|
||||
ORG_MONTHLY_CREDITS=
|
||||
STRIPE_TEAM_PRODUCT_ID=
|
||||
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
|
||||
STRIPE_ORG_MONTHLY_PRICE_ID=
|
||||
STRIPE_ORG_ANNUAL_PRICE_ID=
|
||||
STRIPE_ORG_PRODUCT_ID=
|
||||
# Used to pass trial days for orgs during the Stripe checkout session
|
||||
STRIPE_ORG_TRIAL_DAYS=
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { IOrganizationPermissionService } from "./OrganizationPermissionSer
|
||||
|
||||
vi.stubEnv("STRIPE_ORG_PRODUCT_ID", "STRIPE_ORG_PRODUCT_ID");
|
||||
vi.stubEnv("STRIPE_ORG_MONTHLY_PRICE_ID", "STRIPE_ORG_MONTHLY_PRICE_ID");
|
||||
vi.stubEnv("STRIPE_ORG_ANNUAL_PRICE_ID", "STRIPE_ORG_ANNUAL_PRICE_ID");
|
||||
const defaultOrgOnboarding = {
|
||||
id: "onboard-id-1",
|
||||
name: "Test Org",
|
||||
@@ -209,6 +210,50 @@ describe("OrganizationPaymentService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should use annual fixed price when billingPeriod is ANNUALLY and no custom price", async () => {
|
||||
vi.mocked(prisma.membership.findMany).mockResolvedValue([]);
|
||||
|
||||
await service.createPaymentIntent(
|
||||
{
|
||||
bio: "BIO",
|
||||
logo: "LOGO",
|
||||
teams: [],
|
||||
},
|
||||
{
|
||||
...defaultOrgOnboarding,
|
||||
billingPeriod: "ANNUALLY",
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockBillingService.createSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
priceId: "STRIPE_ORG_ANNUAL_PRICE_ID",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should use monthly fixed price when billingPeriod is MONTHLY and no custom price", async () => {
|
||||
vi.mocked(prisma.membership.findMany).mockResolvedValue([]);
|
||||
|
||||
await service.createPaymentIntent(
|
||||
{
|
||||
bio: "BIO",
|
||||
logo: "LOGO",
|
||||
teams: [],
|
||||
},
|
||||
{
|
||||
...defaultOrgOnboarding,
|
||||
billingPeriod: "MONTHLY",
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockBillingService.createSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
priceId: "STRIPE_ORG_MONTHLY_PRICE_ID",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe("admin overrides", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionService.hasPermissionToModifyDefaultPayment.mockReturnValue(true);
|
||||
|
||||
@@ -219,11 +219,21 @@ export class OrganizationPaymentService {
|
||||
})
|
||||
);
|
||||
|
||||
if (!process.env.STRIPE_ORG_PRODUCT_ID || !process.env.STRIPE_ORG_MONTHLY_PRICE_ID) {
|
||||
throw new Error("STRIPE_ORG_PRODUCT_ID or STRIPE_ORG_MONTHLY_PRICE_ID is not set");
|
||||
if (!process.env.STRIPE_ORG_PRODUCT_ID) {
|
||||
throw new Error("STRIPE_ORG_PRODUCT_ID is not set");
|
||||
}
|
||||
|
||||
const fixedPriceId =
|
||||
config.billingPeriod === "ANNUALLY"
|
||||
? process.env.STRIPE_ORG_ANNUAL_PRICE_ID
|
||||
: process.env.STRIPE_ORG_MONTHLY_PRICE_ID;
|
||||
|
||||
if (!fixedPriceId) {
|
||||
const envVar =
|
||||
config.billingPeriod === "ANNUALLY" ? "STRIPE_ORG_ANNUAL_PRICE_ID" : "STRIPE_ORG_MONTHLY_PRICE_ID";
|
||||
throw new Error(`${envVar} is not set`);
|
||||
}
|
||||
|
||||
const fixedPriceId = process.env.STRIPE_ORG_MONTHLY_PRICE_ID;
|
||||
if (!shouldCreateCustomPrice) {
|
||||
return {
|
||||
priceId: fixedPriceId,
|
||||
|
||||
@@ -6,6 +6,7 @@ import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { BillingPeriod } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import {
|
||||
generateTeamCheckoutSession,
|
||||
getTeamWithPaymentMetadata,
|
||||
purchaseTeamOrOrgSubscription,
|
||||
updateQuantitySubscriptionFromStripe,
|
||||
@@ -13,7 +14,9 @@ import {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubEnv("STRIPE_ORG_MONTHLY_PRICE_ID", "STRIPE_ORG_MONTHLY_PRICE_ID");
|
||||
vi.stubEnv("STRIPE_ORG_ANNUAL_PRICE_ID", "STRIPE_ORG_ANNUAL_PRICE_ID");
|
||||
vi.stubEnv("STRIPE_TEAM_MONTHLY_PRICE_ID", "STRIPE_TEAM_MONTHLY_PRICE_ID");
|
||||
vi.stubEnv("STRIPE_TEAM_ANNUAL_PRICE_ID", "STRIPE_TEAM_ANNUAL_PRICE_ID");
|
||||
vi.resetAllMocks();
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
@@ -36,6 +39,12 @@ vi.mock("@calcom/app-store/stripepayment/lib/customer", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/features/auth/lib/dub", () => {
|
||||
return {
|
||||
getDubCustomer: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/features/ee/payments/server/stripe", () => {
|
||||
return {
|
||||
default: {
|
||||
@@ -58,6 +67,74 @@ vi.mock("@calcom/features/ee/payments/server/stripe", () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe("generateTeamCheckoutSession", () => {
|
||||
it("should use monthly price by default", async () => {
|
||||
const checkoutSessionsCreate = mockStripeCheckoutSessionsCreate({
|
||||
url: "SESSION_URL",
|
||||
});
|
||||
|
||||
await generateTeamCheckoutSession({
|
||||
teamName: "Test Team",
|
||||
teamSlug: "test-team",
|
||||
userId: 1,
|
||||
});
|
||||
|
||||
expect(checkoutSessionsCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
line_items: [
|
||||
expect.objectContaining({
|
||||
price: "STRIPE_TEAM_MONTHLY_PRICE_ID",
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should use annual price when billingPeriod is ANNUALLY", async () => {
|
||||
const checkoutSessionsCreate = mockStripeCheckoutSessionsCreate({
|
||||
url: "SESSION_URL",
|
||||
});
|
||||
|
||||
await generateTeamCheckoutSession({
|
||||
teamName: "Test Team",
|
||||
teamSlug: "test-team",
|
||||
userId: 1,
|
||||
billingPeriod: BillingPeriod.ANNUALLY,
|
||||
});
|
||||
|
||||
expect(checkoutSessionsCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
line_items: [
|
||||
expect.objectContaining({
|
||||
price: "STRIPE_TEAM_ANNUAL_PRICE_ID",
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should store billingPeriod in checkout session metadata", async () => {
|
||||
const checkoutSessionsCreate = mockStripeCheckoutSessionsCreate({
|
||||
url: "SESSION_URL",
|
||||
});
|
||||
|
||||
await generateTeamCheckoutSession({
|
||||
teamName: "Test Team",
|
||||
teamSlug: "test-team",
|
||||
userId: 1,
|
||||
billingPeriod: BillingPeriod.ANNUALLY,
|
||||
});
|
||||
|
||||
expect(checkoutSessionsCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
billingPeriod: "ANNUALLY",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("purchaseTeamOrOrgSubscription", () => {
|
||||
it("should use `seatsToChargeFor` to create price", async () => {
|
||||
const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID";
|
||||
@@ -268,6 +345,116 @@ describe("purchaseTeamOrOrgSubscription", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should use annual fixed price when billingPeriod is ANNUALLY and no custom price", async () => {
|
||||
const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID";
|
||||
const user = await prismock.user.create({
|
||||
data: {
|
||||
name: "test",
|
||||
email: "test@email.com",
|
||||
},
|
||||
});
|
||||
|
||||
const checkoutSessionsCreate = mockStripeCheckoutSessionsCreate({
|
||||
url: "SESSION_URL",
|
||||
});
|
||||
|
||||
mockStripeCheckoutSessionRetrieve(
|
||||
{
|
||||
currency: "USD",
|
||||
product: {
|
||||
id: "PRODUCT_ID",
|
||||
},
|
||||
},
|
||||
[FAKE_PAYMENT_ID]
|
||||
);
|
||||
|
||||
const team = await prismock.team.create({
|
||||
data: {
|
||||
name: "test",
|
||||
metadata: {
|
||||
paymentId: FAKE_PAYMENT_ID,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
await purchaseTeamOrOrgSubscription({
|
||||
teamId: team.id,
|
||||
seatsUsed: 10,
|
||||
userId: user.id,
|
||||
isOrg: false,
|
||||
pricePerSeat: null,
|
||||
billingPeriod: BillingPeriod.ANNUALLY,
|
||||
})
|
||||
).toEqual({ url: "SESSION_URL" });
|
||||
|
||||
expect(checkoutSessionsCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
line_items: [
|
||||
{
|
||||
price: "STRIPE_TEAM_ANNUAL_PRICE_ID",
|
||||
quantity: 10,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should use annual fixed price for org when billingPeriod is ANNUALLY and no custom price", async () => {
|
||||
const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID";
|
||||
const user = await prismock.user.create({
|
||||
data: {
|
||||
name: "test",
|
||||
email: "test@email.com",
|
||||
},
|
||||
});
|
||||
|
||||
const checkoutSessionsCreate = mockStripeCheckoutSessionsCreate({
|
||||
url: "SESSION_URL",
|
||||
});
|
||||
|
||||
mockStripeCheckoutSessionRetrieve(
|
||||
{
|
||||
currency: "USD",
|
||||
product: {
|
||||
id: "PRODUCT_ID",
|
||||
},
|
||||
},
|
||||
[FAKE_PAYMENT_ID]
|
||||
);
|
||||
|
||||
const team = await prismock.team.create({
|
||||
data: {
|
||||
name: "test",
|
||||
metadata: {
|
||||
paymentId: FAKE_PAYMENT_ID,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
await purchaseTeamOrOrgSubscription({
|
||||
teamId: team.id,
|
||||
seatsUsed: 5,
|
||||
userId: user.id,
|
||||
isOrg: true,
|
||||
pricePerSeat: null,
|
||||
billingPeriod: BillingPeriod.ANNUALLY,
|
||||
})
|
||||
).toEqual({ url: "SESSION_URL" });
|
||||
|
||||
expect(checkoutSessionsCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
line_items: [
|
||||
{
|
||||
price: "STRIPE_ORG_ANNUAL_PRICE_ID",
|
||||
quantity: 5,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("It should not create a custom price if price_per_seat is not set", async () => {
|
||||
const FAKE_PAYMENT_ID = "FAKE_PAYMENT_ID";
|
||||
const user = await prismock.user.create({
|
||||
|
||||
@@ -43,6 +43,21 @@ export const checkIfTeamPaymentRequired = async ({ teamId = -1 }) => {
|
||||
return { url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id=${metadata.paymentId}` };
|
||||
};
|
||||
|
||||
function getTeamPriceId(billingPeriod: BillingPeriod): string {
|
||||
const priceId =
|
||||
billingPeriod === "ANNUALLY"
|
||||
? process.env.STRIPE_TEAM_ANNUAL_PRICE_ID
|
||||
: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID;
|
||||
|
||||
if (!priceId) {
|
||||
throw new Error(
|
||||
`Missing env var: ${billingPeriod === "ANNUALLY" ? "STRIPE_TEAM_ANNUAL_PRICE_ID" : "STRIPE_TEAM_MONTHLY_PRICE_ID"}`
|
||||
);
|
||||
}
|
||||
|
||||
return priceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to generate a checkout session when trying to create a team
|
||||
*/
|
||||
@@ -51,18 +66,21 @@ export const generateTeamCheckoutSession = async ({
|
||||
teamSlug,
|
||||
userId,
|
||||
isOnboarding,
|
||||
billingPeriod = BillingPeriod.MONTHLY,
|
||||
tracking,
|
||||
}: {
|
||||
teamName: string;
|
||||
teamSlug: string;
|
||||
userId: number;
|
||||
isOnboarding?: boolean;
|
||||
billingPeriod?: BillingPeriod;
|
||||
tracking?: TrackingData;
|
||||
}) => {
|
||||
const [customer, dubCustomer] = await Promise.all([
|
||||
getStripeCustomerIdFromUserId(userId),
|
||||
getDubCustomer(userId.toString()),
|
||||
]);
|
||||
const priceId = getTeamPriceId(billingPeriod);
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer,
|
||||
mode: "subscription",
|
||||
@@ -83,7 +101,7 @@ export const generateTeamCheckoutSession = async ({
|
||||
line_items: [
|
||||
{
|
||||
/** We only need to set the base price and we can upsell it directly on Stripe's checkout */
|
||||
price: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID,
|
||||
price: priceId,
|
||||
/**Initially it will be just the team owner */
|
||||
quantity: 1,
|
||||
},
|
||||
@@ -106,6 +124,7 @@ export const generateTeamCheckoutSession = async ({
|
||||
teamName,
|
||||
teamSlug,
|
||||
userId,
|
||||
billingPeriod,
|
||||
dubCustomerId: userId, // pass the userId during checkout creation for sales conversion tracking: https://d.to/conversions/stripe
|
||||
...(isOnboarding !== undefined && { isOnboarding: isOnboarding.toString() }),
|
||||
...(tracking?.googleAds?.gclid && {
|
||||
@@ -269,17 +288,27 @@ export const purchaseTeamOrOrgSubscription = async (input: {
|
||||
* If the organization has a custom price per seat, it will create a new price in stripe and return its ID.
|
||||
*/
|
||||
async function getFixedPrice() {
|
||||
const isAnnual = billingPeriod === "ANNUALLY";
|
||||
const fixedPriceId = isOrg
|
||||
? process.env.STRIPE_ORG_MONTHLY_PRICE_ID
|
||||
: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID;
|
||||
? isAnnual
|
||||
? process.env.STRIPE_ORG_ANNUAL_PRICE_ID
|
||||
: process.env.STRIPE_ORG_MONTHLY_PRICE_ID
|
||||
: isAnnual
|
||||
? process.env.STRIPE_TEAM_ANNUAL_PRICE_ID
|
||||
: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID;
|
||||
|
||||
if (!fixedPriceId) {
|
||||
throw new Error(
|
||||
"You need to have STRIPE_ORG_MONTHLY_PRICE_ID and STRIPE_TEAM_MONTHLY_PRICE_ID env variables set"
|
||||
);
|
||||
const envVar = isOrg
|
||||
? isAnnual
|
||||
? "STRIPE_ORG_ANNUAL_PRICE_ID"
|
||||
: "STRIPE_ORG_MONTHLY_PRICE_ID"
|
||||
: isAnnual
|
||||
? "STRIPE_TEAM_ANNUAL_PRICE_ID"
|
||||
: "STRIPE_TEAM_MONTHLY_PRICE_ID";
|
||||
throw new Error(`Missing env var: ${envVar}`);
|
||||
}
|
||||
|
||||
log.debug("Getting price ID", safeStringify({ fixedPriceId, isOrg, teamId, pricePerSeat }));
|
||||
log.debug("Getting price ID", safeStringify({ fixedPriceId, isOrg, teamId, pricePerSeat, billingPeriod }));
|
||||
|
||||
return fixedPriceId;
|
||||
}
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ import { createHandler } from "./createWithPaymentIntent.handler";
|
||||
|
||||
vi.stubEnv("STRIPE_PRIVATE_KEY", "test-stripe-private-key");
|
||||
vi.stubEnv("STRIPE_ORG_MONTHLY_PRICE_ID", "test-stripe-org-monthly-price-id");
|
||||
vi.stubEnv("STRIPE_ORG_ANNUAL_PRICE_ID", "test-stripe-org-annual-price-id");
|
||||
vi.stubEnv("STRIPE_ORG_PRODUCT_ID", "test-stripe-org-product-id");
|
||||
vi.stubEnv("NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW", "37");
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getTrackingFromCookies } from "@calcom/lib/tracking";
|
||||
import type { TrackingData } from "@calcom/lib/tracking";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { BillingPeriod as BillingPeriodEnum } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -28,12 +29,14 @@ const generateCheckoutSession = async ({
|
||||
teamName,
|
||||
userId,
|
||||
isOnboarding,
|
||||
billingPeriod,
|
||||
tracking,
|
||||
}: {
|
||||
teamSlug: string;
|
||||
teamName: string;
|
||||
userId: number;
|
||||
isOnboarding?: boolean;
|
||||
billingPeriod?: "MONTHLY" | "ANNUALLY";
|
||||
tracking?: TrackingData;
|
||||
}) => {
|
||||
if (!IS_TEAM_BILLING_ENABLED) {
|
||||
@@ -46,6 +49,7 @@ const generateCheckoutSession = async ({
|
||||
teamName,
|
||||
userId,
|
||||
isOnboarding,
|
||||
billingPeriod: billingPeriod as BillingPeriodEnum | undefined,
|
||||
tracking,
|
||||
});
|
||||
|
||||
@@ -59,7 +63,7 @@ const generateCheckoutSession = async ({
|
||||
|
||||
export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
const { user } = ctx;
|
||||
const { slug, name, bio, isOnboarding } = input;
|
||||
const { slug, name, bio, isOnboarding, billingPeriod } = input;
|
||||
const isOrgChildTeam = !!user.profile?.organizationId;
|
||||
|
||||
// For orgs we want to create teams under the org
|
||||
@@ -94,6 +98,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
teamName: name,
|
||||
userId: user.id,
|
||||
isOnboarding,
|
||||
billingPeriod,
|
||||
tracking,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import type { BillingPeriod } from "@calcom/prisma/enums";
|
||||
|
||||
export type TCreateInputSchema = {
|
||||
name: string;
|
||||
@@ -8,6 +9,7 @@ export type TCreateInputSchema = {
|
||||
logo?: string | null;
|
||||
bio?: string;
|
||||
isOnboarding?: boolean;
|
||||
billingPeriod?: BillingPeriod;
|
||||
};
|
||||
|
||||
export const ZCreateInputSchema: z.ZodType<TCreateInputSchema> = z.object({
|
||||
@@ -20,4 +22,5 @@ export const ZCreateInputSchema: z.ZodType<TCreateInputSchema> = z.object({
|
||||
.transform((v) => v || null),
|
||||
bio: z.string().optional(),
|
||||
isOnboarding: z.boolean().optional(),
|
||||
billingPeriod: z.enum(["MONTHLY", "ANNUALLY"]).optional(),
|
||||
});
|
||||
|
||||
@@ -175,8 +175,10 @@
|
||||
"STRIPE_WEBHOOK_SECRET_APPS",
|
||||
"STRIPE_WEBHOOK_SECRET_BILLING",
|
||||
"STRIPE_TEAM_MONTHLY_PRICE_ID",
|
||||
"STRIPE_TEAM_ANNUAL_PRICE_ID",
|
||||
"STRIPE_TEAM_PRODUCT_ID",
|
||||
"STRIPE_ORG_MONTHLY_PRICE_ID",
|
||||
"STRIPE_ORG_ANNUAL_PRICE_ID",
|
||||
"STRIPE_ORG_PRODUCT_ID",
|
||||
"STRIPE_ORG_TRIAL_DAYS",
|
||||
"TANDEM_BASE_URL",
|
||||
@@ -372,10 +374,12 @@
|
||||
"SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE",
|
||||
"STRIPE_PREMIUM_PLAN_PRODUCT_ID",
|
||||
"STRIPE_TEAM_MONTHLY_PRICE_ID",
|
||||
"STRIPE_TEAM_ANNUAL_PRICE_ID",
|
||||
"NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID",
|
||||
"STRIPE_TEAM_PRODUCT_ID",
|
||||
"ORG_MONTHLY_CREDITS",
|
||||
"STRIPE_ORG_MONTHLY_PRICE_ID",
|
||||
"STRIPE_ORG_ANNUAL_PRICE_ID",
|
||||
"STRIPE_ORG_PRODUCT_ID",
|
||||
"STRIPE_ORG_TRIAL_DAYS",
|
||||
"NEXT_PUBLIC_API_V2_URL",
|
||||
|
||||
Reference in New Issue
Block a user