mirror of
https://github.com/sqlchat/sqlchat.git
synced 2025-09-23 18:43:18 +08:00
feat: separate subscription from payment
This commit is contained in:
@ -0,0 +1,49 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `amount` on the `subscription` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `currency` on the `subscription` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `customer_id` on the `subscription` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `description` on the `subscription` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `payment_id` on the `subscription` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `receipt` on the `subscription` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX "subscription_payment_id_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "subscription" DROP COLUMN "amount",
|
||||
DROP COLUMN "currency",
|
||||
DROP COLUMN "customer_id",
|
||||
DROP COLUMN "description",
|
||||
DROP COLUMN "payment_id",
|
||||
DROP COLUMN "receipt";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "payment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL DEFAULT '',
|
||||
"email" TEXT NOT NULL DEFAULT '',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"payment_id" TEXT NOT NULL DEFAULT '',
|
||||
"customer_id" TEXT NOT NULL DEFAULT '',
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"amount" INTEGER NOT NULL DEFAULT 0,
|
||||
"currency" TEXT NOT NULL DEFAULT '',
|
||||
"receipt" TEXT NOT NULL DEFAULT '',
|
||||
|
||||
CONSTRAINT "payment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "payment_user_id_idx" ON "payment"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "payment_email_idx" ON "payment"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "payment_payment_id_key" ON "payment"("payment_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "payment" ADD CONSTRAINT "payment_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
@ -0,0 +1,12 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `status` on the `subscription` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "subscription" DROP COLUMN "status",
|
||||
ADD COLUMN "canceled_at" TIMESTAMP(3);
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "SubscriptionStatus";
|
@ -58,38 +58,46 @@ model Usage {
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(cuid())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String @default("") @map("user_id")
|
||||
id String @id @default(cuid())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String @default("") @map("user_id")
|
||||
// Denormalize to avoid join with the user table
|
||||
email String @default("") @map("email")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
status SubscriptionStatus @default(ACTIVE)
|
||||
startAt DateTime @default(now()) @map("start_at")
|
||||
expireAt DateTime @default(now()) @map("expire_at")
|
||||
paymentId String @default("") @map("payment_id")
|
||||
customerId String @default("") @map("customer_id")
|
||||
plan SubscriptionPlan
|
||||
description String @default("")
|
||||
amount Int @default(0)
|
||||
currency String @default("")
|
||||
receipt String @default("")
|
||||
email String @default("") @map("email")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
canceledAt DateTime? @map("canceled_at")
|
||||
startAt DateTime @default(now()) @map("start_at")
|
||||
expireAt DateTime @default(now()) @map("expire_at")
|
||||
plan SubscriptionPlan
|
||||
|
||||
@@unique([paymentId])
|
||||
@@index([userId], map: "subscription_user_id_idx")
|
||||
@@index([email], map: "subscription_email_idx")
|
||||
@@map("subscription")
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
ACTIVE
|
||||
CANCELED
|
||||
}
|
||||
|
||||
enum SubscriptionPlan {
|
||||
PRO
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(cuid())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String @default("") @map("user_id")
|
||||
// Denormalize to avoid join with the user table
|
||||
email String @default("") @map("email")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
paymentId String @default("") @map("payment_id")
|
||||
customerId String @default("") @map("customer_id")
|
||||
description String @default("")
|
||||
amount Int @default(0)
|
||||
currency String @default("")
|
||||
receipt String @default("")
|
||||
|
||||
@@unique([paymentId])
|
||||
@@index([userId], map: "payment_user_id_idx")
|
||||
@@index([email], map: "payment_email_idx")
|
||||
@@map("payment")
|
||||
}
|
||||
|
||||
// NextAuth Prisma Schema Begin
|
||||
// Below are the auth related prisma schema to support NextAuth.js. In partiular, the email flow
|
||||
// requires this. https://authjs.dev/reference/adapter/prisma
|
||||
@ -133,6 +141,7 @@ model User {
|
||||
sessions Session[]
|
||||
|
||||
subscriptions Subscription[]
|
||||
payments Payment[]
|
||||
// The stripe customer id corresponds to this user
|
||||
stripeId String? @unique @map("stripe_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SubscriptionHistoryTable from "./SubscriptionHistoryTable";
|
||||
import PaymentHistoryTable from "./PaymentHistoryTable";
|
||||
import { getDateString } from "@/utils";
|
||||
|
||||
const AccountView = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const expired = session?.user?.subscription?.expireAt && session?.user?.subscription?.expireAt < Date.now();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!session && (
|
||||
@ -38,6 +40,11 @@ const AccountView = () => {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex text-base font-semibold tracking-tight items-center">
|
||||
{!!expired && (
|
||||
<span className="mr-2 rounded-full bg-yellow-50 px-3 py-1 font-medium text-yellow-700 ring-1 ring-inset ring-yellow-600/20">
|
||||
{t("setting.plan.expired")}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`${
|
||||
session?.user.subscription.plan == "PRO"
|
||||
@ -61,7 +68,7 @@ const AccountView = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SubscriptionHistoryTable />
|
||||
<PaymentHistoryTable />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
@ -2,18 +2,18 @@ import axios from "axios";
|
||||
import { t } from "i18next";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SubscriptionPurchase } from "@/types";
|
||||
import { Payment } from "@/types";
|
||||
import { getCurrencySymbol, getDateString } from "@/utils";
|
||||
|
||||
const SubscriptionHistoryTable = () => {
|
||||
const [list, setList] = useState<SubscriptionPurchase[]>([]);
|
||||
const PaymentHistoryTable = () => {
|
||||
const [list, setList] = useState<Payment[]>([]);
|
||||
const { data: session } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
const refreshSubscriptionList = async (userId: string) => {
|
||||
let list: SubscriptionPurchase[] = [];
|
||||
const refreshPaymentList = async (userId: string) => {
|
||||
let list: Payment[] = [];
|
||||
try {
|
||||
const { data } = await axios.get("/api/subscription", {
|
||||
const { data } = await axios.get("/api/payment", {
|
||||
headers: { Authorization: `Bearer ${userId}` },
|
||||
});
|
||||
list = data;
|
||||
@ -24,7 +24,7 @@ const SubscriptionHistoryTable = () => {
|
||||
};
|
||||
|
||||
if (session?.user.id) {
|
||||
refreshSubscriptionList(session.user.id);
|
||||
refreshPaymentList(session.user.id);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
@ -53,18 +53,18 @@ const SubscriptionHistoryTable = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{list.map((subscription) => (
|
||||
<tr key={subscription.id}>
|
||||
{list.map((payment) => (
|
||||
<tr key={payment.id}>
|
||||
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
||||
{getDateString(subscription.createdAt)}
|
||||
{getDateString(payment.createdAt)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{subscription.description}</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{payment.description}</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{getCurrencySymbol(subscription.currency.toLocaleUpperCase())}
|
||||
{subscription.amount / 100}
|
||||
{getCurrencySymbol(payment.currency.toLocaleUpperCase())}
|
||||
{payment.amount / 100}
|
||||
</td>
|
||||
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<a href={subscription.receipt} target="_blank" className="text-indigo-600 hover:text-indigo-900">
|
||||
<a href={payment.receipt} target="_blank" className="text-indigo-600 hover:text-indigo-900">
|
||||
{t("setting.subscription.view-receipt")}
|
||||
</a>
|
||||
</td>
|
||||
@ -79,4 +79,4 @@ const SubscriptionHistoryTable = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionHistoryTable;
|
||||
export default PaymentHistoryTable;
|
@ -1,6 +1,6 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getEndUser } from "./auth/end-user";
|
||||
import { getSubscriptionListByEmail } from "./utils/subscription";
|
||||
import { getPaymentListByEmail } from "./utils/payment";
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
@ -8,9 +8,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
}
|
||||
|
||||
const endUser = await getEndUser(req, res);
|
||||
const subscriptionList = await getSubscriptionListByEmail(endUser);
|
||||
const paymentList = await getPaymentListByEmail(endUser);
|
||||
|
||||
res.status(200).json(subscriptionList);
|
||||
res.status(200).json(paymentList);
|
||||
};
|
||||
|
||||
export default handler;
|
25
src/pages/api/utils/payment.ts
Normal file
25
src/pages/api/utils/payment.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Payment } from "@/types";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export const getPaymentListByEmail = async (email: string): Promise<Payment[]> => {
|
||||
const payments = await prisma.payment.findMany({
|
||||
where: { email: email },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const result: Payment[] = [];
|
||||
for (const payment of payments) {
|
||||
result.push({
|
||||
id: payment.id,
|
||||
email: payment.email,
|
||||
createdAt: payment.createdAt.getTime(),
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
receipt: payment.receipt,
|
||||
description: payment.description,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
import { PlanConfig, PlanType, Subscription, SubscriptionPurchase } from "@/types";
|
||||
import { PrismaClient, SubscriptionStatus } from "@prisma/client";
|
||||
import { PlanConfig, PlanType, Subscription } from "@/types";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@ -9,65 +9,39 @@ export const getSubscriptionByEmail = async (email: string): Promise<Subscriptio
|
||||
orderBy: { expireAt: "desc" },
|
||||
});
|
||||
|
||||
// Return the latest ACTIVE, not-expired subscription if exists.
|
||||
// Return the latest active subscription if exists.
|
||||
for (const subscription of subscriptions) {
|
||||
const result = {
|
||||
const result: Subscription = {
|
||||
id: subscription.id,
|
||||
plan: subscription.plan as PlanType,
|
||||
quota: PlanConfig[subscription.plan].quota,
|
||||
status: subscription.status,
|
||||
startAt: subscription.startAt.getTime(),
|
||||
expireAt: subscription.expireAt.getTime(),
|
||||
canceledAt: subscription.canceledAt?.getTime(),
|
||||
};
|
||||
if (subscription.status === SubscriptionStatus.ACTIVE && subscription.expireAt.getTime() > Date.now()) {
|
||||
if (!result.canceledAt && result.expireAt > Date.now()) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the latest ACTIVE, expired subscripion if exists.
|
||||
// Return the latest subscripion if exists.
|
||||
for (const subscription of subscriptions) {
|
||||
const result = {
|
||||
return {
|
||||
id: subscription.id,
|
||||
plan: subscription.plan as PlanType,
|
||||
quota: PlanConfig["FREE"].quota,
|
||||
status: subscription.status,
|
||||
startAt: subscription.startAt.getTime(),
|
||||
expireAt: subscription.expireAt.getTime(),
|
||||
canceledAt: subscription.canceledAt?.getTime(),
|
||||
};
|
||||
if (subscription.status === SubscriptionStatus.ACTIVE) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Return a FREE subscription.
|
||||
return {
|
||||
id: "",
|
||||
plan: "FREE",
|
||||
quota: PlanConfig["FREE"].quota,
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
startAt: 0,
|
||||
expireAt: 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const getSubscriptionListByEmail = async (email: string): Promise<SubscriptionPurchase[]> => {
|
||||
const subscriptions = await prisma.subscription.findMany({
|
||||
where: { email: email },
|
||||
orderBy: { expireAt: "desc" },
|
||||
});
|
||||
|
||||
// Return the latest ACTIVE, not-expired subscription if exists.
|
||||
const result: SubscriptionPurchase[] = [];
|
||||
for (const subscription of subscriptions) {
|
||||
result.push({
|
||||
id: subscription.id,
|
||||
email: subscription.email,
|
||||
amount: subscription.amount,
|
||||
currency: subscription.currency,
|
||||
receipt: subscription.receipt,
|
||||
plan: subscription.plan as PlanType,
|
||||
description: subscription.description,
|
||||
createdAt: subscription.createdAt.getTime(),
|
||||
startAt: subscription.startAt.getTime(),
|
||||
expireAt: subscription.expireAt.getTime(),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
@ -3,6 +3,7 @@ import { buffer } from "micro";
|
||||
import Cors from "micro-cors";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import Stripe from "stripe";
|
||||
import { getSubscriptionByEmail } from "../utils/subscription";
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_API_KEY, {
|
||||
// https://github.com/stripe/stripe-node#configuration
|
||||
@ -63,25 +64,45 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
where: { email: paymentIntent.metadata.email },
|
||||
});
|
||||
|
||||
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 subscription: Prisma.SubscriptionUncheckedCreateInput = {
|
||||
const payment: Prisma.PaymentUncheckedCreateInput = {
|
||||
userId: user.id,
|
||||
email: paymentIntent.metadata.email,
|
||||
createdAt: new Date(paymentIntent.created * 1000),
|
||||
status: "ACTIVE",
|
||||
startAt: today,
|
||||
expireAt: yearFromNow,
|
||||
paymentId: paymentIntent.id,
|
||||
customerId: customerId || "",
|
||||
plan: paymentIntent.metadata.plan as SubscriptionPlan,
|
||||
description: paymentIntent.metadata.description,
|
||||
amount: paymentIntent.amount,
|
||||
currency: paymentIntent.currency,
|
||||
receipt: charge.receipt_url as string,
|
||||
};
|
||||
await prisma.subscription.create({ data: subscription });
|
||||
await prisma.payment.create({ data: payment });
|
||||
|
||||
const currentSubscription = await getSubscriptionByEmail(paymentIntent.metadata.email);
|
||||
// Create a new subscription if there is no active paid subscription.
|
||||
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 subscription: Prisma.SubscriptionUncheckedCreateInput = {
|
||||
userId: user.id,
|
||||
email: paymentIntent.metadata.email,
|
||||
createdAt: new Date(paymentIntent.created * 1000),
|
||||
startAt: today,
|
||||
expireAt: yearFromNow,
|
||||
plan: paymentIntent.metadata.plan as SubscriptionPlan,
|
||||
};
|
||||
await prisma.subscription.create({ data: subscription });
|
||||
} 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);
|
||||
await prisma.subscription.update({
|
||||
where: { id: currentSubscription.id },
|
||||
data: {
|
||||
expireAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (event.type === "payment_intent.payment_failed") {
|
||||
const paymentIntent = event.data.object as Stripe.PaymentIntent;
|
||||
console.log(`❌ Payment failed: ${paymentIntent.last_payment_error?.message}`);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { SubscriptionPlan, SubscriptionStatus } from "@prisma/client";
|
||||
import { SubscriptionPlan } from "@prisma/client";
|
||||
|
||||
export type PlanType = SubscriptionPlan | "GUEST" | "FREE";
|
||||
|
||||
@ -20,22 +20,20 @@ export const PlanConfig: {
|
||||
};
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
plan: PlanType;
|
||||
quota: number;
|
||||
status: SubscriptionStatus;
|
||||
startAt: number;
|
||||
expireAt: number;
|
||||
canceledAt?: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionPurchase {
|
||||
export interface Payment {
|
||||
id: string;
|
||||
email: string;
|
||||
createdAt: number;
|
||||
amount: number;
|
||||
currency: string;
|
||||
receipt: string;
|
||||
plan: PlanType;
|
||||
description: string;
|
||||
createdAt: number;
|
||||
startAt: number;
|
||||
expireAt: number;
|
||||
}
|
||||
|
Reference in New Issue
Block a user