feat: add subscription management features and database schema updates

- Introduced new database tables for managing subscriptions and user subscriptions.
- Updated package.json with new database commands for generation, migration, and studio.
- Refactored the user schema to remove deprecated fields and added foreign key relationships.
- Enhanced Stripe payment provider logic to handle subscription creation, updates, and cancellations.
- Improved error handling and logging for subscription events.
This commit is contained in:
javayhu 2025-04-06 11:12:27 +08:00
parent e9c9d9f451
commit 2248cfdc35
10 changed files with 1163 additions and 55 deletions

View File

@ -0,0 +1,27 @@
CREATE TABLE "subscription" (
"id" text PRIMARY KEY NOT NULL,
"plan_id" text NOT NULL,
"price_id" text NOT NULL,
"reference_id" text NOT NULL,
"customer_id" text,
"subscription_id" text,
"status" text NOT NULL,
"period_start" timestamp,
"period_end" timestamp,
"cancel_at_period_end" boolean,
"trial_start" timestamp,
"trial_end" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_subscriptions" (
"user_id" text NOT NULL,
"subscription_id" text NOT NULL,
CONSTRAINT "user_subscriptions_user_id_subscription_id_pk" PRIMARY KEY("user_id","subscription_id")
);
--> statement-breakpoint
ALTER TABLE "user_subscriptions" ADD CONSTRAINT "user_subscriptions_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_subscriptions" ADD CONSTRAINT "user_subscriptions_subscription_id_subscription_id_fk" FOREIGN KEY ("subscription_id") REFERENCES "public"."subscription"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "subscription_id";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "subscription_status";

View File

@ -0,0 +1,5 @@
ALTER TABLE "user_subscriptions" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
DROP TABLE "user_subscriptions" CASCADE;--> statement-breakpoint
ALTER TABLE "subscription" ADD COLUMN "user_id" text NOT NULL;--> statement-breakpoint
ALTER TABLE "subscription" ADD CONSTRAINT "subscription_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "subscription" DROP COLUMN "reference_id";

View File

@ -0,0 +1,5 @@
ALTER TABLE "user_subscriptions" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
DROP TABLE "user_subscriptions" CASCADE;--> statement-breakpoint
ALTER TABLE "subscription" ADD COLUMN "user_id" text NOT NULL;--> statement-breakpoint
ALTER TABLE "subscription" ADD CONSTRAINT "subscription_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "subscription" DROP COLUMN "reference_id";

View File

