mirror of
https://github.com/sqlchat/sqlchat.git
synced 2025-07-25 16:13:10 +08:00
feat: add 1 month, 3 month purchase
This commit is contained in:
6
process.d.ts
vendored
6
process.d.ts
vendored
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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 = {
|
||||
|
@ -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>
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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": "订阅",
|
||||
|
@ -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.
|
||||
|
@ -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: {
|
||||
|
@ -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
22
src/utils/plan.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
};
|
Reference in New Issue
Block a user