feat: add 1 month, 3 month purchase

This commit is contained in:
Tianzhou Chen
2023-05-21 23:48:12 +08:00
parent d2ad07f973
commit e3aac03e28
11 changed files with 127 additions and 29 deletions

6
process.d.ts vendored
View File

@ -30,7 +30,11 @@ declare namespace NodeJS {
STRIPE_API_KEY: string;
// Optional. Stripe webhook secret.
STRIPE_WEBHOOK_SECRET: string;
// Optional. Stripe price id for Pro plan 1 month subscription.
NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_1_MONTH_SUBSCRIPTION: string;
// Optional. Stripe price id for Pro plan 3 month subscription.
NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_3_MONTH_SUBSCRIPTION: string;
// Optional. Stripe price id for Pro plan 1 year subscription.
STRIPE_PRICE_ID_PRO_1_YEAR_SUBSCRIPTION: string;
NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_1_YEAR_SUBSCRIPTION: string;
}
}

View File

@ -48,9 +48,9 @@ const AccountView = () => {
<span
className={`${
session?.user.subscription.plan == "PRO"
? "ring-green-600/2 bg-green-50 text-green-700"
: "ring-gray-600/2 bg-gray-50 text-gray-700"
} rounded-full px-3 py-1 ring-1 ring-inset`}
? "ring-green-600/20 bg-green-50 text-green-700"
: "ring-gray-600/20 bg-gray-50 text-gray-700"
} rounded-full px-4 py-1.5 ring-1 ring-inset`}
>
{t(`setting.plan.${session.user.subscription.plan.toLowerCase()}`)}
</span>

View File

@ -59,6 +59,16 @@ const engines = [
name: "TiDB Serverless",
defaultPort: "4000",
},
{
type: Engine.Snowflake,
name: "Snowflake",
defaultPort: "443",
},
{
type: Engine.Hive,
name: "Hive",
defaultPort: "4000",
},
];
const defaultConnection: Connection = {

View File

@ -4,9 +4,11 @@ import { useTranslation } from "react-i18next";
import getStripe from "../utils/get-stripejs";
import { fetchPostJSON } from "../utils/api-helpers";
const checkout = async () => {
const checkout = async (priceId: string) => {
// Create a Checkout Session.
const response = await fetchPostJSON("/api/checkout_sessions", {});
const response = await fetchPostJSON("/api/checkout_sessions", {
price: priceId,
});
if (response.statusCode === 500) {
console.error(response.message);
@ -31,24 +33,62 @@ const PricingView = () => {
const { t } = useTranslation();
const { data: session, status } = useSession();
const tiers = [
{
name: t("setting.plan.1-month"),
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_1_MONTH_SUBSCRIPTION,
priceMonthly: "$5",
buyButton: t("setting.plan.purhcase-1-month"),
},
{
name: t("setting.plan.n-months", {
count: 3,
}),
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_3_MONTH_SUBSCRIPTION,
priceMonthly: "$15",
buyButton: t("setting.plan.purhcase-n-months", {
count: 3,
}),
},
{
name: t("setting.plan.n-months", {
count: 12,
}),
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_1_YEAR_SUBSCRIPTION,
priceMonthly: "$50",
buyButton: t("setting.plan.purhcase-n-months", {
count: 12,
}),
},
];
return (
<div className="bg-white dark:bg-zinc-800">
<span className="rounded-full bg-green-50 px-4 py-1.5 text-xl font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
{"🎈 "} {t(`setting.plan.pro`)}
</span>
<div className="mx-auto max-w-7xl p-6 lg:flex lg:items-center lg:justify-between lg:px-8">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
<div className="bg-white dark:bg-zinc-800 py-4">
<div className="mx-auto max-w-4xl text-center">
<span className="rounded-full bg-green-50 px-4 py-1.5 text-xl font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
{t(`setting.plan.pro-early-bird`)}
</span>
<p className="mt-6 text-4xl font-bold tracking-tight sm:text-5xl">
{t("setting.plan.n-question-per-month", {
count: 1000,
})}
</h2>
<div className="mt-10 flex items-center gap-x-6 lg:mt-0 lg:flex-shrink-0">
<button
className="rounded-md bg-indigo-600 px-3.5 py-2.5 text-xl font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
onClick={() => (session?.user?.email ? checkout() : signIn())}
>
{session?.user?.email ? t("setting.plan.early-bird-checkout") : t("payment.sign-in-to-buy")}
</button>
</p>
</div>
<div className="mt-12 flow-root">
<div className="isolate -mt-16 grid max-w-sm grid-cols-1 gap-y-16 divide-y divide-gray-100 sm:mx-auto lg:-mx-8 lg:mt-0 lg:max-w-none lg:grid-cols-3 lg:divide-x lg:divide-y-0 xl:-mx-4">
{tiers.map((tier) => (
<div key={tier.priceId} className="pt-16 lg:px-8 lg:pt-0 xl:px-14 flex flex-col justify-center">
<h3 id={tier.priceId} className="text-center text-3xl font-semibold leading-7">
{tier.name} - {tier.priceMonthly}
</h3>
<button
onClick={() => (session?.user?.email ? checkout(tier.priceId) : signIn())}
className="mt-6 block rounded-md bg-indigo-600 px-4 py-2.5 text-center text-xl font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
{session?.user?.email ? tier.buyButton : t("payment.sign-in-to-buy")}
</button>
</div>
))}
</div>
</div>
</div>

View File

@ -64,12 +64,16 @@
"guest": "Guest",
"free": "Free",
"pro": "Pro",
"pro-early-bird": "🐤 Early Bird - Pro 50% OFF",
"signup-for-more": "Sign up for more",
"upgrade": "Upgrade",
"renew": "Renew",
"expired": "Expired",
"n-question-per-month": "{{count}} quota / month",
"early-bird-checkout": "Early bird discount, 50% off for 1 year"
"1-month": "1 month",
"n-months": "{{count}} months",
"purhcase-1-month": "Purchase 1 month",
"purhcase-n-months": "Purchase {{count}} months"
},
"subscription": {
"self": "Subscription",

View File

@ -62,12 +62,16 @@
"guest": "Invitada",
"free": "Gratis",
"pro": "Pro",
"pro-early-bird": "🐤 Early Bird - Pro 50% de descuento",
"signup-for-more": "Regístrese para obtener más",
"upgrade": "Mejora",
"renew": "Renovar",
"expired": "Expirado",
"n-question-per-month": "{{count}} Cuota / mes",
"early-bird-checkout": "Descuento por reserva anticipada, 50 % de descuento durante 1 año"
"1-month": "1 mes",
"n-months": "{{count}} meses",
"purhcase-1-month": "Comprar 1 mes",
"purhcase-n-months": "Comprar {{count}} meses"
},
"subscription": {
"self": "Suscripción",

View File

@ -64,12 +64,16 @@
"guest": "访客",
"free": "免费版",
"pro": "专业版",
"pro-early-bird": "🐤 早鸟优惠 - 专业版 5 折",
"signup-for-more": "注册获得额度",
"upgrade": "升级",
"renew": "续费",
"expired": "已过期",
"n-question-per-month": "{{count}} 点额度 / 月",
"early-bird-checkout": "早鸟优惠5 折购买 1 年"
"1-month": "1 个月",
"n-months": "{{count}} 个月",
"purhcase-1-month": "购买 1 个月",
"purhcase-n-months": "购买 {{count}} 个月"
},
"subscription": {
"self": "订阅",

View File

@ -20,7 +20,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
if (req.method === "POST") {
const session = await getServerSession(req, res, authOptions);
const email = session?.user?.email!;
const user = await prisma.user.findUniqueOrThrow({
where: { email: email },
@ -33,7 +32,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
payment_method_types: ["affirm", "alipay", "card", "cashapp", "klarna", "link", "wechat_pay"],
line_items: [
{
price: process.env.STRIPE_PRICE_ID_PRO_1_YEAR_SUBSCRIPTION,
price: req.body.price,
quantity: 1,
},
],
@ -48,7 +47,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
metadata: {
email: session?.user?.email!,
plan: "PRO",
description: "Pro 1 Year (Early Bird)",
price: req.body.price,
},
},
// Link customer if present otherwise pass email and let Stripe create a new customer.

View File

@ -4,6 +4,7 @@ import Cors from "micro-cors";
import { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
import { getSubscriptionByEmail } from "../utils/subscription";
import { getPlanFromPriceId } from "@/utils";
const stripe = new Stripe(process.env.STRIPE_API_KEY, {
// https://github.com/stripe/stripe-node#configuration
@ -47,6 +48,15 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const charge = await stripe.charges.retrieve(paymentIntent.latest_charge as string);
let plan;
try {
plan = getPlanFromPriceId(paymentIntent.metadata.price);
} catch (err) {
console.log(err);
res.status(400).send(`Invalid price id: ${paymentIntent.metadata.price}`);
return;
}
const customerId = paymentIntent.customer as string;
if (customerId) {
// Save the stripe customer id so that we can relate this customer to future payments.
@ -70,7 +80,7 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
createdAt: new Date(paymentIntent.created * 1000),
paymentId: paymentIntent.id,
customerId: customerId || "",
description: paymentIntent.metadata.description,
description: plan.description,
amount: paymentIntent.amount,
currency: paymentIntent.currency,
receipt: charge.receipt_url as string,
@ -82,7 +92,7 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
if (currentSubscription.plan === "FREE" || currentSubscription.canceledAt || currentSubscription.expireAt < new Date().getTime()) {
const today = new Date(new Date().setHours(0, 0, 0, 0));
// Subtract 1 second from the year from now to make it 23:59:59
const yearFromNow = new Date(new Date(new Date().setHours(0, 0, 0, 0)).setFullYear(today.getFullYear() + 1) - 1000);
const yearFromNow = new Date(new Date(new Date().setHours(0, 0, 0, 0)).setMonth(today.getMonth() + plan.month) - 1000);
const subscription: Prisma.SubscriptionUncheckedCreateInput = {
userId: user.id,
email: paymentIntent.metadata.email,
@ -95,7 +105,7 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
} else {
// Extend the current subscription if there is an active paid subscription.
const expireAt = new Date(Math.max(currentSubscription.expireAt, new Date().getTime()));
expireAt.setFullYear(expireAt.getFullYear() + 1);
expireAt.setMonth(expireAt.getMonth() + plan.month);
await prisma.subscription.update({
where: { id: currentSubscription.id },
data: {

View File

@ -6,3 +6,4 @@ export * from "./model";
export * from "./feature";
export * from "./tidb";
export * from "./misc";
export * from "./plan";

22
src/utils/plan.ts Normal file
View File

@ -0,0 +1,22 @@
export const getPlanFromPriceId = (priceId: string) => {
switch (priceId) {
case process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_1_MONTH_SUBSCRIPTION:
return {
month: 1,
description: "Pro 1 Month (Early Bird)",
};
case process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_3_MONTH_SUBSCRIPTION:
return {
month: 1,
description: "Pro 3 Months (Early Bird)",
};
case process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_1_YEAR_SUBSCRIPTION:
return {
month: 12,
description: "Pro 1 Year (Early Bird)",
};
default: {
throw Error(`Invalid price ID ${priceId}`);
}
}
};