BREAKING: postgres + saas

This commit is contained in:
Vasily Zubarev
2025-04-03 13:07:54 +02:00
parent 54a892ddb0
commit f523b1f8ba
136 changed files with 3971 additions and 1563 deletions

View File

@@ -1,46 +1,328 @@
import { prisma } from "@/lib/db"
type ModelEntry = {
type BackupSetting = {
filename: string
model: any
idField: string
recordToBackup: (userId: string, row: any) => Record<string, any>
backupToRecord: (userId: string, json: Record<string, any>) => any
}
// Ordering is important here
export const MODEL_BACKUP: ModelEntry[] = [
export const MODEL_BACKUP: BackupSetting[] = [
{
filename: "settings.json",
model: prisma.setting,
idField: "code",
recordToBackup: (userId: string, row: any) => {
return {
id: row.id,
code: row.code,
name: row.name,
description: row.description,
value: row.value,
}
},
backupToRecord: (userId: string, json: any) => {
return {
id: json.id,
code: json.code,
name: json.name,
description: json.description,
value: json.value,
user: {
connect: {
id: userId,
},
},
}
},
},
{
filename: "currencies.json",
model: prisma.currency,
idField: "code",
recordToBackup: (userId: string, row: any) => {
return {
id: row.id,
code: row.code,
name: row.name,
}
},
backupToRecord: (userId: string, json: any) => {
return {
id: json.id,
code: json.code,
name: json.name,
user: {
connect: {
id: userId,
},
},
}
},
},
{
filename: "categories.json",
model: prisma.category,
idField: "code",
recordToBackup: (userId: string, row: any) => {
return {
id: row.id,
code: row.code,
name: row.name,
color: row.color,
llm_prompt: row.llm_prompt,
createdAt: row.createdAt,
}
},
backupToRecord: (userId: string, json: any) => {
return {
id: json.id,
code: json.code,
name: json.name,
color: json.color,
llm_prompt: json.llm_prompt,
createdAt: json.createdAt,
user: {
connect: {
id: userId,
},
},
}
},
},
{
filename: "projects.json",
model: prisma.project,
idField: "code",
recordToBackup: (userId: string, row: any) => {
return {
id: row.id,
code: row.code,
name: row.name,
color: row.color,
llm_prompt: row.llm_prompt,
createdAt: row.createdAt,
}
},
backupToRecord: (userId: string, json: any) => {
return {
id: json.id,
code: json.code,
name: json.name,
color: json.color,
llm_prompt: json.llm_prompt,
createdAt: json.createdAt,
user: {
connect: {
id: userId,
},
},
}
},
},
{
filename: "fields.json",
model: prisma.field,
idField: "code",
recordToBackup: (userId: string, row: any) => {
return {
id: row.id,
code: row.code,
name: row.name,
type: row.type,
llm_prompt: row.llm_prompt,
options: row.options,
isVisibleInList: row.isVisibleInList,
isVisibleInAnalysis: row.isVisibleInAnalysis,
isRequired: row.isRequired,
isExtra: row.isExtra,
}
},
backupToRecord: (userId: string, json: any) => {
return {
id: json.id,
code: json.code,
name: json.name,
type: json.type,
llm_prompt: json.llm_prompt,
options: json.options,
isVisibleInList: json.isVisibleInList,
isVisibleInAnalysis: json.isVisibleInAnalysis,
isRequired: json.isRequired,
isExtra: json.isExtra,
user: {
connect: {
id: userId,
},
},
}
},
},
{
filename: "files.json",
model: prisma.file,
idField: "id",
recordToBackup: (userId: string, row: any) => {
return {
id: row.id,
filename: row.filename,
path: row.path,
metadata: row.metadata,
isReviewed: row.isReviewed,
mimetype: row.mimetype,
createdAt: row.createdAt,
}
},
backupToRecord: (userId: string, json: any) => {
return {
id: json.id,
filename: json.filename,
path: json.path ? json.path.replace(/^.*\/uploads\//, "") : "",
metadata: json.metadata,
isReviewed: json.isReviewed,
mimetype: json.mimetype,
user: {
connect: {
id: userId,
},
},
}
},
},
{
filename: "transactions.json",
model: prisma.transaction,
idField: "id",
recordToBackup: (userId: string, row: any) => {
return {
id: row.id,
name: row.name,
description: row.description,
merchant: row.merchant,
total: row.total,
currencyCode: row.currencyCode,
convertedTotal: row.convertedTotal,
convertedCurrencyCode: row.convertedCurrencyCode,
type: row.type,
note: row.note,
files: row.files,
extra: row.extra,
categoryCode: row.categoryCode,
projectCode: row.projectCode,
issuedAt: row.issuedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
text: row.text,
}
},
backupToRecord: (userId: string, json: any) => {
return {
id: json.id,
name: json.name,
description: json.description,
merchant: json.merchant,
total: json.total,
currencyCode: json.currencyCode,
convertedTotal: json.convertedTotal,
convertedCurrencyCode: json.convertedCurrencyCode,
type: json.type,
note: json.note,
files: json.files,
extra: json.extra,
issuedAt: json.issuedAt,
user: {
connect: {
id: userId,
},
},
category: {
connect: {
userId_code: { userId, code: json.categoryCode },
},
},
project: {
connect: {
userId_code: { userId, code: json.projectCode },
},
},
}
},
},
]
export async function modelToJSON(userId: string, backup: BackupSetting): Promise<string> {
const data = await backup.model.findMany({ where: { userId } })
if (!data || data.length === 0) {
return "[]"
}
return JSON.stringify(
data.map((row: any) => backup.recordToBackup(userId, row)),
null,
2
)
}
export async function modelFromJSON(userId: string, backup: BackupSetting, jsonContent: string): Promise<number> {
if (!jsonContent) return 0
try {
const records = JSON.parse(jsonContent)
if (!records || records.length === 0) {
return 0
}
let insertedCount = 0
for (const rawRecord of records) {
const record = preprocessRowData(rawRecord)
try {
const data = await backup.backupToRecord(userId, record)
await backup.model.create({ data })
} catch (error) {
console.error(`Error importing record:`, error)
}
insertedCount++
}
return insertedCount
} catch (error) {
console.error(`Error parsing JSON content:`, error)
return 0
}
}
function preprocessRowData(row: Record<string, any>): Record<string, any> {
const processedRow: Record<string, any> = {}
for (const [key, value] of Object.entries(row)) {
if (value === "" || value === "null" || value === undefined) {
processedRow[key] = null
continue
}
// Try to parse JSON for object fields
if (typeof value === "string" && (value.startsWith("{") || value.startsWith("["))) {
try {
processedRow[key] = JSON.parse(value)
continue
} catch (e) {
// Not valid JSON, continue with normal processing
}
}
// Handle dates (checking for ISO date format)
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/.test(value)) {
processedRow[key] = new Date(value)
continue
}
// Handle numbers
if (typeof value === "string" && !isNaN(Number(value)) && key !== "id" && !key.endsWith("Code")) {
// Convert numbers but preserving string IDs
processedRow[key] = Number(value)
continue
}
// Default: keep as is
processedRow[key] = value
}
return processedRow
}

View File

@@ -3,38 +3,50 @@ import { codeFromName } from "@/lib/utils"
import { Prisma } from "@prisma/client"
import { cache } from "react"
export const getCategories = cache(async () => {
export type CategoryData = {
[key: string]: unknown
}
export const getCategories = cache(async (userId: string) => {
return await prisma.category.findMany({
where: { userId },
orderBy: {
name: "asc",
},
})
})
export const getCategoryByCode = cache(async (code: string) => {
export const getCategoryByCode = cache(async (userId: string, code: string) => {
return await prisma.category.findUnique({
where: { code },
where: { userId_code: { code, userId } },
})
})
export const createCategory = async (category: Prisma.CategoryCreateInput) => {
export const createCategory = async (userId: string, category: CategoryData) => {
if (!category.code) {
category.code = codeFromName(category.name as string)
}
return await prisma.category.create({
data: category,
data: {
...category,
user: {
connect: {
id: userId,
},
},
} as Prisma.CategoryCreateInput,
})
}
export const updateCategory = async (code: string, category: Prisma.CategoryUpdateInput) => {
export const updateCategory = async (userId: string, code: string, category: CategoryData) => {
return await prisma.category.update({
where: { code },
where: { userId_code: { code, userId } },
data: category,
})
}
export const deleteCategory = async (code: string) => {
export const deleteCategory = async (userId: string, code: string) => {
return await prisma.category.delete({
where: { code },
where: { userId_code: { code, userId } },
})
}

View File

@@ -2,29 +2,37 @@ import { prisma } from "@/lib/db"
import { Prisma } from "@prisma/client"
import { cache } from "react"
export const getCurrencies = cache(async () => {
export const getCurrencies = cache(async (userId: string) => {
return await prisma.currency.findMany({
where: { userId },
orderBy: {
code: "asc",
},
})
})
export const createCurrency = async (currency: Prisma.CurrencyCreateInput) => {
export const createCurrency = async (userId: string, currency: Prisma.CurrencyCreateInput) => {
return await prisma.currency.create({
data: currency,
data: {
...currency,
user: {
connect: {
id: userId,
},
},
},
})
}
export const updateCurrency = async (code: string, currency: Prisma.CurrencyUpdateInput) => {
export const updateCurrency = async (userId: string, code: string, currency: Prisma.CurrencyUpdateInput) => {
return await prisma.currency.update({
where: { code },
where: { userId_code: { code, userId } },
data: currency,
})
}
export const deleteCurrency = async (code: string) => {
export const deleteCurrency = async (userId: string, code: string) => {
return await prisma.currency.delete({
where: { code },
where: { userId_code: { code, userId } },
})
}

503
models/defaults.ts Normal file
View File

@@ -0,0 +1,503 @@
import { prisma } from "@/lib/db"
export const DEFAULT_PROMPT_ANALYSE_NEW_FILE = `You are an accountant and invoice analysis assistant.
Extract the following information from the given invoice:
{fields}
Where categories are:
{categories}
And projects are:
{projects}
If you can't find something leave it blank. Return only one object. Do not include any other text in your response!`
export const DEFAULT_SETTINGS = [
{
code: "default_currency",
name: "Default Currency",
description: "Don't change this setting if you already have multi-currency transactions. I won't recalculate them.",
value: "EUR",
},
{
code: "default_category",
name: "Default Category",
description: "",
value: "other",
},
{
code: "default_project",
name: "Default Project",
description: "",
value: "personal",
},
{
code: "default_type",
name: "Default Type",
description: "",
value: "expense",
},
{
code: "prompt_analyse_new_file",
name: "Prompt for Analyze Transaction",
description: "Allowed variables: {fields}, {categories}, {categories.code}, {projects}, {projects.code}",
value: DEFAULT_PROMPT_ANALYSE_NEW_FILE,
},
{
code: "is_welcome_message_hidden",
name: "Do not show welcome message on dashboard",
description: "",
value: "false",
},
]
export const DEFAULT_CATEGORIES = [
{
code: "ads",
name: "Advertisement",
color: "#882727",
llm_prompt: "ads, promos, online ads, etc",
},
{
code: "swag",
name: "Swag and Goods",
color: "#882727",
llm_prompt: "swag, stickers, goods, etc",
},
{ code: "donations", name: "Gifts and Donations", color: "#1e6359", llm_prompt: "donations, gifts, charity" },
{ code: "tools", name: "Equipment and Tools", color: "#c69713", llm_prompt: "equipment, tools" },
{ code: "events", name: "Events and Conferences", color: "#ff8b32", llm_prompt: "events, conferences" },
{ code: "food", name: "Food and Drinks", color: "#d40e70", llm_prompt: "food, drinks, business meals" },
{ code: "insurance", name: "Insurance", color: "#050942", llm_prompt: "insurance, health, life" },
{ code: "invoice", name: "Invoice", color: "#064e85", llm_prompt: "custom invoice, bill" },
{ code: "communication", name: "Mobile and Internet", color: "#0e7d86", llm_prompt: "mobile, internet, phone" },
{ code: "office", name: "Office Supplies", color: "#59b0b9", llm_prompt: "office, supplies, stationery" },
{ code: "online", name: "Online Services", color: "#8753fb", llm_prompt: "online services, saas, subscriptions" },
{ code: "rental", name: "Rental", color: "#050942", llm_prompt: "rental, lease" },
{
code: "education",
name: "Education",
color: "#ee5d6c",
llm_prompt: "education, professional development, trainings",
},
{ code: "salary", name: "Salary", color: "#ce4993", llm_prompt: "salary, wages, etc" },
{ code: "fees", name: "Fees", color: "#6a0d83", llm_prompt: "fees, charges, penalties, etc" },
{ code: "travel", name: "Travel Expenses", color: "#fb9062", llm_prompt: "travel, accommodation, etc" },
{ code: "utility_bills", name: "Utility Bills", color: "#af7e2e", llm_prompt: "bills, electricity, water, etc" },
{
code: "transport",
name: "Transport",
color: "#800000",
llm_prompt: "transportation costs, fuel, car rental, vignettes, etc",
},
{ code: "software", name: "Software", color: "#2b5a1d", llm_prompt: "software, licenses" },
{ code: "other", name: "Other", color: "#121216", llm_prompt: "other, miscellaneous," },
]
export const DEFAULT_PROJECTS = [{ code: "personal", name: "Personal", llm_prompt: "personal", color: "#1e202b" }]
export const DEFAULT_CURRENCIES = [
{ code: "USD", name: "$" },
{ code: "EUR", name: "€" },
{ code: "GBP", name: "£" },
{ code: "INR", name: "₹" },
{ code: "AUD", name: "$" },
{ code: "CAD", name: "$" },
{ code: "SGD", name: "$" },
{ code: "CHF", name: "Fr" },
{ code: "MYR", name: "RM" },
{ code: "JPY", name: "¥" },
{ code: "CNY", name: "¥" },
{ code: "NZD", name: "$" },
{ code: "THB", name: "฿" },
{ code: "HUF", name: "Ft" },
{ code: "AED", name: "د.إ" },
{ code: "HKD", name: "$" },
{ code: "MXN", name: "$" },
{ code: "ZAR", name: "R" },
{ code: "PHP", name: "₱" },
{ code: "SEK", name: "kr" },
{ code: "IDR", name: "Rp" },
{ code: "BRL", name: "R$" },
{ code: "SAR", name: "﷼" },
{ code: "TRY", name: "₺" },
{ code: "KES", name: "KSh" },
{ code: "KRW", name: "₩" },
{ code: "EGP", name: "£" },
{ code: "IQD", name: "ع.د" },
{ code: "NOK", name: "kr" },
{ code: "KWD", name: "د.ك" },
{ code: "RUB", name: "₽" },
{ code: "DKK", name: "kr" },
{ code: "PKR", name: "₨" },
{ code: "ILS", name: "₪" },
{ code: "PLN", name: "zł" },
{ code: "QAR", name: "﷼" },
{ code: "OMR", name: "﷼" },
{ code: "COP", name: "$" },
{ code: "CLP", name: "$" },
{ code: "TWD", name: "NT$" },
{ code: "ARS", name: "$" },
{ code: "CZK", name: "Kč" },
{ code: "VND", name: "₫" },
{ code: "MAD", name: "د.م." },
{ code: "JOD", name: "د.ا" },
{ code: "BHD", name: ".د.ب" },
{ code: "XOF", name: "CFA" },
{ code: "LKR", name: "₨" },
{ code: "UAH", name: "₴" },
{ code: "NGN", name: "₦" },
{ code: "TND", name: "د.ت" },
{ code: "UGX", name: "USh" },
{ code: "RON", name: "lei" },
{ code: "BDT", name: "৳" },
{ code: "PEN", name: "S/" },
{ code: "GEL", name: "₾" },
{ code: "XAF", name: "FCFA" },
{ code: "FJD", name: "$" },
{ code: "VEF", name: "Bs" },
{ code: "VES", name: "Bs.S" },
{ code: "BYN", name: "Br" },
{ code: "UZS", name: "лв" },
{ code: "BGN", name: "лв" },
{ code: "DZD", name: "د.ج" },
{ code: "IRR", name: "﷼" },
{ code: "DOP", name: "RD$" },
{ code: "ISK", name: "kr" },
{ code: "CRC", name: "₡" },
{ code: "SYP", name: "£" },
{ code: "JMD", name: "J$" },
{ code: "LYD", name: "ل.د" },
{ code: "GHS", name: "₵" },
{ code: "MUR", name: "₨" },
{ code: "AOA", name: "Kz" },
{ code: "UYU", name: "$U" },
{ code: "AFN", name: "؋" },
{ code: "LBP", name: "ل.ل" },
{ code: "XPF", name: "₣" },
{ code: "TTD", name: "TT$" },
{ code: "TZS", name: "TSh" },
{ code: "ALL", name: "Lek" },
{ code: "XCD", name: "$" },
{ code: "GTQ", name: "Q" },
{ code: "NPR", name: "₨" },
{ code: "BOB", name: "Bs." },
{ code: "ZWD", name: "Z$" },
{ code: "BBD", name: "$" },
{ code: "CUC", name: "$" },
{ code: "LAK", name: "₭" },
{ code: "BND", name: "$" },
{ code: "BWP", name: "P" },
{ code: "HNL", name: "L" },
{ code: "PYG", name: "₲" },
{ code: "ETB", name: "Br" },
{ code: "NAD", name: "$" },
{ code: "PGK", name: "K" },
{ code: "SDG", name: "ج.س." },
{ code: "MOP", name: "MOP$" },
{ code: "BMD", name: "$" },
{ code: "NIO", name: "C$" },
{ code: "BAM", name: "KM" },
{ code: "KZT", name: "₸" },
{ code: "PAB", name: "B/." },
{ code: "GYD", name: "$" },
{ code: "YER", name: "﷼" },
{ code: "MGA", name: "Ar" },
{ code: "KYD", name: "$" },
{ code: "MZN", name: "MT" },
{ code: "RSD", name: "дин." },
{ code: "SCR", name: "₨" },
{ code: "AMD", name: "֏" },
{ code: "AZN", name: "₼" },
{ code: "SBD", name: "$" },
{ code: "SLL", name: "Le" },
{ code: "TOP", name: "T$" },
{ code: "BZD", name: "BZ$" },
{ code: "GMD", name: "D" },
{ code: "MWK", name: "MK" },
{ code: "BIF", name: "FBu" },
{ code: "HTG", name: "G" },
{ code: "SOS", name: "S" },
{ code: "GNF", name: "FG" },
{ code: "MNT", name: "₮" },
{ code: "MVR", name: "Rf" },
{ code: "CDF", name: "FC" },
{ code: "STN", name: "Db" },
{ code: "TJS", name: "ЅМ" },
{ code: "KPW", name: "₩" },
{ code: "KGS", name: "лв" },
{ code: "LRD", name: "$" },
{ code: "LSL", name: "L" },
{ code: "MMK", name: "K" },
{ code: "GIP", name: "£" },
{ code: "MDL", name: "L" },
{ code: "CUP", name: "₱" },
{ code: "KHR", name: "៛" },
{ code: "MKD", name: "ден" },
{ code: "VUV", name: "VT" },
{ code: "ANG", name: "ƒ" },
{ code: "MRU", name: "UM" },
{ code: "SZL", name: "L" },
{ code: "CVE", name: "$" },
{ code: "SRD", name: "$" },
{ code: "SVC", name: "$" },
{ code: "BSD", name: "$" },
{ code: "RWF", name: "R₣" },
{ code: "AWG", name: "ƒ" },
{ code: "BTN", name: "Nu." },
{ code: "DJF", name: "Fdj" },
{ code: "KMF", name: "CF" },
{ code: "ERN", name: "Nfk" },
{ code: "FKP", name: "£" },
{ code: "SHP", name: "£" },
{ code: "WST", name: "WS$" },
{ code: "JEP", name: "£" },
{ code: "TMT", name: "m" },
{ code: "GGP", name: "£" },
{ code: "IMP", name: "£" },
{ code: "TVD", name: "$" },
{ code: "ZMW", name: "ZK" },
{ code: "ADA", name: "Crypto" },
{ code: "BCH", name: "Crypto" },
{ code: "BTC", name: "Crypto" },
{ code: "CLF", name: "UF" },
{ code: "CNH", name: "¥" },
{ code: "DOGE", name: "Crypto" },
{ code: "DOT", name: "Crypto" },
{ code: "ETH", name: "Crypto" },
{ code: "LINK", name: "Crypto" },
{ code: "LTC", name: "Crypto" },
{ code: "LUNA", name: "Crypto" },
{ code: "SLE", name: "Le" },
{ code: "UNI", name: "Crypto" },
{ code: "XBT", name: "Crypto" },
{ code: "XLM", name: "Crypto" },
{ code: "XRP", name: "Crypto" },
{ code: "ZWL", name: "$" },
]
export const DEFAULT_FIELDS = [
{
code: "name",
name: "Name",
type: "string",
llm_prompt: "human readable name, summarize what is bought in the invoice",
isVisibleInList: true,
isVisibleInAnalysis: true,
isRequired: true,
isExtra: false,
},
{
code: "description",
name: "Description",
type: "string",
llm_prompt: "description of the transaction",
isVisibleInList: false,
isVisibleInAnalysis: false,
isRequired: false,
isExtra: false,
},
{
code: "merchant",
name: "Merchant",
type: "string",
llm_prompt: "merchant name, use the original spelling and language",
isVisibleInList: true,
isVisibleInAnalysis: true,
isRequired: false,
isExtra: false,
},
{
code: "issuedAt",
name: "Issued At",
type: "string",
llm_prompt: "issued at date (YYYY-MM-DD format)",
isVisibleInList: true,
isVisibleInAnalysis: true,
isRequired: false,
isExtra: false,
},
{
code: "projectCode",
name: "Project",
type: "string",
llm_prompt: "project code, one of: {projects.code}",
isVisibleInList: true,
isVisibleInAnalysis: true,
isRequired: false,
isExtra: false,
},
{
code: "categoryCode",
name: "Category",
type: "string",
llm_prompt: "category code, one of: {categories.code}",
isVisibleInList: true,
isVisibleInAnalysis: true,
isRequired: false,
isExtra: false,
},
{
code: "files",
name: "Files",
type: "string",
llm_prompt: "",
isVisibleInList: true,
isVisibleInAnalysis: true,
isRequired: false,
isExtra: false,
},
{
code: "total",
name: "Total",
type: "number",
llm_prompt: "total total of the transaction",
isVisibleInList: true,
isVisibleInAnalysis: true,
isRequired: false,
isExtra: false,
},
{
code: "currencyCode",
name: "Currency",
type: "string",
llm_prompt: "currency code, ISO 4217 three letter code like USD, EUR, including crypto codes like BTC, ETH, etc",
isVisibleInList: false,
isVisibleInAnalysis: true,
isRequired: false,
isExtra: false,
},
{
code: "convertedTotal",
name: "Converted Total",
type: "number",
llm_prompt: "",
isVisibleInList: false,
isVisibleInAnalysis: false,
isRequired: false,
isExtra: false,
},
{
code: "convertedCurrencyCode",
name: "Converted Currency Code",
type: "string",
llm_prompt: "",
isVisibleInList: false,
isVisibleInAnalysis: false,
isRequired: false,
isExtra: false,
},
{
code: "type",
name: "Type",
type: "string",
llm_prompt: "",
isVisibleInList: false,
isVisibleInAnalysis: true,
isRequired: false,
isExtra: false,
},
{
code: "note",
name: "Note",
type: "string",
llm_prompt: "",
isVisibleInList: false,
isVisibleInAnalysis: false,
isRequired: false,
isExtra: false,
},
{
code: "vat_rate",
name: "VAT Rate",
type: "number",
llm_prompt: "VAT rate in percentage 0-100",
isVisibleInList: false,
isVisibleInAnalysis: false,
isRequired: false,
isExtra: true,
},
{
code: "vat",
name: "VAT Amount",
type: "number",
llm_prompt: "total VAT in currency of the invoice",
isVisibleInList: false,
isVisibleInAnalysis: false,
isRequired: false,
isExtra: true,
},
{
code: "text",
name: "Extracted Text",
type: "string",
llm_prompt: "extract all recognised text from the invoice",
isVisibleInList: false,
isVisibleInAnalysis: false,
isRequired: false,
isExtra: false,
},
]
export async function createUserDefaults(userId: string) {
// Default projects
for (const project of DEFAULT_PROJECTS) {
await prisma.project.upsert({
where: { userId_code: { code: project.code, userId } },
update: { name: project.name, color: project.color, llm_prompt: project.llm_prompt },
create: { ...project, userId },
})
}
// Default categories
for (const category of DEFAULT_CATEGORIES) {
await prisma.category.upsert({
where: { userId_code: { code: category.code, userId } },
update: { name: category.name, color: category.color, llm_prompt: category.llm_prompt },
create: { ...category, userId },
})
}
// Default currencies
for (const currency of DEFAULT_CURRENCIES) {
await prisma.currency.upsert({
where: { userId_code: { code: currency.code, userId } },
update: { name: currency.name },
create: { ...currency, userId },
})
}
// Default fields
for (const field of DEFAULT_FIELDS) {
await prisma.field.upsert({
where: { userId_code: { code: field.code, userId } },
update: {
name: field.name,
type: field.type,
llm_prompt: field.llm_prompt,
isVisibleInList: field.isVisibleInList,
isVisibleInAnalysis: field.isVisibleInAnalysis,
isRequired: field.isRequired,
isExtra: field.isExtra,
},
create: { ...field, userId },
})
}
// Default settings
for (const setting of DEFAULT_SETTINGS) {
await prisma.setting.upsert({
where: { userId_code: { code: setting.code, userId } },
update: { name: setting.name, description: setting.description, value: setting.value },
create: { ...setting, userId },
})
}
}
export async function isDatabaseEmpty(userId: string) {
const fieldsCount = await prisma.field.count({ where: { userId } })
return fieldsCount === 0
}

View File

@@ -12,8 +12,8 @@ export type ExportFields = string[]
export type ExportImportFieldSettings = {
code: string
type: string
export?: (value: any) => Promise<any>
import?: (value: any) => Promise<any>
export?: (userId: string, value: any) => Promise<any>
import?: (userId: string, value: any) => Promise<any>
}
export const EXPORT_AND_IMPORT_FIELD_MAP: Record<string, ExportImportFieldSettings> = {
@@ -32,10 +32,10 @@ export const EXPORT_AND_IMPORT_FIELD_MAP: Record<string, ExportImportFieldSettin
total: {
code: "total",
type: "number",
export: async function (value: number) {
export: async function (userId: string, value: number) {
return value / 100
},
import: async function (value: string) {
import: async function (userId: string, value: string) {
const num = parseFloat(value)
return isNaN(num) ? 0.0 : num * 100
},
@@ -47,13 +47,13 @@ export const EXPORT_AND_IMPORT_FIELD_MAP: Record<string, ExportImportFieldSettin
convertedTotal: {
code: "convertedTotal",
type: "number",
export: async function (value: number | null) {
export: async function (userId: string, value: number | null) {
if (!value) {
return null
}
return value / 100
},
import: async function (value: string) {
import: async function (userId: string, value: string) {
const num = parseFloat(value)
return isNaN(num) ? 0.0 : num * 100
},
@@ -73,37 +73,37 @@ export const EXPORT_AND_IMPORT_FIELD_MAP: Record<string, ExportImportFieldSettin
categoryCode: {
code: "categoryCode",
type: "string",
export: async function (value: string | null) {
export: async function (userId: string, value: string | null) {
if (!value) {
return null
}
const category = await getCategoryByCode(value)
const category = await getCategoryByCode(userId, value)
return category?.name
},
import: async function (value: string) {
const category = await importCategory(value)
import: async function (userId: string, value: string) {
const category = await importCategory(userId, value)
return category?.code
},
},
projectCode: {
code: "projectCode",
type: "string",
export: async function (value: string | null) {
export: async function (userId: string, value: string | null) {
if (!value) {
return null
}
const project = await getProjectByCode(value)
const project = await getProjectByCode(userId, value)
return project?.name
},
import: async function (value: string) {
const project = await importProject(value)
import: async function (userId: string, value: string) {
const project = await importProject(userId, value)
return project?.code
},
},
issuedAt: {
code: "issuedAt",
type: "date",
export: async function (value: Date | null) {
export: async function (userId: string, value: Date | null) {
if (!value || isNaN(value.getTime())) {
return null
}
@@ -114,7 +114,7 @@ export const EXPORT_AND_IMPORT_FIELD_MAP: Record<string, ExportImportFieldSettin
return null
}
},
import: async function (value: string) {
import: async function (userId: string, value: string) {
try {
return new Date(value)
} catch (error) {
@@ -124,7 +124,7 @@ export const EXPORT_AND_IMPORT_FIELD_MAP: Record<string, ExportImportFieldSettin
},
}
export const importProject = async (name: string) => {
export const importProject = async (userId: string, name: string) => {
const code = codeFromName(name)
const existingProject = await prisma.project.findFirst({
@@ -137,10 +137,10 @@ export const importProject = async (name: string) => {
return existingProject
}
return await createProject({ code, name })
return await createProject(userId, { code, name })
}
export const importCategory = async (name: string) => {
export const importCategory = async (userId: string, name: string) => {
const code = codeFromName(name)
const existingCategory = await prisma.category.findFirst({
@@ -153,5 +153,5 @@ export const importCategory = async (name: string) => {
return existingCategory
}
return await createCategory({ code, name })
return await createCategory(userId, { code, name })
}

View File

@@ -3,32 +3,44 @@ import { codeFromName } from "@/lib/utils"
import { Prisma } from "@prisma/client"
import { cache } from "react"
export const getFields = cache(async () => {
export type FieldData = {
[key: string]: unknown
}
export const getFields = cache(async (userId: string) => {
return await prisma.field.findMany({
where: { userId },
orderBy: {
createdAt: "asc",
},
})
})
export const createField = async (field: Prisma.FieldCreateInput) => {
export const createField = async (userId: string, field: FieldData) => {
if (!field.code) {
field.code = codeFromName(field.name as string)
}
return await prisma.field.create({
data: field,
data: {
...field,
user: {
connect: {
id: userId,
},
},
} as Prisma.FieldCreateInput,
})
}
export const updateField = async (code: string, field: Prisma.FieldUpdateInput) => {
export const updateField = async (userId: string, code: string, field: FieldData) => {
return await prisma.field.update({
where: { code },
where: { userId_code: { code, userId } },
data: field,
})
}
export const deleteField = async (code: string) => {
export const deleteField = async (userId: string, code: string) => {
return await prisma.field.delete({
where: { code },
where: { userId_code: { code, userId } },
})
}

View File

@@ -6,10 +6,11 @@ import path from "path"
import { cache } from "react"
import { getTransactionById } from "./transactions"
export const getUnsortedFiles = cache(async () => {
export const getUnsortedFiles = cache(async (userId: string) => {
return await prisma.file.findMany({
where: {
isReviewed: false,
userId,
},
orderBy: {
createdAt: "desc",
@@ -17,28 +18,30 @@ export const getUnsortedFiles = cache(async () => {
})
})
export const getUnsortedFilesCount = cache(async () => {
export const getUnsortedFilesCount = cache(async (userId: string) => {
return await prisma.file.count({
where: {
isReviewed: false,
userId,
},
})
})
export const getFileById = cache(async (id: string) => {
export const getFileById = cache(async (id: string, userId: string) => {
return await prisma.file.findFirst({
where: { id },
where: { id, userId },
})
})
export const getFilesByTransactionId = cache(async (id: string) => {
const transaction = await getTransactionById(id)
export const getFilesByTransactionId = cache(async (id: string, userId: string) => {
const transaction = await getTransactionById(id, userId)
if (transaction && transaction.files) {
return await prisma.file.findMany({
where: {
id: {
in: transaction.files as string[],
},
userId,
},
orderBy: {
createdAt: "asc",
@@ -48,21 +51,24 @@ export const getFilesByTransactionId = cache(async (id: string) => {
return []
})
export const createFile = async (data: any) => {
export const createFile = async (userId: string, data: any) => {
return await prisma.file.create({
data,
data: {
...data,
userId,
},
})
}
export const updateFile = async (id: string, data: any) => {
export const updateFile = async (id: string, userId: string, data: any) => {
return await prisma.file.update({
where: { id },
where: { id, userId },
data,
})
}
export const deleteFile = async (id: string) => {
const file = await getFileById(id)
export const deleteFile = async (id: string, userId: string) => {
const file = await getFileById(id, userId)
if (!file) {
return
}
@@ -74,6 +80,6 @@ export const deleteFile = async (id: string) => {
}
return await prisma.file.delete({
where: { id },
where: { id, userId },
})
}

View File

@@ -3,38 +3,50 @@ import { codeFromName } from "@/lib/utils"
import { Prisma } from "@prisma/client"
import { cache } from "react"
export const getProjects = cache(async () => {
export type ProjectData = {
[key: string]: unknown
}
export const getProjects = cache(async (userId: string) => {
return await prisma.project.findMany({
where: { userId },
orderBy: {
name: "asc",
},
})
})
export const getProjectByCode = cache(async (code: string) => {
export const getProjectByCode = cache(async (userId: string, code: string) => {
return await prisma.project.findUnique({
where: { code },
where: { userId_code: { code, userId } },
})
})
export const createProject = async (project: Prisma.ProjectCreateInput) => {
export const createProject = async (userId: string, project: ProjectData) => {
if (!project.code) {
project.code = codeFromName(project.name as string)
}
return await prisma.project.create({
data: project,
data: {
...project,
user: {
connect: {
id: userId,
},
},
} as Prisma.ProjectCreateInput,
})
}
export const updateProject = async (code: string, project: Prisma.ProjectUpdateInput) => {
export const updateProject = async (userId: string, code: string, project: ProjectData) => {
return await prisma.project.update({
where: { code },
where: { userId_code: { code, userId } },
data: project,
})
}
export const deleteProject = async (code: string) => {
export const deleteProject = async (userId: string, code: string) => {
return await prisma.project.delete({
where: { code },
where: { userId_code: { code, userId } },
})
}

View File

@@ -3,22 +3,25 @@ import { cache } from "react"
export type SettingsMap = Record<string, string>
export const getSettings = cache(async (): Promise<SettingsMap> => {
const settings = await prisma.setting.findMany()
export const getSettings = cache(async (userId: string): Promise<SettingsMap> => {
const settings = await prisma.setting.findMany({
where: { userId },
})
return settings.reduce((acc, setting) => {
acc[setting.code] = setting.value || ""
return acc
}, {} as SettingsMap)
})
export const updateSettings = cache(async (code: string, value?: any) => {
export const updateSettings = cache(async (userId: string, code: string, value: any) => {
return await prisma.setting.upsert({
where: { code },
where: { userId_code: { code, userId } },
update: { value },
create: {
code,
value,
name: code,
userId,
},
})
})

View File

@@ -11,34 +11,36 @@ export type DashboardStats = {
invoicesProcessed: number
}
export const getDashboardStats = cache(async (filters: TransactionFilters = {}): Promise<DashboardStats> => {
const where: Prisma.TransactionWhereInput = {}
export const getDashboardStats = cache(
async (userId: string, filters: TransactionFilters = {}): Promise<DashboardStats> => {
const where: Prisma.TransactionWhereInput = {}
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.dateFrom || filters.dateTo) {
where.issuedAt = {
gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
lte: filters.dateTo ? new Date(filters.dateTo) : undefined,
}
}
const transactions = await prisma.transaction.findMany({ where: { ...where, userId } })
const totalIncomePerCurrency = calcTotalPerCurrency(transactions.filter((t) => t.type === "income"))
const totalExpensesPerCurrency = calcTotalPerCurrency(transactions.filter((t) => t.type === "expense"))
const profitPerCurrency = Object.fromEntries(
Object.keys(totalIncomePerCurrency).map((currency) => [
currency,
totalIncomePerCurrency[currency] - totalExpensesPerCurrency[currency],
])
)
const invoicesProcessed = transactions.length
return {
totalIncomePerCurrency,
totalExpensesPerCurrency,
profitPerCurrency,
invoicesProcessed,
}
}
const transactions = await prisma.transaction.findMany({ where })
const totalIncomePerCurrency = calcTotalPerCurrency(transactions.filter((t) => t.type === "income"))
const totalExpensesPerCurrency = calcTotalPerCurrency(transactions.filter((t) => t.type === "expense"))
const profitPerCurrency = Object.fromEntries(
Object.keys(totalIncomePerCurrency).map((currency) => [
currency,
totalIncomePerCurrency[currency] - totalExpensesPerCurrency[currency],
])
)
const invoicesProcessed = transactions.length
return {
totalIncomePerCurrency,
totalExpensesPerCurrency,
profitPerCurrency,
invoicesProcessed,
}
})
)
export type ProjectStats = {
totalIncomePerCurrency: Record<string, number>
@@ -47,7 +49,7 @@ export type ProjectStats = {
invoicesProcessed: number
}
export const getProjectStats = cache(async (projectId: string, filters: TransactionFilters = {}) => {
export const getProjectStats = cache(async (userId: string, projectId: string, filters: TransactionFilters = {}) => {
const where: Prisma.TransactionWhereInput = {
projectCode: projectId,
}
@@ -59,7 +61,7 @@ export const getProjectStats = cache(async (projectId: string, filters: Transact
}
}
const transactions = await prisma.transaction.findMany({ where })
const transactions = await prisma.transaction.findMany({ where: { ...where, userId } })
const totalIncomePerCurrency = calcTotalPerCurrency(transactions.filter((t) => t.type === "income"))
const totalExpensesPerCurrency = calcTotalPerCurrency(transactions.filter((t) => t.type === "expense"))
const profitPerCurrency = Object.fromEntries(

View File

@@ -25,13 +25,14 @@ export type TransactionPagination = {
export const getTransactions = cache(
async (
userId: string,
filters?: TransactionFilters,
pagination?: TransactionPagination
): Promise<{
transactions: Transaction[]
total: number
}> => {
const where: Prisma.TransactionWhereInput = {}
const where: Prisma.TransactionWhereInput = { userId }
let orderBy: Prisma.TransactionOrderByWithRelationInput = { issuedAt: "desc" }
if (filters) {
@@ -94,9 +95,9 @@ export const getTransactions = cache(
}
)
export const getTransactionById = cache(async (id: string): Promise<Transaction | null> => {
export const getTransactionById = cache(async (id: string, userId: string): Promise<Transaction | null> => {
return await prisma.transaction.findUnique({
where: { id },
where: { id, userId },
include: {
category: true,
project: true,
@@ -104,22 +105,23 @@ export const getTransactionById = cache(async (id: string): Promise<Transaction
})
})
export const createTransaction = async (data: TransactionData): Promise<Transaction> => {
const { standard, extra } = await splitTransactionDataExtraFields(data)
export const createTransaction = async (userId: string, data: TransactionData): Promise<Transaction> => {
const { standard, extra } = await splitTransactionDataExtraFields(data, userId)
return await prisma.transaction.create({
data: {
...standard,
extra: extra,
userId,
},
})
}
export const updateTransaction = async (id: string, data: TransactionData): Promise<Transaction> => {
const { standard, extra } = await splitTransactionDataExtraFields(data)
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 },
where: { id, userId },
data: {
...standard,
extra: extra,
@@ -127,43 +129,47 @@ export const updateTransaction = async (id: string, data: TransactionData): Prom
})
}
export const updateTransactionFiles = async (id: string, files: string[]): Promise<Transaction> => {
export const updateTransactionFiles = async (id: string, userId: string, files: string[]): Promise<Transaction> => {
return await prisma.transaction.update({
where: { id },
where: { id, userId },
data: { files },
})
}
export const deleteTransaction = async (id: string): Promise<Transaction | undefined> => {
const transaction = await getTransactionById(id)
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[]) {
await deleteFile(fileId)
await deleteFile(fileId, userId)
}
return await prisma.transaction.delete({
where: { id },
where: { id, userId },
})
}
}
export const bulkDeleteTransactions = async (ids: string[]) => {
export const bulkDeleteTransactions = async (ids: string[], userId: string) => {
return await prisma.transaction.deleteMany({
where: { id: { in: ids } },
where: { id: { in: ids }, userId },
})
}
const splitTransactionDataExtraFields = async (
data: TransactionData
data: TransactionData,
userId: string
): Promise<{ standard: TransactionData; extra: Prisma.InputJsonValue }> => {
const fields = await getFields()
const fieldMap = fields.reduce((acc, field) => {
acc[field.code] = field
return acc
}, {} as Record<string, Field>)
const fields = await getFields(userId)
const fieldMap = fields.reduce(
(acc, field) => {
acc[field.code] = field
return acc
},
{} as Record<string, Field>
)
const standard: Omit<Partial<Transaction>, "extra"> = {}
const extra: Record<string, unknown> = {}

35
models/users.ts Normal file
View File

@@ -0,0 +1,35 @@
import { prisma } from "@/lib/db"
import { Prisma } from "@prisma/client"
import { cache } from "react"
export const SELF_HOSTED_USER = {
email: "taxhacker@localhost",
name: "Self-Hosted Mode",
}
export const getSelfHostedUser = cache(async () => {
return await prisma.user.findFirst({
where: { email: SELF_HOSTED_USER.email },
})
})
export const createSelfHostedUser = cache(async () => {
return await prisma.user.upsert({
where: { email: SELF_HOSTED_USER.email },
update: SELF_HOSTED_USER,
create: SELF_HOSTED_USER,
})
})
export const getUserByEmail = cache(async (email: string) => {
return await prisma.user.findUnique({
where: { email },
})
})
export function updateUser(userId: string, data: Prisma.UserUpdateInput) {
return prisma.user.update({
where: { id: userId },
data,
})
}