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

@ -64,30 +64,38 @@ model Subscription {
// Denormalize to avoid join with the user table
email String @default("") @map("email")
createdAt DateTime @default(now()) @map("created_at")
status SubscriptionStatus @default(ACTIVE)
canceledAt DateTime? @map("canceled_at")
startAt DateTime @default(now()) @map("start_at")
expireAt DateTime @default(now()) @map("expire_at")
plan SubscriptionPlan
@@index([userId], map: "subscription_user_id_idx")
@@index([email], map: "subscription_email_idx")
@@map("subscription")
}
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")
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([email], map: "subscription_email_idx")
@@map("subscription")
}
enum SubscriptionStatus {
ACTIVE
CANCELED
}
enum SubscriptionPlan {
PRO
@@index([userId], map: "payment_user_id_idx")
@@index([email], map: "payment_email_idx")
@@map("payment")
}
// NextAuth Prisma Schema Begin
@ -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")

View File

@ -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>
)}
</>

View File

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

View File

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

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

View File

@ -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,6 +64,22 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
where: { email: paymentIntent.metadata.email },
});
const payment: Prisma.PaymentUncheckedCreateInput = {
userId: user.id,
email: paymentIntent.metadata.email,
createdAt: new Date(paymentIntent.created * 1000),
paymentId: paymentIntent.id,
customerId: customerId || "",
description: paymentIntent.metadata.description,
amount: paymentIntent.amount,
currency: paymentIntent.currency,
receipt: charge.receipt_url as string,
};
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);
@ -70,18 +87,22 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
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 });
} 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}`);

View File

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