feat: separate subscription from payment

This commit is contained in:
Tianzhou Chen
2023-05-21 20:20:20 +08:00
parent 31bf7adf00
commit 9f0eec2bb1
10 changed files with 190 additions and 95 deletions

View File

@ -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;

View File

@ -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";

View File

@ -58,38 +58,46 @@ model Usage {
} }
model Subscription { model Subscription {
id String @id @default(cuid()) id String @id @default(cuid())
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId String @default("") @map("user_id") userId String @default("") @map("user_id")
// Denormalize to avoid join with the user table // Denormalize to avoid join with the user table
email String @default("") @map("email") email String @default("") @map("email")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
status SubscriptionStatus @default(ACTIVE) canceledAt DateTime? @map("canceled_at")
startAt DateTime @default(now()) @map("start_at") startAt DateTime @default(now()) @map("start_at")
expireAt DateTime @default(now()) @map("expire_at") expireAt DateTime @default(now()) @map("expire_at")
paymentId String @default("") @map("payment_id") plan SubscriptionPlan
customerId String @default("") @map("customer_id")
plan SubscriptionPlan
description String @default("")
amount Int @default(0)
currency String @default("")
receipt String @default("")
@@unique([paymentId])
@@index([userId], map: "subscription_user_id_idx") @@index([userId], map: "subscription_user_id_idx")
@@index([email], map: "subscription_email_idx") @@index([email], map: "subscription_email_idx")
@@map("subscription") @@map("subscription")
} }
enum SubscriptionStatus {
ACTIVE
CANCELED
}
enum SubscriptionPlan { enum SubscriptionPlan {
PRO 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 // NextAuth Prisma Schema Begin
// Below are the auth related prisma schema to support NextAuth.js. In partiular, the email flow // Below are the auth related prisma schema to support NextAuth.js. In partiular, the email flow
// requires this. https://authjs.dev/reference/adapter/prisma // requires this. https://authjs.dev/reference/adapter/prisma
@ -133,6 +141,7 @@ model User {
sessions Session[] sessions Session[]
subscriptions Subscription[] subscriptions Subscription[]
payments Payment[]
// The stripe customer id corresponds to this user // The stripe customer id corresponds to this user
stripeId String? @unique @map("stripe_id") stripeId String? @unique @map("stripe_id")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")

View File

@ -1,13 +1,15 @@
import { signIn, useSession } from "next-auth/react"; import { signIn, useSession } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import SubscriptionHistoryTable from "./SubscriptionHistoryTable"; import PaymentHistoryTable from "./PaymentHistoryTable";
import { getDateString } from "@/utils"; import { getDateString } from "@/utils";
const AccountView = () => { const AccountView = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: session } = useSession(); const { data: session } = useSession();
const expired = session?.user?.subscription?.expireAt && session?.user?.subscription?.expireAt < Date.now();
return ( return (
<> <>
{!session && ( {!session && (
@ -38,6 +40,11 @@ const AccountView = () => {
</Link> </Link>
</div> </div>
<div className="flex text-base font-semibold tracking-tight items-center"> <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 <span
className={`${ className={`${
session?.user.subscription.plan == "PRO" session?.user.subscription.plan == "PRO"
@ -61,7 +68,7 @@ const AccountView = () => {
)} )}
</div> </div>
</div> </div>
<SubscriptionHistoryTable /> <PaymentHistoryTable />
</div> </div>
)} )}
</> </>

View File

@ -2,18 +2,18 @@ import axios from "axios";
import { t } from "i18next"; import { t } from "i18next";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { SubscriptionPurchase } from "@/types"; import { Payment } from "@/types";
import { getCurrencySymbol, getDateString } from "@/utils"; import { getCurrencySymbol, getDateString } from "@/utils";
const SubscriptionHistoryTable = () => { const PaymentHistoryTable = () => {
const [list, setList] = useState<SubscriptionPurchase[]>([]); const [list, setList] = useState<Payment[]>([]);
const { data: session } = useSession(); const { data: session } = useSession();
useEffect(() => { useEffect(() => {
const refreshSubscriptionList = async (userId: string) => { const refreshPaymentList = async (userId: string) => {
let list: SubscriptionPurchase[] = []; let list: Payment[] = [];
try { try {
const { data } = await axios.get("/api/subscription", { const { data } = await axios.get("/api/payment", {
headers: { Authorization: `Bearer ${userId}` }, headers: { Authorization: `Bearer ${userId}` },
}); });
list = data; list = data;
@ -24,7 +24,7 @@ const SubscriptionHistoryTable = () => {
}; };
if (session?.user.id) { if (session?.user.id) {
refreshSubscriptionList(session.user.id); refreshPaymentList(session.user.id);
} }
}, [session]); }, [session]);
@ -53,18 +53,18 @@ const SubscriptionHistoryTable = () => {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200">
{list.map((subscription) => ( {list.map((payment) => (
<tr key={subscription.id}> <tr key={payment.id}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0"> <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>
<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"> <td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{getCurrencySymbol(subscription.currency.toLocaleUpperCase())} {getCurrencySymbol(payment.currency.toLocaleUpperCase())}
{subscription.amount / 100} {payment.amount / 100}
</td> </td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0"> <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")} {t("setting.subscription.view-receipt")}
</a> </a>
</td> </td>
@ -79,4 +79,4 @@ const SubscriptionHistoryTable = () => {
); );
}; };
export default SubscriptionHistoryTable; export default PaymentHistoryTable;

View File

@ -1,6 +1,6 @@
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import { getEndUser } from "./auth/end-user"; import { getEndUser } from "./auth/end-user";
import { getSubscriptionListByEmail } from "./utils/subscription"; import { getPaymentListByEmail } from "./utils/payment";
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") { if (req.method !== "GET") {
@ -8,9 +8,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
} }
const endUser = await getEndUser(req, res); 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; export default handler;

View 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;
};

View File

@ -1,5 +1,5 @@
import { PlanConfig, PlanType, Subscription, SubscriptionPurchase } from "@/types"; import { PlanConfig, PlanType, Subscription } from "@/types";
import { PrismaClient, SubscriptionStatus } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -9,65 +9,39 @@ export const getSubscriptionByEmail = async (email: string): Promise<Subscriptio
orderBy: { expireAt: "desc" }, orderBy: { expireAt: "desc" },
}); });
// Return the latest ACTIVE, not-expired subscription if exists. // Return the latest active subscription if exists.
for (const subscription of subscriptions) { for (const subscription of subscriptions) {
const result = { const result: Subscription = {
id: subscription.id,
plan: subscription.plan as PlanType, plan: subscription.plan as PlanType,
quota: PlanConfig[subscription.plan].quota, quota: PlanConfig[subscription.plan].quota,
status: subscription.status,
startAt: subscription.startAt.getTime(), startAt: subscription.startAt.getTime(),
expireAt: subscription.expireAt.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 result;
} }
} }
// Return the latest ACTIVE, expired subscripion if exists. // Return the latest subscripion if exists.
for (const subscription of subscriptions) { for (const subscription of subscriptions) {
const result = { return {
id: subscription.id,
plan: subscription.plan as PlanType, plan: subscription.plan as PlanType,
quota: PlanConfig["FREE"].quota, quota: PlanConfig["FREE"].quota,
status: subscription.status,
startAt: subscription.startAt.getTime(), startAt: subscription.startAt.getTime(),
expireAt: subscription.expireAt.getTime(), expireAt: subscription.expireAt.getTime(),
canceledAt: subscription.canceledAt?.getTime(),
}; };
if (subscription.status === SubscriptionStatus.ACTIVE) {
return result;
}
} }
// Return a FREE subscription. // Return a FREE subscription.
return { return {
id: "",
plan: "FREE", plan: "FREE",
quota: PlanConfig["FREE"].quota, quota: PlanConfig["FREE"].quota,
status: SubscriptionStatus.ACTIVE,
startAt: 0, startAt: 0,
expireAt: 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;
};

View File

@ -3,6 +3,7 @@ import { buffer } from "micro";
import Cors from "micro-cors"; 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";
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
@ -63,25 +64,45 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
where: { email: paymentIntent.metadata.email }, where: { email: paymentIntent.metadata.email },
}); });
const today = new Date(new Date().setHours(0, 0, 0, 0)); const payment: Prisma.PaymentUncheckedCreateInput = {
// 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, userId: user.id,
email: paymentIntent.metadata.email, email: paymentIntent.metadata.email,
createdAt: new Date(paymentIntent.created * 1000), createdAt: new Date(paymentIntent.created * 1000),
status: "ACTIVE",
startAt: today,
expireAt: yearFromNow,
paymentId: paymentIntent.id, paymentId: paymentIntent.id,
customerId: customerId || "", customerId: customerId || "",
plan: paymentIntent.metadata.plan as SubscriptionPlan,
description: paymentIntent.metadata.description, description: paymentIntent.metadata.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,
}; };
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") { } else if (event.type === "payment_intent.payment_failed") {
const paymentIntent = event.data.object as Stripe.PaymentIntent; const paymentIntent = event.data.object as Stripe.PaymentIntent;
console.log(`❌ Payment failed: ${paymentIntent.last_payment_error?.message}`); console.log(`❌ Payment failed: ${paymentIntent.last_payment_error?.message}`);

View File

@ -1,4 +1,4 @@
import { SubscriptionPlan, SubscriptionStatus } from "@prisma/client"; import { SubscriptionPlan } from "@prisma/client";
export type PlanType = SubscriptionPlan | "GUEST" | "FREE"; export type PlanType = SubscriptionPlan | "GUEST" | "FREE";
@ -20,22 +20,20 @@ export const PlanConfig: {
}; };
export interface Subscription { export interface Subscription {
id: string;
plan: PlanType; plan: PlanType;
quota: number; quota: number;
status: SubscriptionStatus;
startAt: number; startAt: number;
expireAt: number; expireAt: number;
canceledAt?: number;
} }
export interface SubscriptionPurchase { export interface Payment {
id: string; id: string;
email: string; email: string;
createdAt: number;
amount: number; amount: number;
currency: string; currency: string;
receipt: string; receipt: string;
plan: PlanType;
description: string; description: string;
createdAt: number;
startAt: number;
expireAt: number;
} }