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; STRIPE_API_KEY: string;
// Optional. Stripe webhook secret. // Optional. Stripe webhook secret.
STRIPE_WEBHOOK_SECRET: string; 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. // 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 <span
className={`${ className={`${
session?.user.subscription.plan == "PRO" session?.user.subscription.plan == "PRO"
? "ring-green-600/2 bg-green-50 text-green-700" ? "ring-green-600/20 bg-green-50 text-green-700"
: "ring-gray-600/2 bg-gray-50 text-gray-700" : "ring-gray-600/20 bg-gray-50 text-gray-700"
} rounded-full px-3 py-1 ring-1 ring-inset`} } rounded-full px-4 py-1.5 ring-1 ring-inset`}
> >
{t(`setting.plan.${session.user.subscription.plan.toLowerCase()}`)} {t(`setting.plan.${session.user.subscription.plan.toLowerCase()}`)}
</span> </span>

View File

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

View File

@ -4,9 +4,11 @@ import { useTranslation } from "react-i18next";
import getStripe from "../utils/get-stripejs"; import getStripe from "../utils/get-stripejs";
import { fetchPostJSON } from "../utils/api-helpers"; import { fetchPostJSON } from "../utils/api-helpers";
const checkout = async () => { const checkout = async (priceId: string) => {
// Create a Checkout Session. // Create a Checkout Session.
const response = await fetchPostJSON("/api/checkout_sessions", {}); const response = await fetchPostJSON("/api/checkout_sessions", {
price: priceId,
});
if (response.statusCode === 500) { if (response.statusCode === 500) {
console.error(response.message); console.error(response.message);
@ -31,24 +33,62 @@ const PricingView = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: session, status } = useSession(); 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 ( return (
<div className="bg-white dark:bg-zinc-800"> <div className="bg-white dark:bg-zinc-800 py-4">
<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"> <div className="mx-auto max-w-4xl text-center">
{"🎈 "} {t(`setting.plan.pro`)} <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">
</span> {t(`setting.plan.pro-early-bird`)}
<div className="mx-auto max-w-7xl p-6 lg:flex lg:items-center lg:justify-between lg:px-8"> </span>
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl"> <p className="mt-6 text-4xl font-bold tracking-tight sm:text-5xl">
{t("setting.plan.n-question-per-month", { {t("setting.plan.n-question-per-month", {
count: 1000, count: 1000,
})} })}
</h2> </p>
<div className="mt-10 flex items-center gap-x-6 lg:mt-0 lg:flex-shrink-0"> </div>
<button <div className="mt-12 flow-root">
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" <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">
onClick={() => (session?.user?.email ? checkout() : signIn())} {tiers.map((tier) => (
> <div key={tier.priceId} className="pt-16 lg:px-8 lg:pt-0 xl:px-14 flex flex-col justify-center">
{session?.user?.email ? t("setting.plan.early-bird-checkout") : t("payment.sign-in-to-buy")} <h3 id={tier.priceId} className="text-center text-3xl font-semibold leading-7">
</button> {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> </div>
</div> </div>

View File

@ -64,12 +64,16 @@
"guest": "Guest", "guest": "Guest",
"free": "Free", "free": "Free",
"pro": "Pro", "pro": "Pro",
"pro-early-bird": "🐤 Early Bird - Pro 50% OFF",
"signup-for-more": "Sign up for more", "signup-for-more": "Sign up for more",
"upgrade": "Upgrade", "upgrade": "Upgrade",
"renew": "Renew", "renew": "Renew",
"expired": "Expired", "expired": "Expired",
"n-question-per-month": "{{count}} quota / month", "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": { "subscription": {
"self": "Subscription", "self": "Subscription",

View File

@ -62,12 +62,16 @@
"guest": "Invitada", "guest": "Invitada",
"free": "Gratis", "free": "Gratis",
"pro": "Pro", "pro": "Pro",
"pro-early-bird": "🐤 Early Bird - Pro 50% de descuento",
"signup-for-more": "Regístrese para obtener más", "signup-for-more": "Regístrese para obtener más",
"upgrade": "Mejora", "upgrade": "Mejora",
"renew": "Renovar", "renew": "Renovar",
"expired": "Expirado", "expired": "Expirado",
"n-question-per-month": "{{count}} Cuota / mes", "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": { "subscription": {
"self": "Suscripción", "self": "Suscripción",

View File

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

View File

@ -20,7 +20,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
if (req.method === "POST") { if (req.method === "POST") {
const session = await getServerSession(req, res, authOptions);
const email = session?.user?.email!; const email = session?.user?.email!;
const user = await prisma.user.findUniqueOrThrow({ const user = await prisma.user.findUniqueOrThrow({
where: { email: email }, 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"], payment_method_types: ["affirm", "alipay", "card", "cashapp", "klarna", "link", "wechat_pay"],
line_items: [ line_items: [
{ {
price: process.env.STRIPE_PRICE_ID_PRO_1_YEAR_SUBSCRIPTION, price: req.body.price,
quantity: 1, quantity: 1,
}, },
], ],
@ -48,7 +47,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
metadata: { metadata: {
email: session?.user?.email!, email: session?.user?.email!,
plan: "PRO", 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. // 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 { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe"; import Stripe from "stripe";
import { getSubscriptionByEmail } from "../utils/subscription"; import { getSubscriptionByEmail } from "../utils/subscription";
import { getPlanFromPriceId } from "@/utils";
const stripe = new Stripe(process.env.STRIPE_API_KEY, { const stripe = new Stripe(process.env.STRIPE_API_KEY, {
// https://github.com/stripe/stripe-node#configuration // 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 paymentIntent = event.data.object as Stripe.PaymentIntent;
const charge = await stripe.charges.retrieve(paymentIntent.latest_charge as string); 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; const customerId = paymentIntent.customer as string;
if (customerId) { if (customerId) {
// Save the stripe customer id so that we can relate this customer to future payments. // 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), createdAt: new Date(paymentIntent.created * 1000),
paymentId: paymentIntent.id, paymentId: paymentIntent.id,
customerId: customerId || "", customerId: customerId || "",
description: paymentIntent.metadata.description, description: plan.description,
amount: paymentIntent.amount, amount: paymentIntent.amount,
currency: paymentIntent.currency, currency: paymentIntent.currency,
receipt: charge.receipt_url as string, 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()) { if (currentSubscription.plan === "FREE" || currentSubscription.canceledAt || currentSubscription.expireAt < new Date().getTime()) {
const today = new Date(new Date().setHours(0, 0, 0, 0)); 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 // 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 = { const subscription: Prisma.SubscriptionUncheckedCreateInput = {
userId: user.id, userId: user.id,
email: paymentIntent.metadata.email, email: paymentIntent.metadata.email,
@ -95,7 +105,7 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
} else { } else {
// Extend the current subscription if there is an active paid subscription. // Extend the current subscription if there is an active paid subscription.
const expireAt = new Date(Math.max(currentSubscription.expireAt, new Date().getTime())); 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({ await prisma.subscription.update({
where: { id: currentSubscription.id }, where: { id: currentSubscription.id },
data: { data: {

View File

@ -6,3 +6,4 @@ export * from "./model";
export * from "./feature"; export * from "./feature";
export * from "./tidb"; export * from "./tidb";
export * from "./misc"; 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}`);
}
}
};