mirror of
https://github.com/vas3k/TaxHacker.git
synced 2026-07-03 10:52:28 +08:00
* feat: add transaction deduplication with side-by-side comparison modal * feat: implement three-way duplicate resolution logic - Add Row-Level check (Merchant/Total/Currency/Date) to Transaction model. - Implement side-by-side comparison modal with Older/Newer/Both options. - Update AI and CSV workflows to intercept duplicates. Closes #92 * fix: include currency code and fallback in transaction deduplication check Closes #92 * refactor(transactions): separate deduplication logic from creation model - Extracted duplicate checking into a dedicated `findDuplicateTransaction` method in `models/transactions.ts` - Restored `createTransaction` to a pure, atomic database insertion to remove unexpected side effects - Updated all server actions (createTransactionAction, saveInvoice, saveFile, saveTransactions) to orchestrate the duplication check before calling the creation model Addresses reviewer feedback to separate concerns and maintain clean model methods.
245 lines
6.6 KiB
TypeScript
245 lines
6.6 KiB
TypeScript
import { prisma } from "@/lib/db"
|
|
import { Field, Prisma, Transaction } from "@/prisma/client"
|
|
import { cache } from "react"
|
|
import { getFields } from "./fields"
|
|
import { deleteFile } from "./files"
|
|
|
|
export type TransactionData = {
|
|
name?: string | null
|
|
description?: string | null
|
|
merchant?: string | null
|
|
total?: number | null
|
|
currencyCode?: string | null
|
|
convertedTotal?: number | null
|
|
convertedCurrencyCode?: string | null
|
|
type?: string | null
|
|
items?: TransactionData[] | undefined
|
|
note?: string | null
|
|
files?: string[] | undefined
|
|
extra?: Record<string, unknown>
|
|
categoryCode?: string | null
|
|
projectCode?: string | null
|
|
issuedAt?: Date | string | null
|
|
text?: string | null
|
|
[key: string]: unknown
|
|
}
|
|
|
|
export type TransactionFilters = {
|
|
search?: string
|
|
dateFrom?: string
|
|
dateTo?: string
|
|
ordering?: string
|
|
categoryCode?: string
|
|
projectCode?: string
|
|
type?: string
|
|
page?: number
|
|
}
|
|
|
|
export type TransactionPagination = {
|
|
limit: number
|
|
offset: number
|
|
}
|
|
|
|
export const getTransactions = cache(
|
|
async (
|
|
userId: string,
|
|
filters?: TransactionFilters,
|
|
pagination?: TransactionPagination
|
|
): Promise<{
|
|
transactions: Transaction[]
|
|
total: number
|
|
}> => {
|
|
const where: Prisma.TransactionWhereInput = { userId }
|
|
let orderBy: Prisma.TransactionOrderByWithRelationInput = { issuedAt: "desc" }
|
|
|
|
if (filters) {
|
|
if (filters.search) {
|
|
where.OR = [
|
|
{ name: { contains: filters.search, mode: "insensitive" } },
|
|
{ merchant: { contains: filters.search, mode: "insensitive" } },
|
|
{ description: { contains: filters.search, mode: "insensitive" } },
|
|
{ note: { contains: filters.search, mode: "insensitive" } },
|
|
{ text: { contains: filters.search, mode: "insensitive" } },
|
|
]
|
|
}
|
|
|
|
if (filters.dateFrom || filters.dateTo) {
|
|
where.issuedAt = {
|
|
gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
|
|
lte: filters.dateTo ? new Date(filters.dateTo) : undefined,
|
|
}
|
|
}
|
|
|
|
if (filters.categoryCode) {
|
|
where.categoryCode = filters.categoryCode
|
|
}
|
|
|
|
if (filters.projectCode) {
|
|
where.projectCode = filters.projectCode
|
|
}
|
|
|
|
if (filters.type) {
|
|
where.type = filters.type
|
|
}
|
|
|
|
if (filters.ordering) {
|
|
const isDesc = filters.ordering.startsWith("-")
|
|
const field = isDesc ? filters.ordering.slice(1) : filters.ordering
|
|
orderBy = { [field]: isDesc ? "desc" : "asc" }
|
|
}
|
|
}
|
|
|
|
if (pagination) {
|
|
const total = await prisma.transaction.count({ where })
|
|
const transactions = await prisma.transaction.findMany({
|
|
where,
|
|
include: {
|
|
category: true,
|
|
project: true,
|
|
},
|
|
orderBy,
|
|
take: pagination?.limit,
|
|
skip: pagination?.offset,
|
|
})
|
|
return { transactions, total }
|
|
} else {
|
|
const transactions = await prisma.transaction.findMany({
|
|
where,
|
|
include: {
|
|
category: true,
|
|
project: true,
|
|
},
|
|
orderBy,
|
|
})
|
|
return { transactions, total: transactions.length }
|
|
}
|
|
}
|
|
)
|
|
|
|
export const getTransactionById = cache(async (id: string, userId: string): Promise<Transaction | null> => {
|
|
return await prisma.transaction.findUnique({
|
|
where: { id, userId },
|
|
include: {
|
|
category: true,
|
|
project: true,
|
|
},
|
|
})
|
|
})
|
|
|
|
export const getTransactionsByFileId = cache(async (fileId: string, userId: string): Promise<Transaction[]> => {
|
|
return await prisma.transaction.findMany({
|
|
where: { files: { array_contains: [fileId] }, userId },
|
|
})
|
|
})
|
|
|
|
// --- 1. New Dedicated Deduplication Function ---
|
|
export const findDuplicateTransaction = async (userId: string, data: TransactionData) => {
|
|
const { standard } = await splitTransactionDataExtraFields(data, userId)
|
|
const currencyCode = standard.currencyCode || "USD"
|
|
|
|
if (standard.total && standard.merchant && standard.issuedAt) {
|
|
const existingTransaction = await prisma.transaction.findFirst({
|
|
where: {
|
|
userId: userId,
|
|
total: standard.total,
|
|
merchant: standard.merchant,
|
|
issuedAt: standard.issuedAt,
|
|
currencyCode: currencyCode,
|
|
},
|
|
})
|
|
|
|
return existingTransaction
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
export const createTransaction = async (userId: string, data: TransactionData): Promise<Transaction> => {
|
|
const { standard, extra } = await splitTransactionDataExtraFields(data, userId)
|
|
|
|
const newTransaction = await prisma.transaction.create({
|
|
data: {
|
|
...standard,
|
|
extra: extra,
|
|
items: data.items as Prisma.InputJsonValue,
|
|
userId,
|
|
},
|
|
})
|
|
|
|
return newTransaction
|
|
}
|
|
|
|
export const updateTransaction = async (id: string, userId: string, data: TransactionData): Promise<Transaction> => {
|
|
const { standard, extra } = await splitTransactionDataExtraFields(data, userId)
|
|
|
|
return await prisma.transaction.update({
|
|
where: { id, userId },
|
|
data: {
|
|
...standard,
|
|
extra: extra,
|
|
items: data.items ? (data.items as Prisma.InputJsonValue) : [],
|
|
},
|
|
})
|
|
}
|
|
|
|
export const updateTransactionFiles = async (id: string, userId: string, files: string[]): Promise<Transaction> => {
|
|
return await prisma.transaction.update({
|
|
where: { id, userId },
|
|
data: { files },
|
|
})
|
|
}
|
|
|
|
export const deleteTransaction = async (id: string, userId: string): Promise<Transaction | undefined> => {
|
|
const transaction = await getTransactionById(id, userId)
|
|
|
|
if (transaction) {
|
|
const files = Array.isArray(transaction.files) ? transaction.files : []
|
|
|
|
for (const fileId of files as string[]) {
|
|
if ((await getTransactionsByFileId(fileId, userId)).length <= 1) {
|
|
await deleteFile(fileId, userId)
|
|
}
|
|
}
|
|
|
|
return await prisma.transaction.delete({
|
|
where: { id, userId },
|
|
})
|
|
}
|
|
}
|
|
|
|
export const bulkDeleteTransactions = async (ids: string[], userId: string) => {
|
|
return await prisma.transaction.deleteMany({
|
|
where: { id: { in: ids }, userId },
|
|
})
|
|
}
|
|
|
|
const splitTransactionDataExtraFields = async (
|
|
data: TransactionData,
|
|
userId: string
|
|
): Promise<{ standard: TransactionData; extra: Prisma.InputJsonValue }> => {
|
|
const fields = await getFields(userId)
|
|
const fieldMap = fields.reduce(
|
|
(acc, field) => {
|
|
acc[field.code] = field
|
|
return acc
|
|
},
|
|
{} as Record<string, Field>
|
|
)
|
|
|
|
const standard: TransactionData = {}
|
|
const extra: Record<string, unknown> = {}
|
|
|
|
Object.entries(data).forEach(([key, value]) => {
|
|
const fieldDef = fieldMap[key]
|
|
if (fieldDef) {
|
|
if (fieldDef.isExtra) {
|
|
extra[key] = value
|
|
} else {
|
|
standard[key] = value
|
|
}
|
|
}
|
|
})
|
|
|
|
return { standard, extra: extra as Prisma.InputJsonValue }
|
|
}
|