- 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
437 lines
13 KiB
TypeScript
437 lines
13 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 {
|
|
channels,
|
|
transactions,
|
|
transactionsDirty,
|
|
type TransactionDirty,
|
|
} from "@/lib/db/schema"
|
|
|
|
async function requireUser() {
|
|
const session = await auth()
|
|
if (!session?.user?.id) {
|
|
throw new Error("请先登录")
|
|
}
|
|
return session.user.id
|
|
}
|
|
|
|
const dirtyTransactionSchema = z.object({
|
|
txnDate: z.string().min(1, "请选择交易日期"),
|
|
txnAmt: z.string().min(1, "请输入交易金额"),
|
|
txnCcy: z.string().min(1, "请输入交易币种").max(10),
|
|
postingAmt: z.string().optional().nullable(),
|
|
postingCcy: z.string().max(10).optional().nullable(),
|
|
commAmt: z.string().optional().nullable(),
|
|
commCcy: z.string().max(10).optional().nullable(),
|
|
surchargeAmt: z.string().optional().nullable(),
|
|
surchargeCcy: z.string().max(10).optional().nullable(),
|
|
discAmt: z.string().optional().nullable(),
|
|
discCcy: z.string().max(10).optional().nullable(),
|
|
|
|
dcFlag: z.enum(["DEBIT", "CREDIT"] as const).default("DEBIT"),
|
|
refChannels: z.array(z.string().uuid()).default([]),
|
|
txnScene: z.string().min(1).default("PAYMENT"),
|
|
|
|
merchantName: z.string().max(255).optional().nullable(),
|
|
description: z.string().max(500).optional().nullable(),
|
|
memo: z.string().max(500).optional().nullable(),
|
|
})
|
|
|
|
export type DirtyTransactionInput = z.infer<typeof dirtyTransactionSchema>
|
|
|
|
export async function getDirtyTransactionsAction(): Promise<{
|
|
success: boolean
|
|
data?: TransactionDirty[]
|
|
error?: string
|
|
}> {
|
|
try {
|
|
const userId = await requireUser()
|
|
const rows = await db
|
|
.select()
|
|
.from(transactionsDirty)
|
|
.where(
|
|
and(
|
|
eq(transactionsDirty.userId, userId),
|
|
isNull(transactionsDirty.deletedAt)
|
|
)
|
|
)
|
|
.orderBy(
|
|
desc(transactionsDirty.txnDate),
|
|
desc(transactionsDirty.createdAt)
|
|
)
|
|
|
|
return { success: true, data: rows }
|
|
} catch (err) {
|
|
console.error("getDirtyTransactionsAction error:", err)
|
|
return {
|
|
success: false,
|
|
error: err instanceof Error ? err.message : "获取待清洗交易失败",
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function createDirtyTransactionAction(
|
|
data: DirtyTransactionInput
|
|
): Promise<{ success: boolean; data?: TransactionDirty; error?: string }> {
|
|
try {
|
|
const userId = await requireUser()
|
|
const parsed = dirtyTransactionSchema.safeParse(data)
|
|
if (!parsed.success) {
|
|
return {
|
|
success: false,
|
|
error: parsed.error.issues[0]?.message || "参数错误",
|
|
}
|
|
}
|
|
|
|
const val = parsed.data
|
|
const [row] = await db
|
|
.insert(transactionsDirty)
|
|
.values({
|
|
userId,
|
|
txnDate: new Date(val.txnDate),
|
|
txnAmt: val.txnAmt.trim(),
|
|
txnCcy: val.txnCcy.trim().toUpperCase(),
|
|
postingAmt: val.postingAmt?.trim() || null,
|
|
postingCcy: val.postingCcy?.trim()
|
|
? val.postingCcy.trim().toUpperCase()
|
|
: null,
|
|
commAmt: val.commAmt?.trim() || null,
|
|
commCcy: val.commCcy?.trim() ? val.commCcy.trim().toUpperCase() : null,
|
|
surchargeAmt: val.surchargeAmt?.trim() || null,
|
|
surchargeCcy: val.surchargeCcy?.trim()
|
|
? val.surchargeCcy.trim().toUpperCase()
|
|
: null,
|
|
discAmt: val.discAmt?.trim() || null,
|
|
discCcy: val.discCcy?.trim() ? val.discCcy.trim().toUpperCase() : null,
|
|
dcFlag: val.dcFlag,
|
|
refChannels: val.refChannels,
|
|
txnScene: val.txnScene,
|
|
merchantName: val.merchantName?.trim() || null,
|
|
description: val.description?.trim() || null,
|
|
memo: val.memo?.trim() || null,
|
|
})
|
|
.returning()
|
|
|
|
revalidatePath("/bookkeeping")
|
|
return { success: true, data: row }
|
|
} catch (err) {
|
|
console.error("createDirtyTransactionAction error:", err)
|
|
return {
|
|
success: false,
|
|
error: err instanceof Error ? err.message : "暂存交易失败",
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function updateDirtyTransactionAction(
|
|
id: string,
|
|
data: DirtyTransactionInput
|
|
): Promise<{ success: boolean; data?: TransactionDirty; error?: string }> {
|
|
try {
|
|
const userId = await requireUser()
|
|
const parsed = dirtyTransactionSchema.safeParse(data)
|
|
if (!parsed.success) {
|
|
return {
|
|
success: false,
|
|
error: parsed.error.issues[0]?.message || "参数错误",
|
|
}
|
|
}
|
|
|
|
const val = parsed.data
|
|
const [row] = await db
|
|
.update(transactionsDirty)
|
|
.set({
|
|
txnDate: new Date(val.txnDate),
|
|
txnAmt: val.txnAmt.trim(),
|
|
txnCcy: val.txnCcy.trim().toUpperCase(),
|
|
postingAmt: val.postingAmt?.trim() || null,
|
|
postingCcy: val.postingCcy?.trim()
|
|
? val.postingCcy.trim().toUpperCase()
|
|
: null,
|
|
commAmt: val.commAmt?.trim() || null,
|
|
commCcy: val.commCcy?.trim() ? val.commCcy.trim().toUpperCase() : null,
|
|
surchargeAmt: val.surchargeAmt?.trim() || null,
|
|
surchargeCcy: val.surchargeCcy?.trim()
|
|
? val.surchargeCcy.trim().toUpperCase()
|
|
: null,
|
|
discAmt: val.discAmt?.trim() || null,
|
|
discCcy: val.discCcy?.trim() ? val.discCcy.trim().toUpperCase() : null,
|
|
dcFlag: val.dcFlag,
|
|
refChannels: val.refChannels,
|
|
txnScene: val.txnScene,
|
|
merchantName: val.merchantName?.trim() || null,
|
|
description: val.description?.trim() || null,
|
|
memo: val.memo?.trim() || null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(transactionsDirty.id, id),
|
|
eq(transactionsDirty.userId, userId),
|
|
isNull(transactionsDirty.deletedAt)
|
|
)
|
|
)
|
|
.returning()
|
|
|
|
if (!row) {
|
|
return { success: false, error: "未找到该待清洗交易" }
|
|
}
|
|
|
|
revalidatePath("/bookkeeping")
|
|
return { success: true, data: row }
|
|
} catch (err) {
|
|
console.error("updateDirtyTransactionAction error:", err)
|
|
return {
|
|
success: false,
|
|
error: err instanceof Error ? err.message : "更新交易失败",
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function deleteDirtyTransactionAction(
|
|
id: string
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const userId = await requireUser()
|
|
const [deleted] = await db
|
|
.delete(transactionsDirty)
|
|
.where(
|
|
and(eq(transactionsDirty.id, id), eq(transactionsDirty.userId, userId))
|
|
)
|
|
.returning()
|
|
|
|
if (!deleted) {
|
|
return { success: false, error: "交易不存在或已被删除" }
|
|
}
|
|
|
|
revalidatePath("/bookkeeping")
|
|
return { success: true }
|
|
} catch (err) {
|
|
console.error("deleteDirtyTransactionAction error:", err)
|
|
return {
|
|
success: false,
|
|
error: err instanceof Error ? err.message : "删除失败",
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 校验单笔待清洗交易的合规性与会计平衡
|
|
*/
|
|
function validateDirtyTransaction(
|
|
item: TransactionDirty,
|
|
userChannelIds: Set<string>
|
|
): string | null {
|
|
// 1. 金额必须为有效正数
|
|
const amt = parseFloat(item.txnAmt)
|
|
if (isNaN(amt) || amt <= 0) {
|
|
return `交易金额「${item.txnAmt}」无效,必须为大于0的数值`
|
|
}
|
|
|
|
// 2. 币种不能为空
|
|
if (!item.txnCcy || item.txnCcy.trim() === "") {
|
|
return "交易币种不能为空"
|
|
}
|
|
|
|
// 3. 必须绑定属于当前用户的支付渠道
|
|
if (!Array.isArray(item.refChannels) || item.refChannels.length === 0) {
|
|
return "未指定支付渠道,无法过账"
|
|
}
|
|
for (const chId of item.refChannels) {
|
|
if (!userChannelIds.has(chId)) {
|
|
return "绑定的支付渠道无效或已被删除"
|
|
}
|
|
}
|
|
|
|
// 4. 入账金额与币种必须同时存在或同时为空
|
|
const hasPostingAmt =
|
|
item.postingAmt !== null && item.postingAmt.trim() !== ""
|
|
const hasPostingCcy =
|
|
item.postingCcy !== null && item.postingCcy.trim() !== ""
|
|
if (hasPostingAmt !== hasPostingCcy) {
|
|
return "入账金额与入账币种必须同时提供"
|
|
}
|
|
|
|
// 5. 若为同币种入账,执行会计恒等式平衡检验
|
|
if (hasPostingAmt && item.postingCcy === item.txnCcy) {
|
|
const pAmt = parseFloat(item.postingAmt!)
|
|
const comm =
|
|
item.commAmt && item.commCcy === item.txnCcy
|
|
? parseFloat(item.commAmt)
|
|
: 0
|
|
const surcharge =
|
|
item.surchargeAmt && item.surchargeCcy === item.txnCcy
|
|
? parseFloat(item.surchargeAmt)
|
|
: 0
|
|
const disc =
|
|
item.discAmt && item.discCcy === item.txnCcy
|
|
? parseFloat(item.discAmt)
|
|
: 0
|
|
|
|
let expected = amt + surcharge - disc
|
|
if (item.dcFlag === "DEBIT") {
|
|
expected += comm
|
|
} else {
|
|
expected -= comm
|
|
}
|
|
|
|
// 允许 0.01 的浮点微差
|
|
if (Math.abs(pAmt - expected) > 0.015) {
|
|
return `会计恒等式不平衡:实际入账 ${pAmt} 与计算期望值 ${expected.toFixed(2)} 不符`
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* 全量原子阻断清洗动作 (All-or-Nothing)
|
|
* 只要有哪怕一笔交易存在问题,就全量阻断合并,返回详尽错误清单,绝不污染正式账本。
|
|
*/
|
|
export async function cleanseTransactionsAction(
|
|
specificIds?: string[]
|
|
): Promise<{
|
|
success: boolean
|
|
cleansedCount?: number
|
|
errors?: { id: string; name: string; error: string }[]
|
|
error?: string
|
|
}> {
|
|
try {
|
|
const userId = await requireUser()
|
|
|
|
// 1. 获取目标待清洗交易
|
|
const queryConditions = [
|
|
eq(transactionsDirty.userId, userId),
|
|
isNull(transactionsDirty.deletedAt),
|
|
]
|
|
if (specificIds && specificIds.length > 0) {
|
|
queryConditions.push(inArray(transactionsDirty.id, specificIds))
|
|
}
|
|
|
|
const dirtyList = await db
|
|
.select()
|
|
.from(transactionsDirty)
|
|
.where(and(...queryConditions))
|
|
|
|
if (dirtyList.length === 0) {
|
|
return { success: false, error: "当前暂无待清洗的交易记录" }
|
|
}
|
|
|
|
// 2. 获取用户的所有有效渠道 ID 用于归属校验
|
|
const userChannels = await db
|
|
.select({ id: channels.id })
|
|
.from(channels)
|
|
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
|
|
const userChannelIdSet = new Set(userChannels.map((c) => c.id))
|
|
|
|
// 3. 执行严格的前置全量校验网关 (Pre-flight Validation Gate)
|
|
const validationErrors: { id: string; name: string; error: string }[] = []
|
|
|
|
for (const item of dirtyList) {
|
|
const err = validateDirtyTransaction(item, userChannelIdSet)
|
|
if (err) {
|
|
const identifier =
|
|
item.merchantName ||
|
|
item.description ||
|
|
`交易(${item.txnAmt} ${item.txnCcy})`
|
|
validationErrors.push({
|
|
id: item.id,
|
|
name: identifier,
|
|
error: err,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 4. 零容忍阻断:只要有一项不合格,立即全量中止,不发生任何写入与删除!
|
|
if (validationErrors.length > 0) {
|
|
return {
|
|
success: false,
|
|
error: `批次中存在 ${validationErrors.length} 笔未平账或要素不全的交易,全量阻断合并!请修正后再试。`,
|
|
errors: validationErrors,
|
|
}
|
|
}
|
|
|
|
// 5. 100% 校验通过,开启原子事务迁移至正式表
|
|
const idsToCleanse = dirtyList.map((d) => d.id)
|
|
|
|
await db.transaction(async (tx) => {
|
|
// 5.1 批量插入正式 transactions 表
|
|
await tx.insert(transactions).values(
|
|
dirtyList.map((item) => {
|
|
// 若未填入账信息且为单币种,自动补足
|
|
const pAmt = item.postingAmt || item.txnAmt
|
|
const pCcy = item.postingCcy || item.txnCcy
|
|
|
|
return {
|
|
id: item.id,
|
|
userId: item.userId,
|
|
version: item.version,
|
|
refTransactions: item.refTransactions,
|
|
txnDate: item.txnDate,
|
|
clearingDate: item.clearingDate,
|
|
postingDate: item.postingDate,
|
|
txnAmt: item.txnAmt,
|
|
txnCcy: item.txnCcy,
|
|
postingAmt: pAmt,
|
|
postingCcy: pCcy,
|
|
commAmt: item.commAmt,
|
|
commCcy: item.commCcy,
|
|
surchargeAmt: item.surchargeAmt,
|
|
surchargeCcy: item.surchargeCcy,
|
|
discAmt: item.discAmt,
|
|
discCcy: item.discCcy,
|
|
fxRates: item.fxRates,
|
|
dcFlag: item.dcFlag,
|
|
refChannels: item.refChannels,
|
|
cp: item.cp,
|
|
acqInst: item.acqInst,
|
|
clearingNetwork: item.clearingNetwork,
|
|
txnSts: item.txnSts,
|
|
description: item.description,
|
|
memo: item.memo,
|
|
ext: item.ext,
|
|
rawDescription: item.rawDescription,
|
|
rawData: item.rawData,
|
|
txnScene: item.txnScene,
|
|
merchantName: item.merchantName,
|
|
orderId: item.orderId,
|
|
geo: item.geo,
|
|
}
|
|
})
|
|
)
|
|
|
|
// 5.2 从 transactions_dirty 表彻底清除已清洗记录
|
|
await tx
|
|
.delete(transactionsDirty)
|
|
.where(
|
|
and(
|
|
eq(transactionsDirty.userId, userId),
|
|
inArray(transactionsDirty.id, idsToCleanse)
|
|
)
|
|
)
|
|
})
|
|
|
|
// 6. 成功,全量刷新路由缓存
|
|
revalidatePath("/bookkeeping")
|
|
revalidatePath("/transactions")
|
|
revalidatePath("/accounts")
|
|
revalidatePath("/")
|
|
|
|
return {
|
|
success: true,
|
|
cleansedCount: idsToCleanse.length,
|
|
}
|
|
} catch (err) {
|
|
console.error("cleanseTransactionsAction error:", err)
|
|
return {
|
|
success: false,
|
|
error: err instanceof Error ? err.message : "清洗合并事务失败",
|
|
}
|
|
}
|
|
}
|