diff --git a/prisma/migrations/20230521103659_separate_payment_from_subscription/migration.sql b/prisma/migrations/20230521103659_separate_payment_from_subscription/migration.sql new file mode 100644 index 0000000..963155c --- /dev/null +++ b/prisma/migrations/20230521103659_separate_payment_from_subscription/migration.sql @@ -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; diff --git a/prisma/migrations/20230521112004_remove_subscription_status_add_cancel_at/migration.sql b/prisma/migrations/20230521112004_remove_subscription_status_add_cancel_at/migration.sql new file mode 100644 index 0000000..7765269 --- /dev/null +++ b/prisma/migrations/20230521112004_remove_subscription_status_add_cancel_at/migration.sql @@ -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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index adf9174..a142849 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/components/AccountView.tsx b/src/components/AccountView.tsx index c6349ba..dca1abd 100644 --- a/src/components/AccountView.tsx +++ b/src/components/AccountView.tsx @@ -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 = () => {
+ {!!expired && ( + + {t("setting.plan.expired")} + + )} { )}
- + )} diff --git a/src/components/SubscriptionHistoryTable.tsx b/src/components/PaymentHistoryTable.tsx similarity index 72% rename from src/components/SubscriptionHistoryTable.tsx rename to src/components/PaymentHistoryTable.tsx index 60ecbce..59dd1e6 100644 --- a/src/components/SubscriptionHistoryTable.tsx +++ b/src/components/PaymentHistoryTable.tsx @@ -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([]); +const PaymentHistoryTable = () => { + const [list, setList] = useState([]); 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 = () => { - {list.map((subscription) => ( - + {list.map((payment) => ( + - {getDateString(subscription.createdAt)} + {getDateString(payment.createdAt)} - {subscription.description} + {payment.description} - {getCurrencySymbol(subscription.currency.toLocaleUpperCase())} - {subscription.amount / 100} + {getCurrencySymbol(payment.currency.toLocaleUpperCase())} + {payment.amount / 100} - + {t("setting.subscription.view-receipt")} @@ -79,4 +79,4 @@ const SubscriptionHistoryTable = () => { ); }; -export default SubscriptionHistoryTable; +export default PaymentHistoryTable; diff --git a/src/pages/api/subscription.ts b/src/pages/api/payment.ts similarity index 64% rename from src/pages/api/subscription.ts rename to src/pages/api/payment.ts index 2fd4483..fcfe19a 100644 --- a/src/pages/api/subscription.ts +++ b/src/pages/api/payment.ts @@ -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; diff --git a/src/pages/api/utils/payment.ts b/src/pages/api/utils/payment.ts new file mode 100644 index 0000000..179fe00 --- /dev/null +++ b/src/pages/api/utils/payment.ts @@ -0,0 +1,25 @@ +import { Payment } from "@/types"; +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +export const getPaymentListByEmail = async (email: string): Promise => { + 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; +}; diff --git a/src/pages/api/utils/subscription.ts b/src/pages/api/utils/subscription.ts index 0ed85ab..54282fd 100644 --- a/src/pages/api/utils/subscription.ts +++ b/src/pages/api/utils/subscription.ts @@ -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 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 => { - 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; -}; diff --git a/src/pages/api/webhook/index.ts b/src/pages/api/webhook/index.ts index 9ab8e00..8190c5a 100644 --- a/src/pages/api/webhook/index.ts +++ b/src/pages/api/webhook/index.ts @@ -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}`); diff --git a/src/types/subscription.ts b/src/types/subscription.ts index b63f047..f3bbdc1 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -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; }