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
+135 -87
View File
@@ -1,34 +1,40 @@
"use server";
"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";
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();
const session = await auth()
if (!session?.user?.id) {
throw new Error("请先登录");
throw new Error("请先登录")
}
return session.user.id;
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, "请至少关联一个资金账户"),
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(),
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(),
@@ -37,56 +43,62 @@ const channelSchema = z.object({
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(),
});
subChannelType: z
.enum(["CREDIT", "DEBIT"] as const)
.optional()
.nullable(),
})
export type ChannelInput = z.infer<typeof channelSchema>;
export type ChannelInput = z.infer<typeof channelSchema>
export type ChannelWithAccountNames = Channel & {
linkedAccounts: { id: string; name: string }[];
};
linkedAccounts: { id: string; name: string }[]
}
export async function getChannelsAction(): Promise<{
success: boolean;
data?: ChannelWithAccountNames[];
error?: string;
success: boolean
data?: ChannelWithAccountNames[]
error?: string
}> {
try {
const userId = await requireUser();
const userId = await requireUser()
const userChannels = await db
.select()
.from(channels)
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
.orderBy(desc(channels.createdAt));
.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)));
.where(and(eq(accounts.userId, userId), isNull(accounts.deletedAt)))
const accountMap = new Map(userAccounts.map((a) => [a.id, a.name]));
const accountMap = new Map(userAccounts.map((a) => [a.id, a.name]))
const result: ChannelWithAccountNames[] = userChannels.map((ch) => {
const linked: { id: string; name: string }[] = [];
const linked: { id: string; name: string }[] = []
if (Array.isArray(ch.refAccounts)) {
for (const accId of ch.refAccounts) {
const name = accountMap.get(accId);
const name = accountMap.get(accId)
if (name) {
linked.push({ id: accId, name });
linked.push({ id: accId, name })
}
}
}
return {
...ch,
linkedAccounts: linked,
};
});
}
})
return { success: true, data: result };
return { success: true, data: result }
} catch (err) {
console.error("getChannelsAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "获取渠道失败" };
console.error("getChannelsAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "获取渠道失败",
}
}
}
@@ -94,13 +106,16 @@ export async function createChannelAction(
data: ChannelInput
): Promise<{ success: boolean; data?: Channel; error?: string }> {
try {
const userId = await requireUser();
const parsed = channelSchema.safeParse(data);
const userId = await requireUser()
const parsed = channelSchema.safeParse(data)
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" };
return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
}
const val = parsed.data;
const val = parsed.data
// 校验关联的所有账户必须属于当前登录用户
const userOwnedAccounts = await db
@@ -112,16 +127,16 @@ export async function createChannelAction(
inArray(accounts.id, val.refAccounts),
isNull(accounts.deletedAt)
)
);
)
if (userOwnedAccounts.length !== val.refAccounts.length) {
return { success: false, error: "关联的部分账户不存在或已被删除" };
return { success: false, error: "关联的部分账户不存在或已被删除" }
}
// 自动提取或补全卡号后4位
let suffix = val.cardNumberSuffix?.trim() || null;
let suffix = val.cardNumberSuffix?.trim() || null
if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) {
suffix = val.cardNumberFull.slice(-4);
suffix = val.cardNumberFull.slice(-4)
}
const [newChannel] = await db
@@ -144,15 +159,18 @@ export async function createChannelAction(
subChannel: val.subChannel?.trim() || null,
subChannelType: val.subChannelType || null,
})
.returning();
.returning()
revalidatePath("/channels");
revalidatePath("/accounts");
revalidatePath("/");
return { success: true, data: newChannel };
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 : "创建渠道失败" };
console.error("createChannelAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "创建渠道失败",
}
}
}
@@ -161,13 +179,16 @@ export async function updateChannelAction(
data: ChannelInput
): Promise<{ success: boolean; data?: Channel; error?: string }> {
try {
const userId = await requireUser();
const parsed = channelSchema.safeParse(data);
const userId = await requireUser()
const parsed = channelSchema.safeParse(data)
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" };
return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
}
const val = parsed.data;
const val = parsed.data
const userOwnedAccounts = await db
.select({ id: accounts.id })
@@ -178,15 +199,15 @@ export async function updateChannelAction(
inArray(accounts.id, val.refAccounts),
isNull(accounts.deletedAt)
)
);
)
if (userOwnedAccounts.length !== val.refAccounts.length) {
return { success: false, error: "关联的部分账户不存在或已被删除" };
return { success: false, error: "关联的部分账户不存在或已被删除" }
}
let suffix = val.cardNumberSuffix?.trim() || null;
let suffix = val.cardNumberSuffix?.trim() || null
if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) {
suffix = val.cardNumberFull.slice(-4);
suffix = val.cardNumberFull.slice(-4)
}
const [updated] = await db
@@ -208,20 +229,29 @@ export async function updateChannelAction(
subChannelType: val.subChannelType || null,
updatedAt: new Date(),
})
.where(and(eq(channels.id, id), eq(channels.userId, userId), isNull(channels.deletedAt)))
.returning();
.where(
and(
eq(channels.id, id),
eq(channels.userId, userId),
isNull(channels.deletedAt)
)
)
.returning()
if (!updated) {
return { success: false, error: "渠道不存在或无权修改" };
return { success: false, error: "渠道不存在或无权修改" }
}
revalidatePath("/channels");
revalidatePath("/accounts");
revalidatePath("/");
return { success: true, data: updated };
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 : "更新渠道失败" };
console.error("updateChannelAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "更新渠道失败",
}
}
}
@@ -230,22 +260,31 @@ export async function toggleChannelActiveAction(
isActive: boolean
): Promise<{ success: boolean; error?: string }> {
try {
const userId = await requireUser();
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)));
.where(
and(
eq(channels.id, id),
eq(channels.userId, userId),
isNull(channels.deletedAt)
)
)
revalidatePath("/channels");
revalidatePath("/accounts");
revalidatePath("/");
return { success: true };
revalidatePath("/channels")
revalidatePath("/accounts")
revalidatePath("/")
return { success: true }
} catch (err) {
console.error("toggleChannelActiveAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "状态切换失败" };
console.error("toggleChannelActiveAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "状态切换失败",
}
}
}
@@ -253,26 +292,35 @@ export async function deleteChannelAction(
id: string
): Promise<{ success: boolean; error?: string }> {
try {
const userId = await requireUser();
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();
.where(
and(
eq(channels.id, id),
eq(channels.userId, userId),
isNull(channels.deletedAt)
)
)
.returning()
if (!deleted) {
return { success: false, error: "渠道不存在或已删除" };
return { success: false, error: "渠道不存在或已删除" }
}
revalidatePath("/channels");
revalidatePath("/accounts");
revalidatePath("/");
return { success: true };
revalidatePath("/channels")
revalidatePath("/accounts")
revalidatePath("/")
return { success: true }
} catch (err) {
console.error("deleteChannelAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "删除渠道失败" };
console.error("deleteChannelAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "删除渠道失败",
}
}
}