chore: add credit related tables

This commit is contained in:
javayhu 2025-06-30 00:25:26 +08:00
parent 181e478bc3
commit 684bbdff82
5 changed files with 696 additions and 35 deletions

View File

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

View File

@ -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": {}
}
}

View File

@ -8,6 +8,13 @@
"when": 1744304844165, "when": 1744304844165,
"tag": "0000_fine_sir_ram", "tag": "0000_fine_sir_ram",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1751214200582,
"tag": "0001_woozy_jigsaw",
"breakpoints": true
} }
] ]
} }

View File

@ -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", { export const user = pgTable("user", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@ -70,27 +70,25 @@ export const payment = pgTable("payment", {
updatedAt: timestamp('updated_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", { export const userCredit = pgTable("user_credit", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }),
balance: text("balance").notNull(), // store as string for bigints, or use integer if preferred currentCredits: integer("current_credits").notNull().default(0),
lastRefresh: timestamp("last_refresh"), // last time free/monthly credits were refreshed lastRefreshAt: timestamp("last_refresh_at"),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_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", { export const creditTransaction = pgTable("credit_transaction", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), 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 type: text("type").notNull(),
description: text("description"), // description, e.g. "Register gift credits: 100" description: text("description"),
amount: text("amount").notNull(), // positive for earn, negative for spend amount: integer("amount").notNull(),
remainingAmount: text("remaining_amount"), // for FIFO consumption remainingAmount: integer("remaining_amount"),
paymentId: text("payment_id"), // associated payment order, can be null, only has value when purchasing credits paymentId: text("payment_id"),
expirationDate: timestamp("expiration_date"), // when these credits expire, null for no expiration expirationDate: timestamp("expiration_date"),
expirationDateProcessedAt: timestamp("expiration_date_processed_at"), // when expired credits were processed expirationDateProcessedAt: timestamp("expiration_date_processed_at"),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(),
}); });

View File

@ -17,7 +17,7 @@ export async function getUserCredits(userId: string): Promise<number> {
.from(userCredit) .from(userCredit)
.where(eq(userCredit.userId, userId)) .where(eq(userCredit.userId, userId))
.limit(1); .limit(1);
return record[0]?.balance ? Number.parseInt(record[0].balance, 10) : 0; return record[0]?.currentCredits || 0;
} }
// Write a credit transaction record // Write a credit transaction record
@ -40,8 +40,8 @@ async function logCreditTransaction(params: {
id: crypto.randomUUID(), id: crypto.randomUUID(),
userId: params.userId, userId: params.userId,
type: params.type, type: params.type,
amount: params.amount.toString(), amount: params.amount,
remainingAmount: params.amount > 0 ? params.amount.toString() : undefined, remainingAmount: params.amount > 0 ? params.amount : null,
description: params.description, description: params.description,
paymentId: params.paymentId, paymentId: params.paymentId,
expirationDate: params.expirationDate, expirationDate: params.expirationDate,
@ -84,15 +84,13 @@ export async function addCredits({
.from(userCredit) .from(userCredit)
.where(eq(userCredit.userId, userId)) .where(eq(userCredit.userId, userId))
.limit(1); .limit(1);
const newBalance = ( const newBalance = (current[0]?.currentCredits || 0) + amount;
Number.parseInt(current[0]?.balance || '0', 10) + amount
).toString();
if (current.length > 0) { if (current.length > 0) {
await db await db
.update(userCredit) .update(userCredit)
.set({ .set({
balance: newBalance, currentCredits: newBalance,
lastRefresh: new Date(), lastRefreshAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(userCredit.userId, userId)); .where(eq(userCredit.userId, userId));
@ -100,8 +98,8 @@ export async function addCredits({
await db.insert(userCredit).values({ await db.insert(userCredit).values({
id: crypto.randomUUID(), id: crypto.randomUUID(),
userId, userId,
balance: newBalance, currentCredits: newBalance,
lastRefresh: new Date(), lastRefreshAt: new Date(),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}); });
@ -163,14 +161,14 @@ export async function consumeCredits({
let left = amount; let left = amount;
for (const tx of txs) { for (const tx of txs) {
if (left <= 0) break; if (left <= 0) break;
const remain = Number.parseInt(tx.remainingAmount || '0', 10); const remain = tx.remainingAmount || 0;
if (remain <= 0) continue; if (remain <= 0) continue;
// credits to consume at most in this transaction // credits to consume at most in this transaction
const consume = Math.min(remain, left); const consume = Math.min(remain, left);
await db await db
.update(creditTransaction) .update(creditTransaction)
.set({ .set({
remainingAmount: (remain - consume).toString(), remainingAmount: remain - consume,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(creditTransaction.id, tx.id)); .where(eq(creditTransaction.id, tx.id));
@ -182,12 +180,10 @@ export async function consumeCredits({
.from(userCredit) .from(userCredit)
.where(eq(userCredit.userId, userId)) .where(eq(userCredit.userId, userId))
.limit(1); .limit(1);
const newBalance = ( const newBalance = (current[0]?.currentCredits || 0) - amount;
Number.parseInt(current[0]?.balance || '0', 10) - amount
).toString();
await db await db
.update(userCredit) .update(userCredit)
.set({ balance: newBalance, updatedAt: new Date() }) .set({ currentCredits: newBalance, updatedAt: new Date() })
.where(eq(userCredit.userId, userId)); .where(eq(userCredit.userId, userId));
// Write usage record // Write usage record
await logCreditTransaction({ await logCreditTransaction({
@ -226,13 +222,13 @@ export async function processExpiredCredits(userId: string) {
isAfter(now, tx.expirationDate) && isAfter(now, tx.expirationDate) &&
!tx.expirationDateProcessedAt !tx.expirationDateProcessedAt
) { ) {
const remain = Number.parseInt(tx.remainingAmount || '0', 10); const remain = tx.remainingAmount || 0;
if (remain > 0) { if (remain > 0) {
expiredTotal += remain; expiredTotal += remain;
await db await db
.update(creditTransaction) .update(creditTransaction)
.set({ .set({
remainingAmount: '0', remainingAmount: 0,
expirationDateProcessedAt: now, expirationDateProcessedAt: now,
updatedAt: now, updatedAt: now,
}) })
@ -249,11 +245,11 @@ export async function processExpiredCredits(userId: string) {
.limit(1); .limit(1);
const newBalance = Math.max( const newBalance = Math.max(
0, 0,
Number.parseInt(current[0]?.balance || '0', 10) - expiredTotal (current[0]?.currentCredits || 0) - expiredTotal
).toString(); );
await db await db
.update(userCredit) .update(userCredit)
.set({ balance: newBalance, updatedAt: now }) .set({ currentCredits: newBalance, updatedAt: now })
.where(eq(userCredit.userId, userId)); .where(eq(userCredit.userId, userId));
// Write expire record // Write expire record
await logCreditTransaction({ await logCreditTransaction({
@ -302,10 +298,10 @@ export async function addMonthlyFreeCredits(userId: string) {
const now = new Date(); const now = new Date();
let canAdd = false; let canAdd = false;
// never added credits before // never added credits before
if (!record[0]?.lastRefresh) { if (!record[0]?.lastRefreshAt) {
canAdd = true; canAdd = true;
} else { } else {
const last = new Date(record[0].lastRefresh); const last = new Date(record[0].lastRefreshAt);
canAdd = canAdd =
now.getMonth() !== last.getMonth() || now.getMonth() !== last.getMonth() ||
now.getFullYear() !== last.getFullYear(); now.getFullYear() !== last.getFullYear();