@ -0,0 +1,533 @@
{
"id": "0c38a6eb-f695-430a-8d70-c60a2cbb745a",
"prevId": "4542a435-02d6-4849-9970-c11721171a00",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.subscription": {
"name": "subscription",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"plan_id": {
"name": "plan_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_id": {
"name": "price_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reference_id": {
"name": "reference_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"subscription_id": {
"name": "subscription_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"period_start": {
"name": "period_start",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"period_end": {
"name": "period_end",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"cancel_at_period_end": {
"name": "cancel_at_period_end",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"trial_start": {
"name": "trial_start",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"trial_end": {
"name": "trial_end",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"lifetime_member": {
"name": "lifetime_member",
"type": "boolean",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
},
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_subscriptions": {
"name": "user_subscriptions",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"subscription_id": {
"name": "subscription_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"user_subscriptions_user_id_user_id_fk": {
"name": "user_subscriptions_user_id_user_id_fk",
"tableFrom": "user_subscriptions",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_subscriptions_subscription_id_subscription_id_fk": {
"name": "user_subscriptions_subscription_id_subscription_id_fk",
"tableFrom": "user_subscriptions",
"tableTo": "subscription",
"columnsFrom": [
"subscription_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_subscriptions_user_id_subscription_id_pk": {
"name": "user_subscriptions_user_id_subscription_id_pk",
"columns": [
"user_id",
"subscription_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,487 @@
{
"id": "92ae96f1-74f5-4253-b5c4-d287b7de7d9e",
"prevId": "0c38a6eb-f695-430a-8d70-c60a2cbb745a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.subscription": {
"name": "subscription",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"plan_id": {
"name": "plan_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_id": {
"name": "price_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"subscription_id": {
"name": "subscription_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"period_start": {
"name": "period_start",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"period_end": {
"name": "period_end",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"cancel_at_period_end": {
"name": "cancel_at_period_end",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"trial_start": {
"name": "trial_start",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"trial_end": {
"name": "trial_end",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"subscription_user_id_user_id_fk": {
"name": "subscription_user_id_user_id_fk",
"tableFrom": "subscription",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"lifetime_member": {
"name": "lifetime_member",
"type": "boolean",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
},
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -64,6 +64,20 @@
"when": 1743743427477,
"tag": "0008_curious_pepper_potts",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1743905967329,
"tag": "0009_uneven_morgan_stark",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1743906787012,
"tag": "0010_cooing_millenium_guard",
"breakpoints": true
}
]
}

View File

@ -7,6 +7,9 @@
"build": "content-collections build && next build",
"start": "next start",
"lint": "next lint",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"docs": "content-collections build",
"email": "email dev --dir src/mail/emails --port 3333"
},

View File

@ -1,4 +1,4 @@
import { pgTable, text, integer, timestamp, boolean } from "drizzle-orm/pg-core";
import { pgTable, text, integer, timestamp, boolean, primaryKey, foreignKey } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
@ -15,8 +15,6 @@ export const user = pgTable("user", {
banExpires: timestamp('ban_expires'),
customerId: text('customer_id'),
lifetimeMember: boolean('lifetime_member'),
subscriptionId: text('subscription_id'),
subscriptionStatus: text('subscription_status'),
});
export const session = pgTable("session", {
@ -55,3 +53,20 @@ export const verification = pgTable("verification", {
createdAt: timestamp('created_at'),
updatedAt: timestamp('updated_at')
});
export const subscription = pgTable("subscription", {
id: text("id").primaryKey(),
planId: text('plan_id').notNull(),
priceId: text('price_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
customerId: text('customer_id'),
subscriptionId: text('subscription_id'),
status: text('status').notNull(),
periodStart: timestamp('period_start'),
periodEnd: timestamp('period_end'),
cancelAtPeriodEnd: boolean('cancel_at_period_end'),
trialStart: timestamp('trial_start'),
trialEnd: timestamp('trial_end'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
});

View File

@ -123,18 +123,6 @@ export const auth = betterAuth({
defaultValue: false,
input: false, // don't allow user to set lifetimeMember
},
subscriptionId: {
type: "string",
required: false,
defaultValue: "",
input: false, // don't allow user to set subscriptionId
},
subscriptionStatus: {
type: "string",
required: false,
defaultValue: "",
input: false, // don't allow user to set subscriptionStatus
},
},
// https://www.better-auth.com/docs/concepts/users-accounts#delete-user
deleteUser: {

View File

@ -1,5 +1,6 @@
import db from '@/db/index';
import { user } from '@/db/schema';
import { subscription as subscriptionTable, user } from '@/db/schema';
import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
import Stripe from 'stripe';
import { findPriceInPlan, getPlanById } from '../index';
@ -176,7 +177,7 @@ export class StripeProvider implements PaymentProvider {
...metadata,
},
};
// Add trial period if applicable
if (price.trialPeriodDays && price.trialPeriodDays > 0) {
checkoutParams.subscription_data.trial_period_days = price.trialPeriodDays;
@ -341,7 +342,6 @@ export class StripeProvider implements PaymentProvider {
? new Date(subscription.trial_end * 1000)
: undefined,
createdAt: new Date(subscription.created * 1000),
updatedAt: new Date(),
};
});
} catch (error) {
@ -382,64 +382,88 @@ export class StripeProvider implements PaymentProvider {
// Handle subscription events
if (eventType.startsWith('customer.subscription.')) {
const subscription = event.data.object as Stripe.Subscription;
console.log(`Processing subscription ${subscription.id}, status: ${subscription.status}`);
const stripeSubscription = event.data.object as Stripe.Subscription;
// Get customerId from subscription
const customerId = subscription.customer as string;
const customerId = stripeSubscription.customer as string;
console.log(`Processing subscription ${stripeSubscription.id} for user ${customerId}, status: ${stripeSubscription.status}`);
// Extract information from metadata, then we do not need to query the database for userId
const userId = stripeSubscription.metadata.userId || 'unknown';
const planId = stripeSubscription.metadata.planId || 'unknown';
const priceId = stripeSubscription.metadata.priceId || stripeSubscription.items.data[0]?.price.id || 'unknown';
// Process based on subscription status and event type
switch (eventType) {
case 'customer.subscription.created': {
// New subscription created - update user record with subscription ID and status
const result = await db
.update(user)
.set({
subscriptionId: subscription.id,
subscriptionStatus: subscription.status,
updatedAt: new Date()
})
.where(eq(user.customerId, customerId))
.returning({ id: user.id });
// Create new subscription record with a unique ID
const newSubscriptionId = randomUUID();
const result = await db.insert(subscriptionTable).values({
id: newSubscriptionId,
planId: planId,
priceId: priceId,
userId: userId,
customerId: customerId,
subscriptionId: stripeSubscription.id,
status: this.mapSubscriptionStatus(stripeSubscription.status),
periodStart: stripeSubscription.current_period_start ? new Date(stripeSubscription.current_period_start * 1000) : null,
periodEnd: stripeSubscription.current_period_end ? new Date(stripeSubscription.current_period_end * 1000) : null,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
trialStart: stripeSubscription.trial_start ? new Date(stripeSubscription.trial_start * 1000) : null,
trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null,
createdAt: new Date(),
updatedAt: new Date()
}).returning({ id: subscriptionTable.id });
if (result.length > 0) {
console.log(`Updated user ${customerId} with subscription ${subscription.id}`);
console.log(`Created new subscription ${newSubscriptionId} for Stripe subscription ${stripeSubscription.id}`);
} else {
console.warn(`Update operation performed but no rows were updated for customerId ${customerId}`);
console.warn(`No subscription created for Stripe subscription ${stripeSubscription.id}`);
}
break;
}
case 'customer.subscription.updated': {
// Subscription was updated - update status
await db
.update(user)
// Update subscription record
const result = await db
.update(subscriptionTable)
.set({
subscriptionStatus: subscription.status,
status: this.mapSubscriptionStatus(stripeSubscription.status),
periodStart: stripeSubscription.current_period_start ? new Date(stripeSubscription.current_period_start * 1000) : undefined,
periodEnd: stripeSubscription.current_period_end ? new Date(stripeSubscription.current_period_end * 1000) : undefined,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : undefined,
updatedAt: new Date()
})
.where(eq(user.customerId, customerId) && eq(user.subscriptionId, subscription.id));
.where(eq(subscriptionTable.subscriptionId, stripeSubscription.id))
.returning({ id: subscriptionTable.id });
console.log(`Updated subscription status for user ${customerId} to ${subscription.status}`);
if (result.length > 0) {
console.log(`Updated subscription ${result[0].id} for Stripe subscription ${stripeSubscription.id}`);
} else {
console.warn(`No subscription found for Stripe subscription ${stripeSubscription.id}`);
}
break;
}
case 'customer.subscription.deleted': {
// Subscription was cancelled/deleted - remove from user
await db
.update(user)
// Update subscription record, set status to canceled
const result = await db
.update(subscriptionTable)
.set({
subscriptionId: null,
subscriptionStatus: 'canceled',
status: this.mapSubscriptionStatus(stripeSubscription.status),
updatedAt: new Date()
})
.where(eq(user.customerId, customerId) && eq(user.subscriptionId, subscription.id));
.where(eq(subscriptionTable.subscriptionId, stripeSubscription.id))
.returning({ id: subscriptionTable.id });
console.log(`Removed subscription from user ${customerId}`);
if (result.length > 0) {
console.log(`Marked subscription ${stripeSubscription.id} as canceled`);
} else {
console.warn(`No subscription found to cancel for Stripe subscription ${stripeSubscription.id}`);
}
break;
}
case 'customer.subscription.trial_will_end': {
// Trial ending soon - we could trigger an email notification here
console.log(`Trial ending soon for subscription ${subscription.id}, customerId ${customerId}`);
// Just log for now, could send notifications or update the subscription record
console.log(`Trial ending soon for subscription ${stripeSubscription.id}, customerId ${customerId}`);
break;
}
}
@ -460,22 +484,28 @@ export class StripeProvider implements PaymentProvider {
// Safely handle the case where planId might not exist
if (!planId) {
console.log(`No planId found in metadata for checkout session ${session.id}, customerId: ${customerId}`);
console.log(`No planId found for checkout session ${session.id}`);
return;
}
const plan = getPlanById(planId);
if (plan && plan.isLifetime) {
// Mark user as lifetime member
await db
// Mark user as lifetime member by customerId
const result = await db
.update(user)
.set({
lifetimeMember: true,
updatedAt: new Date()
})
.where(eq(user.customerId, customerId));
.where(eq(user.customerId, customerId))
.returning({ id: user.id });
console.log(`Marked user ${customerId} as lifetime member`);
if (result.length === 0) {
console.warn(`No user ${customerId} marked as lifetime member`);
return;
} else {
console.log(`Marked user ${customerId} as lifetime member`);
}
} else {
// Handle other one-time payments if needed, like increase user credits
console.log(`One-time payment for non-lifetime plan: ${planId}, customerId: ${customerId}`);
@ -521,6 +551,7 @@ export class StripeProvider implements PaymentProvider {
/**
* Convert Stripe subscription status to PaymentStatus
* We narrow down the status to our own status types
* @param status Stripe subscription status
* @returns PaymentStatus
*/