- 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
327 lines
9.0 KiB
TypeScript
327 lines
9.0 KiB
TypeScript
"use server"
|
|
|
|
import { z } from "zod"
|
|
import { and, desc, eq, inArray, isNull } from "drizzle-orm"
|
|
import { revalidatePath } from "next/cache"
|
|
import { auth } from "@/lib/auth"
|
|
import { db } from "@/lib/db"
|
|
import { accounts, channels, type Channel } from "@/lib/db/schema"
|
|
|
|
async function requireUser() {
|
|
const session = await auth()
|
|
if (!session?.user?.id) {
|
|
throw new Error("请先登录")
|
|
}
|
|
return session.user.id
|
|
}
|
|
|
|
const channelSchema = z.object({
|
|
channelType: z.enum([
|
|
"PAYMENT_CARD",
|
|
"E_WALLET",
|
|
"CASH",
|
|
"TRANSFER",
|
|
] as const),
|
|
refAccounts: z
|
|
.array(z.string().uuid("无效的账户标识"))
|
|
.min(1, "请至少关联一个资金账户"),
|
|
desc: z.string().max(500).optional().nullable(),
|
|
ext: z.string().optional().nullable(),
|
|
|
|
// 支付卡特定字段
|
|
region: z.string().max(10).optional().nullable(),
|
|
issuerName: z.string().max(100).optional().nullable(),
|
|
cardType: z
|
|
.enum(["CREDIT", "DEBIT"] as const)
|
|
.optional()
|
|
.nullable(),
|
|
cardNumberFull: z.string().max(100).optional().nullable(),
|
|
cardNumberSuffix: z.string().max(10).optional().nullable(),
|
|
cardBrand: z.string().max(32).optional().nullable(),
|
|
|
|
// 电子钱包特定字段
|
|
platform: z.string().max(64).optional().nullable(),
|
|
platformAccountId: z.string().max(100).optional().nullable(),
|
|
subChannel: z.string().max(64).optional().nullable(),
|
|
subChannelType: z
|
|
.enum(["CREDIT", "DEBIT"] as const)
|
|
.optional()
|
|
.nullable(),
|
|
})
|
|
|
|
export type ChannelInput = z.infer<typeof channelSchema>
|
|
|
|
export type ChannelWithAccountNames = Channel & {
|
|
linkedAccounts: { id: string; name: string }[]
|
|
}
|
|
|
|
export async function getChannelsAction(): Promise<{
|
|
success: boolean
|
|
data?: ChannelWithAccountNames[]
|
|
error?: string
|
|
}> {
|
|
try {
|
|
const userId = await requireUser()
|
|
|
|
const userChannels = await db
|
|
.select()
|
|
.from(channels)
|
|
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
|
|
.orderBy(desc(channels.createdAt))
|
|
|
|
const userAccounts = await db
|
|
.select({ id: accounts.id, name: accounts.name })
|
|
.from(accounts)
|
|
.where(and(eq(accounts.userId, userId), isNull(accounts.deletedAt)))
|
|
|
|
const accountMap = new Map(userAccounts.map((a) => [a.id, a.name]))
|
|
|
|
const result: ChannelWithAccountNames[] = userChannels.map((ch) => {
|
|
const linked: { id: string; name: string }[] = []
|
|
if (Array.isArray(ch.refAccounts)) {
|
|
for (const accId of ch.refAccounts) {
|
|
const name = accountMap.get(accId)
|
|
if (name) {
|
|
linked.push({ id: accId, name })
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
...ch,
|
|
linkedAccounts: linked,
|
|
}
|
|
})
|
|
|
|
return { success: true, data: result }
|
|
} catch (err) {
|
|
console.error("getChannelsAction error:", err)
|
|
return {
|
|
success: false,
|
|
error: err instanceof Error ? err.message : "获取渠道失败",
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function createChannelAction(
|
|
data: ChannelInput
|
|
): Promise<{ success: boolean; data?: Channel; error?: string }> {
|
|
try {
|
|
const userId = await requireUser()
|
|
const parsed = channelSchema.safeParse(data)
|
|
if (!parsed.success) {
|
|
return {
|
|
success: false,
|
|
error: parsed.error.issues[0]?.message || "参数错误",
|
|
}
|
|
}
|
|
|
|
const val = parsed.data
|
|
|
|
// 校验关联的所有账户必须属于当前登录用户
|
|
const userOwnedAccounts = await db
|
|
.select({ id: accounts.id })
|
|
.from(accounts)
|
|
.where(
|
|
and(
|
|
eq(accounts.userId, userId),
|
|
inArray(accounts.id, val.refAccounts),
|
|
isNull(accounts.deletedAt)
|
|
)
|
|
)
|
|
|
|
if (userOwnedAccounts.length !== val.refAccounts.length) {
|
|
return { success: false, error: "关联的部分账户不存在或已被删除" }
|
|
}
|
|
|
|
// 自动提取或补全卡号后4位
|
|
let suffix = val.cardNumberSuffix?.trim() || null
|
|
if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) {
|
|
suffix = val.cardNumberFull.slice(-4)
|
|
}
|
|
|
|
const [newChannel] = await db
|
|
.insert(channels)
|
|
.values({
|
|
userId,
|
|
channelType: val.channelType,
|
|
refAccounts: val.refAccounts,
|
|
isActive: true,
|
|
desc: val.desc?.trim() || null,
|
|
ext: val.ext?.trim() || null,
|
|
region: val.region ? val.region.toUpperCase().trim() : null,
|
|
issuerName: val.issuerName?.trim() || null,
|
|
cardType: val.cardType || null,
|
|
cardNumberFull: val.cardNumberFull?.trim() || null,
|
|
cardNumberSuffix: suffix,
|
|
cardBrand: val.cardBrand?.trim() || null,
|
|
platform: val.platform?.trim() || null,
|
|
platformAccountId: val.platformAccountId?.trim() || null,
|
|
subChannel: val.subChannel?.trim() || null,
|
|
subChannelType: val.subChannelType || null,
|
|
})
|
|
.returning()
|
|
|
|
revalidatePath("/channels")
|
|
revalidatePath("/accounts")
|
|
revalidatePath("/")
|
|
return { success: true, data: newChannel }
|
|
} catch (err) {
|
|
console.error("createChannelAction error:", err)
|
|
return {
|
|
success: false,
|
|
error: err instanceof Error ? err.message : "创建渠道失败",
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function updateChannelAction(
|
|
id: string,
|
|
data: ChannelInput
|
|
): Promise<{ success: boolean; data?: Channel; error?: string }> {
|
|
try {
|
|
const userId = await requireUser()
|
|
const parsed = channelSchema.safeParse(data)
|
|
if (!parsed.success) {
|
|
return {
|
|
success: false,
|
|
error: parsed.error.issues[0]?.message || "参数错误",
|
|
}
|
|
}
|
|
|
|
const val = parsed.data
|
|
|
|
const userOwnedAccounts = await db
|
|
.select({ id: accounts.id })
|
|
.from(accounts)
|
|
.where(
|
|
and(
|
|
eq(accounts.userId, userId),
|
|
inArray(accounts.id, val.refAccounts),
|
|
isNull(accounts.deletedAt)
|
|
)
|
|
)
|
|
|
|
if (userOwnedAccounts.length !== val.refAccounts.length) {
|
|
return { success: false, error: "关联的部分账户不存在或已被删除" }
|
|
}
|
|
|
|
let suffix = val.cardNumberSuffix?.trim() || null
|
|
if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) {
|
|
suffix = val.cardNumberFull.slice(-4)
|
|
}
|
|
|
|
const [updated] = await db
|
|
.update(channels)
|
|
.set({
|
|
channelType: val.channelType,
|
|
refAccounts: val.refAccounts,
|
|
desc: val.desc?.trim() || null,
|
|
ext: val.ext?.trim() || null,
|
|
region: val.region ? val.region.toUpperCase().trim() : null,
|
|
issuerName: val.issuerName?.trim() || null,
|
|
cardType: val.cardType || null,
|
|
cardNumberFull: val.cardNumberFull?.trim() || null,
|
|
cardNumberSuffix: suffix,
|
|
cardBrand: val.cardBrand?.trim() || null,
|
|
platform: val.platform?.trim() || null,
|
|
platformAccountId: val.platformAccountId?.trim() || null,
|
|
subChannel: val.subChannel?.trim() || null,
|
|
subChannelType: val.subChannelType || null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(channels.id, id),
|
|
eq(channels.userId, userId),
|
|
isNull(channels.deletedAt)
|
|
)
|
|
)
|
|
.returning()
|
|
|
|
if (!updated) {
|
|
return { success: false, error: "渠道不存在或无权修改" }
|
|
}
|
|
|
|
revalidatePath("/channels")
|
|
revalidatePath("/accounts")
|
|
revalidatePath("/")
|
|
return { success: true, data: updated }
|
|
} catch (err) {
|
|
console.error("updateChannelAction error:", err)
|
|
return {
|
|
success: false,
|
|
error: err instanceof Error ? err.message : "更新渠道失败",
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function toggleChannelActiveAction(
|
|
id: string,
|
|
isActive: boolean
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const userId = await requireUser()
|
|
await db
|
|
.update(channels)
|
|
.set({
|
|
isActive,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(channels.id, id),
|
|
eq(channels.userId, userId),
|
|
isNull(channels.deletedAt)
|
|
)
|
|
)
|
|
|
|
revalidatePath("/channels")
|
|
revalidatePath("/accounts")
|
|
revalidatePath("/")
|
|
return { success: true }
|
|
} catch (err) {
|
|
console.error("toggleChannelActiveAction error:", err)
|
|
return {
|
|
success: false,
|
|
error: err instanceof Error ? err.message : "状态切换失败",
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function deleteChannelAction(
|
|
id: string
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const userId = await requireUser()
|
|
const [deleted] = await db
|
|
.update(channels)
|
|
.set({
|
|
deletedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(channels.id, id),
|
|
eq(channels.userId, userId),
|
|
isNull(channels.deletedAt)
|
|
)
|
|
)
|
|
.returning()
|
|
|
|
if (!deleted) {
|
|
return { success: false, error: "渠道不存在或已删除" }
|
|
}
|
|
|
|
revalidatePath("/channels")
|
|
revalidatePath("/accounts")
|
|
revalidatePath("/")
|
|
return { success: true }
|
|
} catch (err) {
|
|
console.error("deleteChannelAction error:", err)
|
|
return {
|
|
success: false,
|
|
error: err instanceof Error ? err.message : "删除渠道失败",
|
|
}
|
|
}
|
|
}
|