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