From 9e0bd57ecc984a12901a63dae15836ab31a8fe24 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 22 May 2025 00:30:10 +0800 Subject: [PATCH 1/7] feat: support credits --- src/actions/credits.action.ts | 63 +++++ .../[locale]/(protected)/dashboard/page.tsx | 62 ++++- src/db/schema.ts | 25 ++ src/lib/constants.ts | 22 ++ src/lib/credits.ts | 251 ++++++++++++++++++ 5 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 src/actions/credits.action.ts create mode 100644 src/lib/credits.ts diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts new file mode 100644 index 0000000..4511b00 --- /dev/null +++ b/src/actions/credits.action.ts @@ -0,0 +1,63 @@ +import { CREDIT_TRANSACTION_TYPE } from '@/lib/constants'; +import { + addCredits, + addMonthlyFreeCredits, + consumeCredits, + getUserCredits, +} from '@/lib/credits'; +import { getSession } from '@/lib/server'; +import { createSafeActionClient } from 'next-safe-action'; +import { z } from 'zod'; + +const actionClient = createSafeActionClient(); + +// get current user's credits +export const getCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + const credits = await getUserCredits(session.user.id); + return { success: true, credits }; +}); + +// consume credits (simulate button) +const consumeSchema = z.object({ + amount: z.number().min(1), + reason: z.string().optional(), +}); +export const consumeCreditsAction = actionClient + .schema(consumeSchema) + .action(async ({ parsedInput }) => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + try { + await consumeCredits({ + userId: session.user.id, + amount: parsedInput.amount, + reason: parsedInput.reason || 'SIMULATE_USE', + }); + return { success: true }; + } catch (e) { + return { success: false, error: (e as Error).message }; + } + }); + +// add register credits (for testing) +export const addRegisterCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + await addCredits({ + userId: session.user.id, + amount: 100, + type: CREDIT_TRANSACTION_TYPE.REGISTER, + reason: 'REGISTER', + }); + return { success: true }; +}); + +// add monthly free credits (for testing) +export const addMonthlyCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + await addMonthlyFreeCredits(session.user.id); + return { success: true }; +}); diff --git a/src/app/[locale]/(protected)/dashboard/page.tsx b/src/app/[locale]/(protected)/dashboard/page.tsx index 61d51d0..5cf3ed9 100644 --- a/src/app/[locale]/(protected)/dashboard/page.tsx +++ b/src/app/[locale]/(protected)/dashboard/page.tsx @@ -1,8 +1,15 @@ +import { + consumeCreditsAction, + getCreditsAction, +} from '@/actions/credits.action'; import { ChartAreaInteractive } from '@/components/dashboard/chart-area-interactive'; import { DashboardHeader } from '@/components/dashboard/dashboard-header'; import { DataTable } from '@/components/dashboard/data-table'; import { SectionCards } from '@/components/dashboard/section-cards'; +import { Button } from '@/components/ui/button'; import { useTranslations } from 'next-intl'; +import React from 'react'; +import { useState } from 'react'; import data from './data.json'; @@ -14,6 +21,44 @@ import data from './data.json'; */ export default function DashboardPage() { const t = useTranslations(); + const [credits, setCredits] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // get credits + async function fetchCredits() { + setLoading(true); + setError(null); + const res = await getCreditsAction(); + if ( + typeof res === 'object' && + res && + 'success' in res && + res.success && + 'credits' in res + ) + setCredits(res.credits as number); + else if (typeof res === 'object' && res && 'error' in res) + setError((res.error as string) || 'get credits failed'); + setLoading(false); + } + + // consume credits + async function consumeCredits() { + setLoading(true); + setError(null); + const res = await consumeCreditsAction({ amount: 10 }); + if (typeof res === 'object' && res && 'success' in res && res.success) + await fetchCredits(); + else if (typeof res === 'object' && res && 'error' in res) + setError((res.error as string) || 'consume credits failed'); + setLoading(false); + } + + // first load credits + React.useEffect(() => { + fetchCredits(); + }, []); const breadcrumbs = [ { @@ -24,8 +69,21 @@ export default function DashboardPage() { return ( <> - - + + 当前积分: {credits === null ? '加载中...' : credits} + + + } + /> + {error &&
{error}
}
diff --git a/src/db/schema.ts b/src/db/schema.ts index e8afeb3..513881a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -69,3 +69,28 @@ export const payment = pgTable("payment", { createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }); + +// Credits table: stores user's current credit balance and last refresh date +export const userCredit = pgTable("user_credit", { + id: text("id").primaryKey(), + userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), + balance: text("balance").notNull(), // store as string for bigints, or use integer if preferred + lastRefresh: timestamp("last_refresh"), // last time free/monthly credits were refreshed + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +// Credit transaction table: records all credit changes (earn/spend/expire) +export const creditTransaction = pgTable("credit_transaction", { + id: text("id").primaryKey(), + userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), + type: text("type").notNull(), // main type, e.g. REGISTER, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE + reason: text("reason"), // sub reason, e.g. REGISTER, MONTHLY_REFRESH, FEATURE_USE + amount: text("amount").notNull(), // positive for earn, negative for spend + remainingAmount: text("remaining_amount"), // for FIFO consumption + paymentId: text("payment_id"), // associated payment order, can be null, only has value when purchasing credits + expirationDate: timestamp("expiration_date"), // when these credits expire + expirationDateProcessedAt: timestamp("expiration_date_processed_at"), // when expired credits were processed + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 39459fd..e29cdb1 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,2 +1,24 @@ export const PLACEHOLDER_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAoJJREFUWEfFl4lu4zAMRO3cx/9/au6reMaOdkxTTl0grQFCRoqaT+SQotq2bV9N8rRt28xms87m83l553eZ/9vr9Wpkz+ezkT0ej+6dv1X81AFw7M4FBACPVn2c1Z3zLgDeJwHgeLFYdAARYioAEAKJEG2WAjl3gCwNYymQQ9b7/V4spmIAwO6Wy2VnAMikBWlDURBELf8CuN1uHQSrPwMAHK5WqwFELQ01AIXdAa7XawfAb3p6AOwK5+v1ugAoEq4FRSFLgavfQ49jAGQpAE5wjgGCeRrGdBArwHOPcwFcLpcGU1X0IsBuN5tNgYhaiFFwHTiAwq8I+O5xfj6fOz38K+X/fYAdb7fbAgFAjIJ6Aav3AYlQ6nfnDoDz0+lUxNiLALvf7XaDNGQ6GANQBKR85V27B4D3QQRw7hGIYlQKWGM79hSweyCUe1blXhEAogfABwHAXAcqSYkxCtHLUK3XBajSc4Dj8dilAeiSAgD2+30BAEKV4GKcAuDqB4TdYwBgPQByCgApUBoE4EJUGvxUjF3Q69/zLw3g/HA45ABKgdIQu+JPIyDnisCfAxAFNFM0EFNQ64gfS0EUoQP8ighrZSjn3oziZEQpauyKbfjbZchHUL/3AS/Dd30gAkxuRACgfO+EWQW8qwI1o+wseNuKcQiESjALvwNoMI0TcRzD4lFcPYwIM+JTF5x6HOs8yI7jeB5oKhpMRFH9UwaSCDB2Jmg4rc6E2TT0biIaG0rQhNqyhpHBcayTTSXH6vcDL7/sdqRK8LkwTsU499E8vRcAojHcZ4AxABdilgrp4lsXk8oVqgwh7+6H3phqd8J0Kk4vbx/+sZqCD/vNLya/5dT9fAH8g1WdNGgwbQAAAABJRU5ErkJggg=='; + +// credit package definition (example) +export const CREDIT_PACKAGES = [ + { id: 'package-1', credits: 500, price: 5 }, + { id: 'package-2', credits: 1200, price: 10 }, + { id: 'package-3', credits: 3000, price: 20 }, +]; + +// free monthly credits (10% of the smallest package) +export const FREE_MONTHLY_CREDITS = 50; + +// default credit expiration days +export const CREDIT_EXPIRE_DAYS = 30; + +// credit transaction type +export const CREDIT_TRANSACTION_TYPE = { + MONTHLY_REFRESH: 'MONTHLY_REFRESH', + REGISTER: 'REGISTER', + PURCHASE: 'PURCHASE', + USAGE: 'USAGE', + EXPIRE: 'EXPIRE', +}; diff --git a/src/lib/credits.ts b/src/lib/credits.ts new file mode 100644 index 0000000..903a6fe --- /dev/null +++ b/src/lib/credits.ts @@ -0,0 +1,251 @@ +import db from '@/db'; +import { creditTransaction, payment, userCredit } from '@/db/schema'; +import { addDays, isAfter } from 'date-fns'; +import { and, asc, eq } from 'drizzle-orm'; +import { + CREDIT_EXPIRE_DAYS, + CREDIT_TRANSACTION_TYPE, + FREE_MONTHLY_CREDITS, +} from './constants'; + +// Get user's current credit balance +export async function getUserCredits(userId: string): Promise { + const record = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + return record[0]?.balance ? Number.parseInt(record[0].balance, 10) : 0; +} + +// Write a credit transaction record +async function logCreditTransaction(params: { + userId: string; + type: string; + amount: number; + reason: string; + paymentId?: string; + expirationDate?: Date; +}) { + await db.insert(creditTransaction).values({ + id: crypto.randomUUID(), + userId: params.userId, + type: params.type, + amount: params.amount.toString(), + remainingAmount: params.amount > 0 ? params.amount.toString() : undefined, + reason: params.reason, + paymentId: params.paymentId, + expirationDate: params.expirationDate, + createdAt: new Date(), + updatedAt: new Date(), + }); +} + +// Add credits (registration, monthly, purchase, etc.) +export async function addCredits({ + userId, + amount, + type, + reason, + paymentId, + expireDays = CREDIT_EXPIRE_DAYS, +}: { + userId: string; + amount: number; + type: string; + reason: string; + paymentId?: string; + expireDays?: number; +}) { + // Process expired credits first + await processExpiredCredits(userId); + // Update balance + const current = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + const newBalance = ( + Number.parseInt(current[0]?.balance || '0', 10) + amount + ).toString(); + if (current.length > 0) { + await db + .update(userCredit) + .set({ balance: newBalance, updatedAt: new Date() }) + .where(eq(userCredit.userId, userId)); + } else { + await db.insert(userCredit).values({ + id: crypto.randomUUID(), + userId, + balance: newBalance, + lastRefresh: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }); + } + // Write transaction record + await logCreditTransaction({ + userId, + type, + amount, + reason, + paymentId, + expirationDate: addDays(new Date(), expireDays), + }); + // Refresh session if needed + // await refreshUserSession(userId); +} + +// Consume credits (FIFO, by expiration) +export async function consumeCredits({ + userId, + amount, + reason, +}: { + userId: string; + amount: number; + reason: string; +}) { + await processExpiredCredits(userId); + const balance = await getUserCredits(userId); + if (balance < amount) throw new Error('Insufficient credits'); + // FIFO consumption: consume from the earliest unexpired credits first + const txs = await db + .select() + .from(creditTransaction) + .where( + and( + eq(creditTransaction.userId, userId), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE) + ) + ) + .orderBy( + asc(creditTransaction.expirationDate), + asc(creditTransaction.createdAt) + ); + let left = amount; + for (const tx of txs) { + if (left <= 0) break; + const remain = Number.parseInt(tx.remainingAmount || '0', 10); + if (remain <= 0) continue; + const consume = Math.min(remain, left); + await db + .update(creditTransaction) + .set({ + remainingAmount: (remain - consume).toString(), + updatedAt: new Date(), + }) + .where(eq(creditTransaction.id, tx.id)); + left -= consume; + } + // Update balance + const current = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + const newBalance = ( + Number.parseInt(current[0]?.balance || '0', 10) - amount + ).toString(); + await db + .update(userCredit) + .set({ balance: newBalance, updatedAt: new Date() }) + .where(eq(userCredit.userId, userId)); + // Write usage record + await logCreditTransaction({ + userId, + type: CREDIT_TRANSACTION_TYPE.USAGE, + amount: -amount, + reason, + }); + // Refresh session if needed + // await refreshUserSession(userId); +} + +// Process expired credits +export async function processExpiredCredits(userId: string) { + const now = new Date(); + const txs = await db + .select() + .from(creditTransaction) + .where( + and( + eq(creditTransaction.userId, userId), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE) + ) + ); + let expiredTotal = 0; + for (const tx of txs) { + if ( + tx.expirationDate && + isAfter(now, tx.expirationDate) && + !tx.expirationDateProcessedAt + ) { + const remain = Number.parseInt(tx.remainingAmount || '0', 10); + if (remain > 0) { + expiredTotal += remain; + await db + .update(creditTransaction) + .set({ + remainingAmount: '0', + expirationDateProcessedAt: now, + updatedAt: now, + }) + .where(eq(creditTransaction.id, tx.id)); + } + } + } + if (expiredTotal > 0) { + // Deduct expired credits from balance + const current = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + const newBalance = ( + Number.parseInt(current[0]?.balance || '0', 10) - expiredTotal + ).toString(); + await db + .update(userCredit) + .set({ balance: newBalance, updatedAt: now }) + .where(eq(userCredit.userId, userId)); + // Write expire record + await logCreditTransaction({ + userId, + type: CREDIT_TRANSACTION_TYPE.EXPIRE, + amount: -expiredTotal, + reason: 'EXPIRE', + }); + } +} + +// Add free monthly credits (can be called by scheduler) +export async function addMonthlyFreeCredits(userId: string) { + // Check last refresh time + const record = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + const now = new Date(); + let canAdd = false; + if (!record[0]?.lastRefresh) canAdd = true; + else { + const last = new Date(record[0].lastRefresh); + canAdd = + now.getMonth() !== last.getMonth() || + now.getFullYear() !== last.getFullYear(); + } + if (canAdd) { + await addCredits({ + userId, + amount: FREE_MONTHLY_CREDITS, + type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, + reason: 'MONTHLY_FREE', + }); + await db + .update(userCredit) + .set({ lastRefresh: now, updatedAt: now }) + .where(eq(userCredit.userId, userId)); + } +} From ac320b21f4ae10ffdb077daabd708dcdf9bf016b Mon Sep 17 00:00:00 2001 From: javayhu Date: Fri, 23 May 2025 00:38:30 +0800 Subject: [PATCH 2/7] chore: update credits related functions --- src/actions/credits.action.ts | 15 ++++++++---- src/db/schema.ts | 6 ++--- src/lib/constants.ts | 19 +++++++++++---- src/lib/credits.ts | 46 +++++++++++++++++++++++------------ 4 files changed, 58 insertions(+), 28 deletions(-) diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts index 4511b00..b1d1d39 100644 --- a/src/actions/credits.action.ts +++ b/src/actions/credits.action.ts @@ -1,4 +1,7 @@ -import { CREDIT_TRANSACTION_TYPE } from '@/lib/constants'; +import { + CREDIT_TRANSACTION_DESCRIPTION, + CREDIT_TRANSACTION_TYPE, +} from '@/lib/constants'; import { addCredits, addMonthlyFreeCredits, @@ -22,8 +25,9 @@ export const getCreditsAction = actionClient.action(async () => { // consume credits (simulate button) const consumeSchema = z.object({ amount: z.number().min(1), - reason: z.string().optional(), + description: z.string().optional(), }); + export const consumeCreditsAction = actionClient .schema(consumeSchema) .action(async ({ parsedInput }) => { @@ -33,7 +37,8 @@ export const consumeCreditsAction = actionClient await consumeCredits({ userId: session.user.id, amount: parsedInput.amount, - reason: parsedInput.reason || 'SIMULATE_USE', + description: + parsedInput.description || CREDIT_TRANSACTION_DESCRIPTION.USAGE, }); return { success: true }; } catch (e) { @@ -48,8 +53,8 @@ export const addRegisterCreditsAction = actionClient.action(async () => { await addCredits({ userId: session.user.id, amount: 100, - type: CREDIT_TRANSACTION_TYPE.REGISTER, - reason: 'REGISTER', + type: CREDIT_TRANSACTION_TYPE.REGISTER_GIFT, + description: CREDIT_TRANSACTION_DESCRIPTION.REGISTER_GIFT, }); return { success: true }; }); diff --git a/src/db/schema.ts b/src/db/schema.ts index 513881a..cc76130 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -84,12 +84,12 @@ export const userCredit = pgTable("user_credit", { export const creditTransaction = pgTable("credit_transaction", { id: text("id").primaryKey(), userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), - type: text("type").notNull(), // main type, e.g. REGISTER, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE - reason: text("reason"), // sub reason, e.g. REGISTER, MONTHLY_REFRESH, FEATURE_USE + type: text("type").notNull(), // main type, e.g. REGISTER_GIFT, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE + description: text("description"), // description, e.g. REGISTER_GIFT, MONTHLY_REFRESH, USAGE, EXPIRE amount: text("amount").notNull(), // positive for earn, negative for spend remainingAmount: text("remaining_amount"), // for FIFO consumption paymentId: text("payment_id"), // associated payment order, can be null, only has value when purchasing credits - expirationDate: timestamp("expiration_date"), // when these credits expire + expirationDate: timestamp("expiration_date"), // when these credits expire, null for no expiration expirationDateProcessedAt: timestamp("expiration_date_processed_at"), // when expired credits were processed createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), diff --git a/src/lib/constants.ts b/src/lib/constants.ts index e29cdb1..d86f2bf 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -16,9 +16,18 @@ export const CREDIT_EXPIRE_DAYS = 30; // credit transaction type export const CREDIT_TRANSACTION_TYPE = { - MONTHLY_REFRESH: 'MONTHLY_REFRESH', - REGISTER: 'REGISTER', - PURCHASE: 'PURCHASE', - USAGE: 'USAGE', - EXPIRE: 'EXPIRE', + MONTHLY_REFRESH: 'MONTHLY_REFRESH', // credits earned by monthly refresh + REGISTER_GIFT: 'REGISTER_GIFT', // credits earned by register gift + PURCHASE: 'PURCHASE', // credits earned by purchase + USAGE: 'USAGE', // credits spent by usage + EXPIRE: 'EXPIRE', // credits expired +}; + +// credit transaction description +export const CREDIT_TRANSACTION_DESCRIPTION = { + MONTHLY_REFRESH: 'MONTHLY_REFRESH', // credits earned by monthly refresh + REGISTER_GIFT: 'REGISTER_GIFT', // credits earned by register gift + PURCHASE: 'PURCHASE', // credits earned by purchase + USAGE: 'USAGE', // credits spent by usage + EXPIRE: 'EXPIRE', // credits expired }; diff --git a/src/lib/credits.ts b/src/lib/credits.ts index 903a6fe..b9ab813 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -1,9 +1,10 @@ import db from '@/db'; -import { creditTransaction, payment, userCredit } from '@/db/schema'; +import { creditTransaction, userCredit } from '@/db/schema'; import { addDays, isAfter } from 'date-fns'; -import { and, asc, eq } from 'drizzle-orm'; +import { and, asc, eq, or } from 'drizzle-orm'; import { CREDIT_EXPIRE_DAYS, + CREDIT_TRANSACTION_DESCRIPTION, CREDIT_TRANSACTION_TYPE, FREE_MONTHLY_CREDITS, } from './constants'; @@ -23,7 +24,7 @@ async function logCreditTransaction(params: { userId: string; type: string; amount: number; - reason: string; + description: string; paymentId?: string; expirationDate?: Date; }) { @@ -33,7 +34,7 @@ async function logCreditTransaction(params: { type: params.type, amount: params.amount.toString(), remainingAmount: params.amount > 0 ? params.amount.toString() : undefined, - reason: params.reason, + description: params.description, paymentId: params.paymentId, expirationDate: params.expirationDate, createdAt: new Date(), @@ -46,14 +47,14 @@ export async function addCredits({ userId, amount, type, - reason, + description, paymentId, expireDays = CREDIT_EXPIRE_DAYS, }: { userId: string; amount: number; type: string; - reason: string; + description: string; paymentId?: string; expireDays?: number; }) { @@ -88,7 +89,7 @@ export async function addCredits({ userId, type, amount, - reason, + description, paymentId, expirationDate: addDays(new Date(), expireDays), }); @@ -100,13 +101,15 @@ export async function addCredits({ export async function consumeCredits({ userId, amount, - reason, + description, }: { userId: string; amount: number; - reason: string; + description: string; }) { + // Process expired credits first await processExpiredCredits(userId); + // Check balance const balance = await getUserCredits(userId); if (balance < amount) throw new Error('Insufficient credits'); // FIFO consumption: consume from the earliest unexpired credits first @@ -116,18 +119,24 @@ export async function consumeCredits({ .where( and( eq(creditTransaction.userId, userId), - eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE) + or( + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.REGISTER_GIFT) + ) ) ) .orderBy( asc(creditTransaction.expirationDate), asc(creditTransaction.createdAt) ); + // Consume credits let left = amount; for (const tx of txs) { if (left <= 0) break; const remain = Number.parseInt(tx.remainingAmount || '0', 10); if (remain <= 0) continue; + // credits to consume at most in this transaction const consume = Math.min(remain, left); await db .update(creditTransaction) @@ -156,7 +165,7 @@ export async function consumeCredits({ userId, type: CREDIT_TRANSACTION_TYPE.USAGE, amount: -amount, - reason, + description, }); // Refresh session if needed // await refreshUserSession(userId); @@ -165,16 +174,22 @@ export async function consumeCredits({ // Process expired credits export async function processExpiredCredits(userId: string) { const now = new Date(); + // Get all credit transactions without type EXPIRE const txs = await db .select() .from(creditTransaction) .where( and( eq(creditTransaction.userId, userId), - eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE) + or( + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.REGISTER_GIFT) + ) ) ); let expiredTotal = 0; + // Process expired credit transactions for (const tx of txs) { if ( tx.expirationDate && @@ -202,7 +217,8 @@ export async function processExpiredCredits(userId: string) { .from(userCredit) .where(eq(userCredit.userId, userId)) .limit(1); - const newBalance = ( + const newBalance = Math.max( + 0, Number.parseInt(current[0]?.balance || '0', 10) - expiredTotal ).toString(); await db @@ -214,7 +230,7 @@ export async function processExpiredCredits(userId: string) { userId, type: CREDIT_TRANSACTION_TYPE.EXPIRE, amount: -expiredTotal, - reason: 'EXPIRE', + description: CREDIT_TRANSACTION_DESCRIPTION.EXPIRE, }); } } @@ -241,7 +257,7 @@ export async function addMonthlyFreeCredits(userId: string) { userId, amount: FREE_MONTHLY_CREDITS, type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, - reason: 'MONTHLY_FREE', + description: CREDIT_TRANSACTION_DESCRIPTION.MONTHLY_REFRESH, }); await db .update(userCredit) From 4374f118b4f8be2a3d313649eba12d8ae53a6183 Mon Sep 17 00:00:00 2001 From: javayhu Date: Wed, 28 May 2025 00:54:27 +0800 Subject: [PATCH 3/7] chore: update credits related functions (2) --- src/actions/credits.action.ts | 45 +++++++++++---------------- src/db/schema.ts | 2 +- src/lib/constants.ts | 18 ++++------- src/lib/credits.ts | 58 +++++++++++++++++++++++++++-------- 4 files changed, 70 insertions(+), 53 deletions(-) diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts index b1d1d39..406eefa 100644 --- a/src/actions/credits.action.ts +++ b/src/actions/credits.action.ts @@ -1,10 +1,6 @@ import { - CREDIT_TRANSACTION_DESCRIPTION, - CREDIT_TRANSACTION_TYPE, -} from '@/lib/constants'; -import { - addCredits, addMonthlyFreeCredits, + addRegisterGiftCredits, consumeCredits, getUserCredits, } from '@/lib/credits'; @@ -22,6 +18,22 @@ export const getCreditsAction = actionClient.action(async () => { return { success: true, credits }; }); +// add register gift credits (for testing) +export const addRegisterCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + await addRegisterGiftCredits(session.user.id); + return { success: true }; +}); + +// add monthly free credits (for testing) +export const addMonthlyCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + await addMonthlyFreeCredits(session.user.id); + return { success: true }; +}); + // consume credits (simulate button) const consumeSchema = z.object({ amount: z.number().min(1), @@ -38,31 +50,10 @@ export const consumeCreditsAction = actionClient userId: session.user.id, amount: parsedInput.amount, description: - parsedInput.description || CREDIT_TRANSACTION_DESCRIPTION.USAGE, + parsedInput.description || `Consume credits: ${parsedInput.amount}`, }); return { success: true }; } catch (e) { return { success: false, error: (e as Error).message }; } }); - -// add register credits (for testing) -export const addRegisterCreditsAction = actionClient.action(async () => { - const session = await getSession(); - if (!session) return { success: false, error: 'Unauthorized' }; - await addCredits({ - userId: session.user.id, - amount: 100, - type: CREDIT_TRANSACTION_TYPE.REGISTER_GIFT, - description: CREDIT_TRANSACTION_DESCRIPTION.REGISTER_GIFT, - }); - return { success: true }; -}); - -// add monthly free credits (for testing) -export const addMonthlyCreditsAction = actionClient.action(async () => { - const session = await getSession(); - if (!session) return { success: false, error: 'Unauthorized' }; - await addMonthlyFreeCredits(session.user.id); - return { success: true }; -}); diff --git a/src/db/schema.ts b/src/db/schema.ts index cc76130..0ccd085 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -85,7 +85,7 @@ export const creditTransaction = pgTable("credit_transaction", { id: text("id").primaryKey(), userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), type: text("type").notNull(), // main type, e.g. REGISTER_GIFT, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE - description: text("description"), // description, e.g. REGISTER_GIFT, MONTHLY_REFRESH, USAGE, EXPIRE + description: text("description"), // description, e.g. "Register gift credits: 100" amount: text("amount").notNull(), // positive for earn, negative for spend remainingAmount: text("remaining_amount"), // for FIFO consumption paymentId: text("payment_id"), // associated payment order, can be null, only has value when purchasing credits diff --git a/src/lib/constants.ts b/src/lib/constants.ts index d86f2bf..07cc3f5 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -3,14 +3,17 @@ export const PLACEHOLDER_IMAGE = // credit package definition (example) export const CREDIT_PACKAGES = [ - { id: 'package-1', credits: 500, price: 5 }, - { id: 'package-2', credits: 1200, price: 10 }, - { id: 'package-3', credits: 3000, price: 20 }, + { id: 'package-1', credits: 1000, price: 10 }, + { id: 'package-2', credits: 2500, price: 20 }, + { id: 'package-3', credits: 5000, price: 30 }, ]; // free monthly credits (10% of the smallest package) export const FREE_MONTHLY_CREDITS = 50; +// register gift credits (for new user registration) +export const REGISTER_GIFT_CREDITS = 100; + // default credit expiration days export const CREDIT_EXPIRE_DAYS = 30; @@ -22,12 +25,3 @@ export const CREDIT_TRANSACTION_TYPE = { USAGE: 'USAGE', // credits spent by usage EXPIRE: 'EXPIRE', // credits expired }; - -// credit transaction description -export const CREDIT_TRANSACTION_DESCRIPTION = { - MONTHLY_REFRESH: 'MONTHLY_REFRESH', // credits earned by monthly refresh - REGISTER_GIFT: 'REGISTER_GIFT', // credits earned by register gift - PURCHASE: 'PURCHASE', // credits earned by purchase - USAGE: 'USAGE', // credits spent by usage - EXPIRE: 'EXPIRE', // credits expired -}; diff --git a/src/lib/credits.ts b/src/lib/credits.ts index b9ab813..ec72310 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -4,9 +4,9 @@ import { addDays, isAfter } from 'date-fns'; import { and, asc, eq, or } from 'drizzle-orm'; import { CREDIT_EXPIRE_DAYS, - CREDIT_TRANSACTION_DESCRIPTION, CREDIT_TRANSACTION_TYPE, FREE_MONTHLY_CREDITS, + REGISTER_GIFT_CREDITS, } from './constants'; // Get user's current credit balance @@ -60,7 +60,7 @@ export async function addCredits({ }) { // Process expired credits first await processExpiredCredits(userId); - // Update balance + // Update user credit balance const current = await db .select() .from(userCredit) @@ -72,7 +72,11 @@ export async function addCredits({ if (current.length > 0) { await db .update(userCredit) - .set({ balance: newBalance, updatedAt: new Date() }) + .set({ + balance: newBalance, + lastRefresh: new Date(), + updatedAt: new Date(), + }) .where(eq(userCredit.userId, userId)); } else { await db.insert(userCredit).values({ @@ -84,7 +88,7 @@ export async function addCredits({ updatedAt: new Date(), }); } - // Write transaction record + // Write credit transaction record await logCreditTransaction({ userId, type, @@ -230,12 +234,36 @@ export async function processExpiredCredits(userId: string) { userId, type: CREDIT_TRANSACTION_TYPE.EXPIRE, amount: -expiredTotal, - description: CREDIT_TRANSACTION_DESCRIPTION.EXPIRE, + description: `Expire credits: ${expiredTotal}`, }); } } -// Add free monthly credits (can be called by scheduler) +// Add register gift credits +export async function addRegisterGiftCredits(userId: string) { + // Check if user has already received register gift credits + const record = await db + .select() + .from(creditTransaction) + .where( + and( + eq(creditTransaction.userId, userId), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.REGISTER_GIFT) + ) + ) + .limit(1); + // add register gift credits if user has not received them yet + if (record.length === 0) { + await addCredits({ + userId, + amount: REGISTER_GIFT_CREDITS, + type: CREDIT_TRANSACTION_TYPE.REGISTER_GIFT, + description: `Register gift credits: ${REGISTER_GIFT_CREDITS}`, + }); + } +} + +// Add free monthly credits export async function addMonthlyFreeCredits(userId: string) { // Check last refresh time const record = await db @@ -245,23 +273,27 @@ export async function addMonthlyFreeCredits(userId: string) { .limit(1); const now = new Date(); let canAdd = false; - if (!record[0]?.lastRefresh) canAdd = true; - else { + // never added credits before + if (!record[0]?.lastRefresh) { + canAdd = true; + } else { const last = new Date(record[0].lastRefresh); canAdd = now.getMonth() !== last.getMonth() || now.getFullYear() !== last.getFullYear(); } + // add credits if it's a new month if (canAdd) { await addCredits({ userId, amount: FREE_MONTHLY_CREDITS, type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, - description: CREDIT_TRANSACTION_DESCRIPTION.MONTHLY_REFRESH, + description: `Free monthly credits: ${FREE_MONTHLY_CREDITS} for ${now.getFullYear()}-${now.getMonth() + 1}`, }); - await db - .update(userCredit) - .set({ lastRefresh: now, updatedAt: now }) - .where(eq(userCredit.userId, userId)); + // update last refresh time ? addCredits has already updated it + // await db + // .update(userCredit) + // .set({ lastRefresh: now, updatedAt: now }) + // .where(eq(userCredit.userId, userId)); } } From 3a8acc5ef401fcc06087e91ad2c22c167e01f574 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 29 May 2025 01:21:04 +0800 Subject: [PATCH 4/7] chore: update credit related functions (2) --- src/lib/credits.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/lib/credits.ts b/src/lib/credits.ts index ec72310..a6a075a 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -28,6 +28,12 @@ async function logCreditTransaction(params: { paymentId?: string; expirationDate?: Date; }) { + if (!params.userId || !params.type || !params.description) { + throw new Error('Invalid params'); + } + if (!Number.isFinite(params.amount) || params.amount === 0) { + throw new Error('Amount must be positive'); + } await db.insert(creditTransaction).values({ id: crypto.randomUUID(), userId: params.userId, @@ -58,6 +64,15 @@ export async function addCredits({ paymentId?: string; expireDays?: number; }) { + if (!userId || !type || !description) { + throw new Error('Invalid params'); + } + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error('Amount must be positive'); + } + if (!Number.isFinite(expireDays) || expireDays <= 0) { + throw new Error('expireDays must be positive'); + } // Process expired credits first await processExpiredCredits(userId); // Update user credit balance @@ -111,6 +126,12 @@ export async function consumeCredits({ amount: number; description: string; }) { + if (!userId || !description) { + throw new Error('Invalid params'); + } + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error('Amount must be positive'); + } // Process expired credits first await processExpiredCredits(userId); // Check balance From 181e478bc346e4e31f765dc6a353ea7d79f3d019 Mon Sep 17 00:00:00 2001 From: javayhu Date: Mon, 30 Jun 2025 00:05:25 +0800 Subject: [PATCH 5/7] chore: fix db instance --- .../[locale]/(protected)/dashboard/page.tsx | 62 +------------------ src/lib/credits.ts | 9 ++- 2 files changed, 10 insertions(+), 61 deletions(-) diff --git a/src/app/[locale]/(protected)/dashboard/page.tsx b/src/app/[locale]/(protected)/dashboard/page.tsx index 5cf3ed9..61d51d0 100644 --- a/src/app/[locale]/(protected)/dashboard/page.tsx +++ b/src/app/[locale]/(protected)/dashboard/page.tsx @@ -1,15 +1,8 @@ -import { - consumeCreditsAction, - getCreditsAction, -} from '@/actions/credits.action'; import { ChartAreaInteractive } from '@/components/dashboard/chart-area-interactive'; import { DashboardHeader } from '@/components/dashboard/dashboard-header'; import { DataTable } from '@/components/dashboard/data-table'; import { SectionCards } from '@/components/dashboard/section-cards'; -import { Button } from '@/components/ui/button'; import { useTranslations } from 'next-intl'; -import React from 'react'; -import { useState } from 'react'; import data from './data.json'; @@ -21,44 +14,6 @@ import data from './data.json'; */ export default function DashboardPage() { const t = useTranslations(); - const [credits, setCredits] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - // get credits - async function fetchCredits() { - setLoading(true); - setError(null); - const res = await getCreditsAction(); - if ( - typeof res === 'object' && - res && - 'success' in res && - res.success && - 'credits' in res - ) - setCredits(res.credits as number); - else if (typeof res === 'object' && res && 'error' in res) - setError((res.error as string) || 'get credits failed'); - setLoading(false); - } - - // consume credits - async function consumeCredits() { - setLoading(true); - setError(null); - const res = await consumeCreditsAction({ amount: 10 }); - if (typeof res === 'object' && res && 'success' in res && res.success) - await fetchCredits(); - else if (typeof res === 'object' && res && 'error' in res) - setError((res.error as string) || 'consume credits failed'); - setLoading(false); - } - - // first load credits - React.useEffect(() => { - fetchCredits(); - }, []); const breadcrumbs = [ { @@ -69,21 +24,8 @@ export default function DashboardPage() { return ( <> - - 当前积分: {credits === null ? '加载中...' : credits} - -
- } - /> - {error &&
{error}
} + +
diff --git a/src/lib/credits.ts b/src/lib/credits.ts index a6a075a..b6d0728 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -1,4 +1,4 @@ -import db from '@/db'; +import { getDb } from '@/db'; import { creditTransaction, userCredit } from '@/db/schema'; import { addDays, isAfter } from 'date-fns'; import { and, asc, eq, or } from 'drizzle-orm'; @@ -11,6 +11,7 @@ import { // Get user's current credit balance export async function getUserCredits(userId: string): Promise { + const db = await getDb(); const record = await db .select() .from(userCredit) @@ -34,6 +35,7 @@ async function logCreditTransaction(params: { if (!Number.isFinite(params.amount) || params.amount === 0) { throw new Error('Amount must be positive'); } + const db = await getDb(); await db.insert(creditTransaction).values({ id: crypto.randomUUID(), userId: params.userId, @@ -76,6 +78,7 @@ export async function addCredits({ // Process expired credits first await processExpiredCredits(userId); // Update user credit balance + const db = await getDb(); const current = await db .select() .from(userCredit) @@ -138,6 +141,7 @@ export async function consumeCredits({ const balance = await getUserCredits(userId); if (balance < amount) throw new Error('Insufficient credits'); // FIFO consumption: consume from the earliest unexpired credits first + const db = await getDb(); const txs = await db .select() .from(creditTransaction) @@ -200,6 +204,7 @@ export async function consumeCredits({ export async function processExpiredCredits(userId: string) { const now = new Date(); // Get all credit transactions without type EXPIRE + const db = await getDb(); const txs = await db .select() .from(creditTransaction) @@ -263,6 +268,7 @@ export async function processExpiredCredits(userId: string) { // Add register gift credits export async function addRegisterGiftCredits(userId: string) { // Check if user has already received register gift credits + const db = await getDb(); const record = await db .select() .from(creditTransaction) @@ -287,6 +293,7 @@ export async function addRegisterGiftCredits(userId: string) { // Add free monthly credits export async function addMonthlyFreeCredits(userId: string) { // Check last refresh time + const db = await getDb(); const record = await db .select() .from(userCredit) From 684bbdff8281aae568a6983d4dede6c538ae0ed2 Mon Sep 17 00:00:00 2001 From: javayhu Date: Mon, 30 Jun 2025 00:25:26 +0800 Subject: [PATCH 6/7] chore: add credit related tables --- src/db/migrations/0001_woozy_jigsaw.sql | 25 + src/db/migrations/meta/0001_snapshot.json | 635 ++++++++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/schema.ts | 22 +- src/lib/credits.ts | 42 +- 5 files changed, 696 insertions(+), 35 deletions(-) create mode 100644 src/db/migrations/0001_woozy_jigsaw.sql create mode 100644 src/db/migrations/meta/0001_snapshot.json diff --git a/src/db/migrations/0001_woozy_jigsaw.sql b/src/db/migrations/0001_woozy_jigsaw.sql new file mode 100644 index 0000000..dd997f8 --- /dev/null +++ b/src/db/migrations/0001_woozy_jigsaw.sql @@ -0,0 +1,25 @@ +CREATE TABLE "credit_transaction" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "type" text NOT NULL, + "description" text, + "amount" integer NOT NULL, + "remaining_amount" integer, + "payment_id" text, + "expiration_date" timestamp, + "expiration_date_processed_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_credit" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "current_credits" integer DEFAULT 0 NOT NULL, + "last_refresh_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "credit_transaction" ADD CONSTRAINT "credit_transaction_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_credit" ADD CONSTRAINT "user_credit_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/src/db/migrations/meta/0001_snapshot.json b/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..83c624e --- /dev/null +++ b/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,635 @@ +{ + "id": "6ed4f085-66bb-42c4-a708-2e5d86438ca2", + "prevId": "7ecbd97a-94eb-4a46-996e-dbff727fc0c7", + "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.credit_transaction": { + "name": "credit_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "remaining_amount": { + "name": "remaining_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payment_id": { + "name": "payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expiration_date_processed_at": { + "name": "expiration_date_processed_at", + "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": { + "credit_transaction_user_id_user_id_fk": { + "name": "credit_transaction_user_id_user_id_fk", + "tableFrom": "credit_transaction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment": { + "name": "payment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "payment_user_id_user_id_fk": { + "name": "payment_user_id_user_id_fk", + "tableFrom": "payment", + "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 + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_credit": { + "name": "user_credit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_credits": { + "name": "current_credits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_refresh_at": { + "name": "last_refresh_at", + "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": { + "user_credit_user_id_user_id_fk": { + "name": "user_credit_user_id_user_id_fk", + "tableFrom": "user_credit", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "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": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 578b9e1..8f5787e 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1744304844165, "tag": "0000_fine_sir_ram", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1751214200582, + "tag": "0001_woozy_jigsaw", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 0ccd085..0f05063 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { boolean, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; export const user = pgTable("user", { id: text("id").primaryKey(), @@ -70,27 +70,25 @@ export const payment = pgTable("payment", { updatedAt: timestamp('updated_at').notNull().defaultNow(), }); -// Credits table: stores user's current credit balance and last refresh date export const userCredit = pgTable("user_credit", { id: text("id").primaryKey(), userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), - balance: text("balance").notNull(), // store as string for bigints, or use integer if preferred - lastRefresh: timestamp("last_refresh"), // last time free/monthly credits were refreshed + currentCredits: integer("current_credits").notNull().default(0), + lastRefreshAt: timestamp("last_refresh_at"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); -// Credit transaction table: records all credit changes (earn/spend/expire) export const creditTransaction = pgTable("credit_transaction", { id: text("id").primaryKey(), userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), - type: text("type").notNull(), // main type, e.g. REGISTER_GIFT, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE - description: text("description"), // description, e.g. "Register gift credits: 100" - amount: text("amount").notNull(), // positive for earn, negative for spend - remainingAmount: text("remaining_amount"), // for FIFO consumption - paymentId: text("payment_id"), // associated payment order, can be null, only has value when purchasing credits - expirationDate: timestamp("expiration_date"), // when these credits expire, null for no expiration - expirationDateProcessedAt: timestamp("expiration_date_processed_at"), // when expired credits were processed + type: text("type").notNull(), + description: text("description"), + amount: integer("amount").notNull(), + remainingAmount: integer("remaining_amount"), + paymentId: text("payment_id"), + expirationDate: timestamp("expiration_date"), + expirationDateProcessedAt: timestamp("expiration_date_processed_at"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); diff --git a/src/lib/credits.ts b/src/lib/credits.ts index b6d0728..9edff94 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -17,7 +17,7 @@ export async function getUserCredits(userId: string): Promise { .from(userCredit) .where(eq(userCredit.userId, userId)) .limit(1); - return record[0]?.balance ? Number.parseInt(record[0].balance, 10) : 0; + return record[0]?.currentCredits || 0; } // Write a credit transaction record @@ -40,8 +40,8 @@ async function logCreditTransaction(params: { id: crypto.randomUUID(), userId: params.userId, type: params.type, - amount: params.amount.toString(), - remainingAmount: params.amount > 0 ? params.amount.toString() : undefined, + amount: params.amount, + remainingAmount: params.amount > 0 ? params.amount : null, description: params.description, paymentId: params.paymentId, expirationDate: params.expirationDate, @@ -84,15 +84,13 @@ export async function addCredits({ .from(userCredit) .where(eq(userCredit.userId, userId)) .limit(1); - const newBalance = ( - Number.parseInt(current[0]?.balance || '0', 10) + amount - ).toString(); + const newBalance = (current[0]?.currentCredits || 0) + amount; if (current.length > 0) { await db .update(userCredit) .set({ - balance: newBalance, - lastRefresh: new Date(), + currentCredits: newBalance, + lastRefreshAt: new Date(), updatedAt: new Date(), }) .where(eq(userCredit.userId, userId)); @@ -100,8 +98,8 @@ export async function addCredits({ await db.insert(userCredit).values({ id: crypto.randomUUID(), userId, - balance: newBalance, - lastRefresh: new Date(), + currentCredits: newBalance, + lastRefreshAt: new Date(), createdAt: new Date(), updatedAt: new Date(), }); @@ -163,14 +161,14 @@ export async function consumeCredits({ let left = amount; for (const tx of txs) { if (left <= 0) break; - const remain = Number.parseInt(tx.remainingAmount || '0', 10); + const remain = tx.remainingAmount || 0; if (remain <= 0) continue; // credits to consume at most in this transaction const consume = Math.min(remain, left); await db .update(creditTransaction) .set({ - remainingAmount: (remain - consume).toString(), + remainingAmount: remain - consume, updatedAt: new Date(), }) .where(eq(creditTransaction.id, tx.id)); @@ -182,12 +180,10 @@ export async function consumeCredits({ .from(userCredit) .where(eq(userCredit.userId, userId)) .limit(1); - const newBalance = ( - Number.parseInt(current[0]?.balance || '0', 10) - amount - ).toString(); + const newBalance = (current[0]?.currentCredits || 0) - amount; await db .update(userCredit) - .set({ balance: newBalance, updatedAt: new Date() }) + .set({ currentCredits: newBalance, updatedAt: new Date() }) .where(eq(userCredit.userId, userId)); // Write usage record await logCreditTransaction({ @@ -226,13 +222,13 @@ export async function processExpiredCredits(userId: string) { isAfter(now, tx.expirationDate) && !tx.expirationDateProcessedAt ) { - const remain = Number.parseInt(tx.remainingAmount || '0', 10); + const remain = tx.remainingAmount || 0; if (remain > 0) { expiredTotal += remain; await db .update(creditTransaction) .set({ - remainingAmount: '0', + remainingAmount: 0, expirationDateProcessedAt: now, updatedAt: now, }) @@ -249,11 +245,11 @@ export async function processExpiredCredits(userId: string) { .limit(1); const newBalance = Math.max( 0, - Number.parseInt(current[0]?.balance || '0', 10) - expiredTotal - ).toString(); + (current[0]?.currentCredits || 0) - expiredTotal + ); await db .update(userCredit) - .set({ balance: newBalance, updatedAt: now }) + .set({ currentCredits: newBalance, updatedAt: now }) .where(eq(userCredit.userId, userId)); // Write expire record await logCreditTransaction({ @@ -302,10 +298,10 @@ export async function addMonthlyFreeCredits(userId: string) { const now = new Date(); let canAdd = false; // never added credits before - if (!record[0]?.lastRefresh) { + if (!record[0]?.lastRefreshAt) { canAdd = true; } else { - const last = new Date(record[0].lastRefresh); + const last = new Date(record[0].lastRefreshAt); canAdd = now.getMonth() !== last.getMonth() || now.getFullYear() !== last.getFullYear(); From 45e6a59fe61e94cb8c2d797abf3192b94255a48d Mon Sep 17 00:00:00 2001 From: javayhu Date: Mon, 30 Jun 2025 01:10:14 +0800 Subject: [PATCH 7/7] chore: optimize the credit functions --- src/lib/credits.ts | 125 ++++++++++++++++++++++++++++----------------- 1 file changed, 78 insertions(+), 47 deletions(-) diff --git a/src/lib/credits.ts b/src/lib/credits.ts index 9edff94..495a0ac 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'crypto'; import { getDb } from '@/db'; import { creditTransaction, userCredit } from '@/db/schema'; import { addDays, isAfter } from 'date-fns'; @@ -9,7 +10,11 @@ import { REGISTER_GIFT_CREDITS, } from './constants'; -// Get user's current credit balance +/** + * Get user's current credit balance + * @param userId - User ID + * @returns User's current credit balance + */ export async function getUserCredits(userId: string): Promise { const db = await getDb(); const record = await db @@ -20,8 +25,18 @@ export async function getUserCredits(userId: string): Promise { return record[0]?.currentCredits || 0; } -// Write a credit transaction record -async function logCreditTransaction(params: { +/** + * Write a credit transaction record + * @param params - Credit transaction parameters + */ +async function logCreditTransaction({ + userId, + type, + amount, + description, + paymentId, + expirationDate, +}: { userId: string; type: string; amount: number; @@ -29,28 +44,33 @@ async function logCreditTransaction(params: { paymentId?: string; expirationDate?: Date; }) { - if (!params.userId || !params.type || !params.description) { + if (!userId || !type || !description) { throw new Error('Invalid params'); } - if (!Number.isFinite(params.amount) || params.amount === 0) { - throw new Error('Amount must be positive'); + if (!Number.isFinite(amount) || amount === 0) { + throw new Error('Invalid amount'); } const db = await getDb(); await db.insert(creditTransaction).values({ - id: crypto.randomUUID(), - userId: params.userId, - type: params.type, - amount: params.amount, - remainingAmount: params.amount > 0 ? params.amount : null, - description: params.description, - paymentId: params.paymentId, - expirationDate: params.expirationDate, + id: randomUUID(), + userId, + type, + amount, + // remaining amount is the same as amount for earn transactions + // remaining amount is null for spend transactions + remainingAmount: amount > 0 ? amount : null, + description, + paymentId, + expirationDate, createdAt: new Date(), updatedAt: new Date(), }); } -// Add credits (registration, monthly, purchase, etc.) +/** + * Add credits (registration, monthly, purchase, etc.) + * @param params - Credit creation parameters + */ export async function addCredits({ userId, amount, @@ -70,10 +90,10 @@ export async function addCredits({ throw new Error('Invalid params'); } if (!Number.isFinite(amount) || amount <= 0) { - throw new Error('Amount must be positive'); + throw new Error('Invalid amount'); } if (!Number.isFinite(expireDays) || expireDays <= 0) { - throw new Error('expireDays must be positive'); + throw new Error('Invalid expire days'); } // Process expired credits first await processExpiredCredits(userId); @@ -90,16 +110,16 @@ export async function addCredits({ .update(userCredit) .set({ currentCredits: newBalance, - lastRefreshAt: new Date(), + lastRefreshAt: new Date(), // TODO: maybe we can not update this field here updatedAt: new Date(), }) .where(eq(userCredit.userId, userId)); } else { await db.insert(userCredit).values({ - id: crypto.randomUUID(), + id: randomUUID(), userId, currentCredits: newBalance, - lastRefreshAt: new Date(), + lastRefreshAt: new Date(), // TODO: maybe we can not update this field here createdAt: new Date(), updatedAt: new Date(), }); @@ -111,13 +131,15 @@ export async function addCredits({ amount, description, paymentId, + // TODO: maybe there is no expiration date for PURCHASE type? expirationDate: addDays(new Date(), expireDays), }); - // Refresh session if needed - // await refreshUserSession(userId); } -// Consume credits (FIFO, by expiration) +/** + * Consume credits (FIFO, by expiration) + * @param params - Credit consumption parameters + */ export async function consumeCredits({ userId, amount, @@ -131,16 +153,21 @@ export async function consumeCredits({ throw new Error('Invalid params'); } if (!Number.isFinite(amount) || amount <= 0) { - throw new Error('Amount must be positive'); + throw new Error('Invalid amount'); } // Process expired credits first await processExpiredCredits(userId); // Check balance const balance = await getUserCredits(userId); - if (balance < amount) throw new Error('Insufficient credits'); + if (balance < amount) { + console.error( + `Insufficient credits for user ${userId}, balance: ${balance}, amount: ${amount}, description: ${description}` + ); + throw new Error('Insufficient credits'); + } // FIFO consumption: consume from the earliest unexpired credits first const db = await getDb(); - const txs = await db + const transactions = await db .select() .from(creditTransaction) .where( @@ -159,9 +186,9 @@ export async function consumeCredits({ ); // Consume credits let left = amount; - for (const tx of txs) { + for (const transaction of transactions) { if (left <= 0) break; - const remain = tx.remainingAmount || 0; + const remain = transaction.remainingAmount || 0; if (remain <= 0) continue; // credits to consume at most in this transaction const consume = Math.min(remain, left); @@ -171,7 +198,7 @@ export async function consumeCredits({ remainingAmount: remain - consume, updatedAt: new Date(), }) - .where(eq(creditTransaction.id, tx.id)); + .where(eq(creditTransaction.id, transaction.id)); left -= consume; } // Update balance @@ -181,6 +208,7 @@ export async function consumeCredits({ .where(eq(userCredit.userId, userId)) .limit(1); const newBalance = (current[0]?.currentCredits || 0) - amount; + // TODO: there must have one record for this user in userCredit? await db .update(userCredit) .set({ currentCredits: newBalance, updatedAt: new Date() }) @@ -192,23 +220,25 @@ export async function consumeCredits({ amount: -amount, description, }); - // Refresh session if needed - // await refreshUserSession(userId); } -// Process expired credits +/** + * Process expired credits + * @param userId - User ID + */ export async function processExpiredCredits(userId: string) { const now = new Date(); // Get all credit transactions without type EXPIRE const db = await getDb(); - const txs = await db + const transactions = await db .select() .from(creditTransaction) .where( and( eq(creditTransaction.userId, userId), or( - eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE), + // TODO: credits with PURCHASE type can not be expired? + // eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE), eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH), eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.REGISTER_GIFT) ) @@ -216,13 +246,13 @@ export async function processExpiredCredits(userId: string) { ); let expiredTotal = 0; // Process expired credit transactions - for (const tx of txs) { + for (const transaction of transactions) { if ( - tx.expirationDate && - isAfter(now, tx.expirationDate) && - !tx.expirationDateProcessedAt + transaction.expirationDate && + isAfter(now, transaction.expirationDate) && + !transaction.expirationDateProcessedAt ) { - const remain = tx.remainingAmount || 0; + const remain = transaction.remainingAmount || 0; if (remain > 0) { expiredTotal += remain; await db @@ -232,7 +262,7 @@ export async function processExpiredCredits(userId: string) { expirationDateProcessedAt: now, updatedAt: now, }) - .where(eq(creditTransaction.id, tx.id)); + .where(eq(creditTransaction.id, transaction.id)); } } } @@ -261,7 +291,10 @@ export async function processExpiredCredits(userId: string) { } } -// Add register gift credits +/** + * Add register gift credits + * @param userId - User ID + */ export async function addRegisterGiftCredits(userId: string) { // Check if user has already received register gift credits const db = await getDb(); @@ -286,7 +319,10 @@ export async function addRegisterGiftCredits(userId: string) { } } -// Add free monthly credits +/** + * Add free monthly credits + * @param userId - User ID + */ export async function addMonthlyFreeCredits(userId: string) { // Check last refresh time const db = await getDb(); @@ -314,10 +350,5 @@ export async function addMonthlyFreeCredits(userId: string) { type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, description: `Free monthly credits: ${FREE_MONTHLY_CREDITS} for ${now.getFullYear()}-${now.getMonth() + 1}`, }); - // update last refresh time ? addCredits has already updated it - // await db - // .update(userCredit) - // .set({ lastRefresh: now, updatedAt: now }) - // .where(eq(userCredit.userId, userId)); } }