Files
fluxent-web/lib/actions/account.ts
T
SerinaNya c33857ffac 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
2026-09-06 23:23:29 +08:00

267 lines
7.3 KiB
TypeScript

"use server"
import { z } from "zod"
import { and, desc, eq, isNull } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import { accounts, channels, type Account } from "@/lib/db/schema"
async function requireUser() {
const session = await auth()
if (!session?.user?.id) {
throw new Error("请先登录")
}
return session.user.id
}
const accountSchema = z.object({
name: z.string().min(1, "请输入账户名称").max(100),
accountType: z.enum(["BANK", "E_WALLET", "CASH"] as const),
balanceType: z.enum(["ASSET", "LIABILITY", "EQUITY"] as const),
primaryCurrency: z.string().trim().max(10).optional().nullable(),
supportedCurrencies: z.array(z.string().trim()).optional().nullable(),
remark: z.string().max(500).optional().nullable(),
ext: z.string().optional().nullable(),
// 银行账户字段
issuerName: z.string().max(100).optional().nullable(),
accountNumber: z.string().max(100).optional().nullable(),
// 电子钱包字段
platform: z.string().max(64).optional().nullable(),
accountId: z.string().max(100).optional().nullable(),
// 现金账户字段
location: z.string().max(150).optional().nullable(),
})
export type AccountInput = z.infer<typeof accountSchema>
export type AccountWithChannels = Account & {
channelCount: number
}
export async function getAccountsAction(): Promise<{
success: boolean
data?: AccountWithChannels[]
error?: string
}> {
try {
const userId = await requireUser()
const userAccounts = await db
.select()
.from(accounts)
.where(and(eq(accounts.userId, userId), isNull(accounts.deletedAt)))
.orderBy(desc(accounts.createdAt))
const userChannels = await db
.select({ id: channels.id, refAccounts: channels.refAccounts })
.from(channels)
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
const result: AccountWithChannels[] = userAccounts.map((acc) => {
const count = userChannels.filter(
(ch) => Array.isArray(ch.refAccounts) && ch.refAccounts.includes(acc.id)
).length
return {
...acc,
channelCount: count,
}
})
return { success: true, data: result }
} catch (err) {
console.error("getAccountsAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "获取账户失败",
}
}
}
export async function createAccountAction(
data: AccountInput
): Promise<{ success: boolean; data?: Account; error?: string }> {
try {
const userId = await requireUser()
const parsed = accountSchema.safeParse(data)
if (!parsed.success) {
return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
}
const val = parsed.data
const [newAccount] = await db
.insert(accounts)
.values({
userId,
name: val.name.trim(),
accountType: val.accountType,
balanceType: val.balanceType,
primaryCurrency: val.primaryCurrency
? val.primaryCurrency.toUpperCase()
: null,
supportedCurrencies:
val.supportedCurrencies && val.supportedCurrencies.length > 0
? val.supportedCurrencies
: null,
remark: val.remark?.trim() || null,
ext: val.ext?.trim() || null,
issuerName: val.issuerName?.trim() || null,
accountNumber: val.accountNumber?.trim() || null,
platform: val.platform?.trim() || null,
accountId: val.accountId?.trim() || null,
location: val.location?.trim() || null,
isActive: true,
})
.returning()
revalidatePath("/accounts")
revalidatePath("/channels")
revalidatePath("/")
return { success: true, data: newAccount }
} catch (err) {
console.error("createAccountAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "创建账户失败",
}
}
}
export async function updateAccountAction(
id: string,
data: AccountInput
): Promise<{ success: boolean; data?: Account; error?: string }> {
try {
const userId = await requireUser()
const parsed = accountSchema.safeParse(data)
if (!parsed.success) {
return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
}
const val = parsed.data
const [updated] = await db
.update(accounts)
.set({
name: val.name.trim(),
accountType: val.accountType,
balanceType: val.balanceType,
primaryCurrency: val.primaryCurrency
? val.primaryCurrency.toUpperCase()
: null,
supportedCurrencies:
val.supportedCurrencies && val.supportedCurrencies.length > 0
? val.supportedCurrencies
: null,
remark: val.remark?.trim() || null,
ext: val.ext?.trim() || null,
issuerName: val.issuerName?.trim() || null,
accountNumber: val.accountNumber?.trim() || null,
platform: val.platform?.trim() || null,
accountId: val.accountId?.trim() || null,
location: val.location?.trim() || null,
updatedAt: new Date(),
})
.where(
and(
eq(accounts.id, id),
eq(accounts.userId, userId),
isNull(accounts.deletedAt)
)
)
.returning()
if (!updated) {
return { success: false, error: "未找到该账户或无权修改" }
}
revalidatePath("/accounts")
revalidatePath("/channels")
revalidatePath("/")
return { success: true, data: updated }
} catch (err) {
console.error("updateAccountAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "更新账户失败",
}
}
}
export async function toggleAccountActiveAction(
id: string,
isActive: boolean
): Promise<{ success: boolean; error?: string }> {
try {
const userId = await requireUser()
await db
.update(accounts)
.set({
isActive,
updatedAt: new Date(),
})
.where(
and(
eq(accounts.id, id),
eq(accounts.userId, userId),
isNull(accounts.deletedAt)
)
)
revalidatePath("/accounts")
return { success: true }
} catch (err) {
console.error("toggleAccountActiveAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "状态切换失败",
}
}
}
export async function deleteAccountAction(
id: string
): Promise<{ success: boolean; error?: string }> {
try {
const userId = await requireUser()
// 软删除
const [deleted] = await db
.update(accounts)
.set({
deletedAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(accounts.id, id),
eq(accounts.userId, userId),
isNull(accounts.deletedAt)
)
)
.returning()
if (!deleted) {
return { success: false, error: "账户不存在或已删除" }
}
revalidatePath("/accounts")
revalidatePath("/channels")
revalidatePath("/")
return { success: true }
} catch (err) {
console.error("deleteAccountAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "删除账户失败",
}
}
}