feat(ledger): add bookkeeping workbench and transaction views

- Add transactions_dirty table and zero-tolerance cleansing action

- Implement bookkeeping workbench (/bookkeeping) with searchable Combobox channel selector and card suffix / account identifier

- Implement transaction ledger (/transactions) with date groupings, multi-currency metrics, and filters

- Update AGENTS.md guidelines for Base UI Select and Combobox bindings
This commit is contained in:
2026-09-06 23:23:29 +08:00
parent 1ccee1414c
commit c33857ffac
42 changed files with 3329 additions and 598 deletions
+34 -34
View File
@@ -1,11 +1,11 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { users, userAccounts } from "@/lib/db/schema";
import { verifyPassword } from "./password";
import { authConfig } from "./config";
import type { Provider } from "next-auth/providers";
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { users, userAccounts } from "@/lib/db/schema"
import { verifyPassword } from "./password"
import { authConfig } from "./config"
import type { Provider } from "next-auth/providers"
// 动态构建 Providers 列表
const providers: Provider[] = [
@@ -17,25 +17,25 @@ const providers: Provider[] = [
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
return null
}
const email = String(credentials.email).toLowerCase().trim();
const password = String(credentials.password);
const email = String(credentials.email).toLowerCase().trim()
const password = String(credentials.password)
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
.limit(1)
if (!user || !user.passwordHash || !user.isActive) {
return null;
return null
}
const isValid = await verifyPassword(password, user.passwordHash);
const isValid = await verifyPassword(password, user.passwordHash)
if (!isValid) {
return null;
return null
}
return {
@@ -43,16 +43,16 @@ const providers: Provider[] = [
name: user.name,
email: user.email,
image: user.avatar,
};
}
},
}),
];
]
// 如果配置了 OIDC,且 AUTH_OIDC_ENABLED 为 true,则动态注册通用 OIDC 提供商
const isOidcEnabled =
process.env.AUTH_OIDC_ENABLED === "true" &&
Boolean(process.env.AUTH_OIDC_ISSUER) &&
Boolean(process.env.AUTH_OIDC_CLIENT_ID);
Boolean(process.env.AUTH_OIDC_CLIENT_ID)
if (isOidcEnabled) {
providers.push({
@@ -72,7 +72,7 @@ if (isOidcEnabled) {
scope: process.env.AUTH_OIDC_SCOPES || "openid profile email",
},
},
});
})
}
export const { handlers, signIn, signOut, auth } = NextAuth({
@@ -86,13 +86,13 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
...authConfig.callbacks,
async signIn({ user, account }) {
if (!account || account.type === "credentials") {
return true;
return true
}
// 处理 OIDC / OAuth 登录与本地用户的关联或新建
const email = user.email?.toLowerCase().trim();
const email = user.email?.toLowerCase().trim()
if (!email) {
return false;
return false
}
// 1. 查询用户是否已存在
@@ -100,7 +100,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
.limit(1)
if (!existingUser) {
// 创建新用户
@@ -112,20 +112,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
avatar: user.image || null,
isActive: true,
})
.returning();
existingUser = newUser;
.returning()
existingUser = newUser
}
user.id = existingUser.id;
user.id = existingUser.id
// 2. 查询是否已记录该 provider 的账户绑定
const [existingAccount] = await db
.select()
.from(userAccounts)
.where(
eq(userAccounts.providerAccountId, account.providerAccountId)
)
.limit(1);
.where(eq(userAccounts.providerAccountId, account.providerAccountId))
.limit(1)
if (!existingAccount) {
await db.insert(userAccounts).values({
@@ -134,14 +132,16 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
providerAccountId: account.providerAccountId,
refreshToken: account.refresh_token,
accessToken: account.access_token,
expiresAt: account.expires_at ? new Date(account.expires_at * 1000) : null,
expiresAt: account.expires_at
? new Date(account.expires_at * 1000)
: null,
tokenType: account.token_type,
scope: account.scope,
idToken: account.id_token,
});
})
}
return true;
return true
},
},
});
})