feat: add subscription_status column to user table and update related schema

- Introduced a new column `subscription_status` to the user table for improved subscription management.
- Updated the user schema in the database to reflect this change.
- Added a new migration file and snapshot for versioning.
- Updated the StripeProvider to handle subscription status updates in webhook events.
This commit is contained in:
javayhu 2025-04-04 13:58:08 +08:00
parent ab8d85be00
commit 2ab25a402b
8 changed files with 503 additions and 71 deletions

View File

@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "subscription_status" text;

View File

@ -0,0 +1,386 @@
{
"id": "4542a435-02d6-4849-9970-c11721171a00",
"prevId": "95005672-7c98-424f-b955-b44ddeaf6903",
"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.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
},
"subscription_id": {
"name": "subscription_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"subscription_status": {
"name": "subscription_status",
"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

@ -57,6 +57,13 @@
"when": 1743693630071,
"tag": "0007_overconfident_adam_destine",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1743743427477,
"tag": "0008_curious_pepper_potts",
"breakpoints": true
}
]
}

View File

@ -1,15 +1,6 @@
import { handleWebhookEvent } from '@/payment';
import { NextRequest, NextResponse } from 'next/server';
/**
* Disable body parsing as we need the raw body for signature verification
*/
export const config = {
api: {
bodyParser: false,
},
};
/**
* Stripe webhook handler
* This endpoint receives webhook events from Stripe and processes them

View File

@ -1,5 +1,4 @@
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
/**
* https://orm.drizzle.team/docs/get-started/neon-new
@ -7,8 +6,17 @@ import { neon } from '@neondatabase/serverless';
*
* Using the browser-compatible Neon HTTP driver for better compatibility with Next.js
* This avoids the Node.js-specific modules that cause build issues
*
* With the neon-http and neon-websockets drivers, you can access a Neon database from serverless environments over HTTP or WebSockets instead of TCP.
* Querying over HTTP is faster for single, non-interactive transactions.
*/
const sql = neon(process.env.DATABASE_URL!);
const db = drizzle({ client: sql });
// If you need to provide your existing drivers:
// import { neon } from '@neondatabase/serverless';
// const sql = neon(process.env.DATABASE_URL!);
// const db = drizzle({ client: sql });
// https://orm.drizzle.team/docs/connect-neon
const db = drizzle(process.env.DATABASE_URL!);
export default db;

View File

@ -15,6 +15,7 @@ export const user = pgTable("user", {
banExpires: timestamp('ban_expires'),
customerId: text('customer_id'),
subscriptionId: text('subscription_id'),
subscriptionStatus: text('subscription_status'),
lifetimeMember: boolean('lifetime_member')
});

View File

@ -1,6 +1,7 @@
import { PaymentProvider, PricePlan, PaymentConfig, Customer, Subscription, Payment, PaymentStatus, PlanInterval, PaymentType, Price, CreateCheckoutParams, CheckoutResult, CreatePortalParams, PortalResult, GetCustomerParams, GetSubscriptionParams, WebhookEventHandler, ListCustomerSubscriptionsParams } from "./types";
import { StripeProvider } from "./provider/stripe";
import { paymentConfig } from "./config/payment-config";
/**
* Default payment configuration
*/

View File

@ -1,14 +1,18 @@
import Stripe from 'stripe';
import { PaymentProvider, CreateCheckoutParams, CheckoutResult, CreatePortalParams, PortalResult, GetCustomerParams, Customer, GetSubscriptionParams, Subscription, PaymentStatus, PlanInterval, WebhookEventHandler, PaymentType, PaymentTypes, ListCustomerSubscriptionsParams } from '../types';
import { getPlanById, findPriceInPlan } from '../index';
import db from '@/db/index';
import { user } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
/**
* Stripe payment provider implementation
*/
export class StripeProvider implements PaymentProvider {
private stripe: Stripe;
private webhookSecret: string;
private webhookHandlers: Map<string, WebhookEventHandler[]>;
private webhookSecret: string | undefined;
/**
* Initialize Stripe provider with API key
@ -19,14 +23,14 @@ export class StripeProvider implements PaymentProvider {
throw new Error('STRIPE_SECRET_KEY environment variable is not set');
}
this.webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!this.webhookSecret) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
throw new Error('STRIPE_WEBHOOK_SECRET environment variable is not set.');
}
// Initialize Stripe without specifying apiVersion to use default/latest version
this.stripe = new Stripe(apiKey);
this.webhookSecret = webhookSecret;
this.webhookHandlers = new Map();
}
@ -121,12 +125,6 @@ export class StripeProvider implements PaymentProvider {
*/
private async updateUserWithCustomerId(customerId: string, email: string): Promise<void> {
try {
// Dynamic import to avoid circular dependencies
// TODO: can we avoid using dynamic import?
const { default: db } = await import('@/db/index');
const { user } = await import('@/db/schema');
const { eq } = await import('drizzle-orm');
// Update user record with customer ID if email matches
const result = await db
.update(user)
@ -405,26 +403,21 @@ export class StripeProvider implements PaymentProvider {
*/
public async handleWebhookEvent(payload: string, signature: string): Promise<void> {
let event: Stripe.Event;
console.log('handle webhook event, hook secret:', this.webhookSecret);
try {
// Verify the event signature if webhook secret is available
if (this.webhookSecret) {
event = this.stripe.webhooks.constructEvent(
payload,
signature,
this.webhookSecret
);
} else {
// Parse the event payload without verification
event = JSON.parse(payload) as Stripe.Event;
}
event = this.stripe.webhooks.constructEvent(
payload,
signature,
this.webhookSecret
);
console.log(`Received Stripe webhook event: ${event.type}`);
// Process the event based on type
const handlers = this.webhookHandlers.get(event.type) || [];
const defaultHandlers = this.webhookHandlers.get('*') || [];
const allHandlers = [...handlers, ...defaultHandlers];
// If no custom handlers are registered, use default handling logic
@ -435,7 +428,7 @@ export class StripeProvider implements PaymentProvider {
await Promise.all(allHandlers.map(handler => handler(event)));
}
} catch (error) {
console.error('Handle webhook event failed:', error);
console.error('handle webhook event error:', error);
throw new Error('Failed to handle webhook event');
}
}
@ -451,58 +444,102 @@ export class StripeProvider implements PaymentProvider {
// Handle subscription events
if (eventType.startsWith('customer.subscription.')) {
const subscription = event.data.object as Stripe.Subscription;
console.log(`Subscription ${subscription.id} is ${subscription.status}`);
// Process based on subscription status
console.log(`Processing subscription ${subscription.id}, status: ${subscription.status}`);
// Get customerId from subscription
const customerId = subscription.customer as string;
// Process based on subscription status and event type
switch (eventType) {
case 'customer.subscription.created':
// Handle subscription creation
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 });
if (result.length > 0) {
console.log(`Updated user ${customerId} with subscription ${subscription.id}`);
} else {
console.warn(`Update operation performed but no rows were updated for customerId ${customerId}`);
}
break;
case 'customer.subscription.updated':
// Handle subscription update
}
case 'customer.subscription.updated': {
// Subscription was updated - update status
await db
.update(user)
.set({
subscriptionStatus: subscription.status,
updatedAt: new Date()
})
.where(eq(user.customerId, customerId) && eq(user.subscriptionId, subscription.id));
console.log(`Updated subscription status for user ${customerId} to ${subscription.status}`);
break;
case 'customer.subscription.deleted':
// Handle subscription cancellation
}
case 'customer.subscription.deleted': {
// Subscription was cancelled/deleted - remove from user
await db
.update(user)
.set({
subscriptionId: null,
subscriptionStatus: 'canceled',
updatedAt: new Date()
})
.where(eq(user.customerId, customerId) && eq(user.subscriptionId, subscription.id));
console.log(`Removed subscription from user ${customerId}`);
break;
case 'customer.subscription.trial_will_end':
// Handle trial ending soon
break;
}
}
// Handle payment events
else if (eventType.startsWith('payment_intent.')) {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
console.log(`Payment ${paymentIntent.id} is ${paymentIntent.status}`);
switch (eventType) {
case 'payment_intent.succeeded':
// Handle successful payment
break;
case 'payment_intent.payment_failed':
// Handle failed payment
}
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}`);
break;
}
}
}
// Handle checkout events
else if (eventType.startsWith('checkout.')) {
if (eventType === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
console.log(`Checkout session ${session.id} completed`);
// Handle completed checkout
if (session.mode === 'subscription') {
// Handle subscription checkout
const subscriptionId = session.subscription as string;
console.log(`New subscription: ${subscriptionId}`);
} else if (session.mode === 'payment') {
// Handle one-time payment checkout
const paymentIntentId = session.payment_intent as string;
console.log(`One-time payment: ${paymentIntentId}`);
// Only process one-time payments (likely for lifetime plan)
if (session.mode === 'payment') {
const customerId = session.customer as string;
console.log(`Processing one-time payment for customer ${customerId}`);
// Check if this was for a lifetime plan (via metadata)
const metadata = session.metadata || {};
const planId = metadata.planId;
if (planId === 'lifetime') {
// Mark user as lifetime member
await db
.update(user)
.set({
lifetimeMember: true,
updatedAt: new Date()
})
.where(eq(user.customerId, customerId));
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}`);
}
}
}
}
} catch (error) {
console.error('Default webhook handler failed:', error);
console.error('default webhook handler error:', error);
throw new Error('Failed to handle webhook event');
}
}
}