mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
(squash) init
feat: filters, settings, backups fix: ts compile errors feat: new dashboard, webp previews and settings feat: use webp for pdfs feat: use webp fix: analyze resets old data fix: switch to corsproxy fix: switch to free cors fix: max upload limit fix: currency conversion feat: transaction export fix: currency conversion feat: refactor settings actions feat: new loader feat: README + LICENSE doc: update readme doc: update readme doc: update readme doc: update screenshots ci: bump prisma
This commit is contained in:
57
lib/currency-scraper.ts
Normal file
57
lib/currency-scraper.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { format } from "date-fns"
|
||||
|
||||
type HistoricRate = {
|
||||
currency: string
|
||||
rate: number
|
||||
inverse: number
|
||||
}
|
||||
|
||||
export async function getCurrencyRate(currencyCodeFrom: string, currencyCodeTo: string, date: Date): Promise<number> {
|
||||
const rates = await fetchHistoricalCurrencyRates(currencyCodeFrom, date)
|
||||
|
||||
if (!rates || rates.length === 0) {
|
||||
console.log("Could not fetch currency rates", currencyCodeFrom, currencyCodeTo, date)
|
||||
return 0
|
||||
}
|
||||
|
||||
const rate = rates.find((rate) => rate.currency === currencyCodeTo)
|
||||
|
||||
if (!rate) {
|
||||
console.log("Could not find currency rate", currencyCodeFrom, currencyCodeTo, date)
|
||||
return 0
|
||||
}
|
||||
|
||||
return rate.rate
|
||||
}
|
||||
|
||||
export async function fetchHistoricalCurrencyRates(currency: string = "USD", date: Date): Promise<HistoricRate[]> {
|
||||
const formattedDate = format(date, "yyyy-MM-dd")
|
||||
|
||||
const url = `https://corsproxy.io/?${encodeURIComponent(
|
||||
`https://www.xe.com/currencytables/?from=${currency}&date=${formattedDate}`
|
||||
)}`
|
||||
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`)
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
|
||||
// Extract the JSON data from the __NEXT_DATA__ script tag
|
||||
const scriptTagRegex = /<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/
|
||||
const match = html.match(scriptTagRegex)
|
||||
|
||||
if (!match || !match[1]) {
|
||||
throw new Error("Could not find currency data in the page")
|
||||
}
|
||||
|
||||
const jsonData = JSON.parse(match[1])
|
||||
|
||||
const historicRates = jsonData.props.pageProps.historicRates as HistoricRate[]
|
||||
|
||||
console.log("Historic Rates for this date", historicRates)
|
||||
|
||||
return historicRates
|
||||
}
|
||||
11
lib/db.ts
Normal file
11
lib/db.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { PrismaClient } from "@prisma/client"
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: ["query", "info", "warn", "error"] })
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
|
||||
|
||||
export const DATABASE_FILE = process.env.DATABASE_URL?.split(":").pop() || "db.sqlite"
|
||||
36
lib/files.ts
Normal file
36
lib/files.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Transaction } from "@prisma/client"
|
||||
import { randomUUID } from "crypto"
|
||||
import path from "path"
|
||||
|
||||
export const FILE_ACCEPTED_MIMETYPES = "image/*,.pdf,.doc,.docx,.xls,.xlsx"
|
||||
export const FILE_UPLOAD_PATH = path.resolve(process.env.UPLOAD_PATH || "./uploads")
|
||||
export const FILE_UNSORTED_UPLOAD_PATH = path.join(FILE_UPLOAD_PATH, "unsorted")
|
||||
export const FILE_PREVIEWS_PATH = path.join(FILE_UPLOAD_PATH, "previews")
|
||||
|
||||
export async function getUnsortedFileUploadPath(filename: string) {
|
||||
const fileUuid = randomUUID()
|
||||
const fileExtension = path.extname(filename)
|
||||
const storedFileName = `${fileUuid}${fileExtension}`
|
||||
const filePath = path.join(FILE_UNSORTED_UPLOAD_PATH, storedFileName)
|
||||
|
||||
return { fileUuid, filePath }
|
||||
}
|
||||
|
||||
export async function getTransactionFileUploadPath(filename: string, transaction: Transaction) {
|
||||
const fileUuid = randomUUID()
|
||||
const fileExtension = path.extname(filename)
|
||||
const storedFileName = `${fileUuid}${fileExtension}`
|
||||
const formattedPath = formatFilePath(storedFileName, transaction.issuedAt || new Date())
|
||||
const filePath = path.join(FILE_UPLOAD_PATH, formattedPath)
|
||||
|
||||
return { fileUuid, filePath }
|
||||
}
|
||||
|
||||
function formatFilePath(filename: string, date: Date, format = "{YYYY}/{MM}/{name}{ext}") {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const ext = path.extname(filename)
|
||||
const name = path.basename(filename, ext)
|
||||
|
||||
return format.replace("{YYYY}", String(year)).replace("{MM}", month).replace("{name}", name).replace("{ext}", ext)
|
||||
}
|
||||
50
lib/images.ts
Normal file
50
lib/images.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { FILE_PREVIEWS_PATH } from "@/lib/files"
|
||||
import { existsSync } from "fs"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import sharp from "sharp"
|
||||
|
||||
const MAX_WIDTH = 1800
|
||||
const MAX_HEIGHT = 1800
|
||||
const QUALITY = 90
|
||||
|
||||
export async function resizeImage(
|
||||
origFilePath: string,
|
||||
maxWidth: number = MAX_WIDTH,
|
||||
maxHeight: number = MAX_HEIGHT
|
||||
): Promise<{ contentType: string; resizedPath: string }> {
|
||||
try {
|
||||
await fs.mkdir(FILE_PREVIEWS_PATH, { recursive: true })
|
||||
|
||||
const basename = path.basename(origFilePath, path.extname(origFilePath))
|
||||
const outputPath = path.join(FILE_PREVIEWS_PATH, `${basename}.webp`)
|
||||
|
||||
if (existsSync(outputPath)) {
|
||||
const metadata = await sharp(outputPath).metadata()
|
||||
return {
|
||||
contentType: `image/${metadata.format}`,
|
||||
resizedPath: outputPath,
|
||||
}
|
||||
}
|
||||
|
||||
await sharp(origFilePath)
|
||||
.rotate()
|
||||
.resize(maxWidth, maxHeight, {
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.webp({ quality: QUALITY })
|
||||
.toFile(outputPath)
|
||||
|
||||
return {
|
||||
contentType: "image/webp",
|
||||
resizedPath: outputPath,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error resizing image:", error)
|
||||
return {
|
||||
contentType: "image/unknown",
|
||||
resizedPath: origFilePath,
|
||||
}
|
||||
}
|
||||
}
|
||||
57
lib/pdf.ts
Normal file
57
lib/pdf.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { existsSync } from "fs"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { fromPath } from "pdf2pic"
|
||||
import { FILE_PREVIEWS_PATH } from "./files"
|
||||
|
||||
const MAX_PAGES = 10
|
||||
const DPI = 150
|
||||
const QUALITY = 90
|
||||
const MAX_WIDTH = 1500
|
||||
const MAX_HEIGHT = 1500
|
||||
|
||||
export async function pdfToImages(origFilePath: string): Promise<{ contentType: string; pages: string[] }> {
|
||||
await fs.mkdir(FILE_PREVIEWS_PATH, { recursive: true })
|
||||
|
||||
const basename = path.basename(origFilePath, path.extname(origFilePath))
|
||||
|
||||
// Check if converted pages already exist
|
||||
const existingPages: string[] = []
|
||||
for (let i = 1; i <= MAX_PAGES; i++) {
|
||||
const convertedFilePath = path.join(FILE_PREVIEWS_PATH, `${basename}.${i}.webp`)
|
||||
if (existsSync(convertedFilePath)) {
|
||||
existingPages.push(convertedFilePath)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (existingPages.length > 0) {
|
||||
return { contentType: "image/webp", pages: existingPages }
|
||||
}
|
||||
|
||||
// If not — convert the file as store in previews folder
|
||||
const pdf2picOptions = {
|
||||
density: DPI,
|
||||
saveFilename: basename,
|
||||
savePath: FILE_PREVIEWS_PATH,
|
||||
format: "webp",
|
||||
quality: QUALITY,
|
||||
width: MAX_WIDTH,
|
||||
height: MAX_HEIGHT,
|
||||
preserveAspectRatio: true,
|
||||
}
|
||||
|
||||
try {
|
||||
const convert = fromPath(origFilePath, pdf2picOptions)
|
||||
const results = await convert.bulk(-1, { responseType: "image" }) // TODO: respect MAX_PAGES here too
|
||||
const paths = results.filter((result) => result && result.path).map((result) => result.path) as string[]
|
||||
return {
|
||||
contentType: "image/webp",
|
||||
pages: paths,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error converting PDF to image:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
13
lib/stats.ts
Normal file
13
lib/stats.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Transaction } from "@prisma/client"
|
||||
|
||||
export function calcTotalPerCurrency(transactions: Transaction[]): Record<string, number> {
|
||||
return transactions.reduce((acc, transaction) => {
|
||||
if (transaction.convertedCurrencyCode) {
|
||||
acc[transaction.convertedCurrencyCode] =
|
||||
(acc[transaction.convertedCurrencyCode] || 0) + (transaction.convertedTotal || 0)
|
||||
} else if (transaction.currencyCode) {
|
||||
acc[transaction.currencyCode] = (acc[transaction.currencyCode] || 0) + (transaction.total || 0)
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
}
|
||||
24
lib/utils.ts
Normal file
24
lib/utils.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import slugify from "slugify"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatCurrency(total: number, currency: string) {
|
||||
return new Intl.NumberFormat("en", {
|
||||
style: "currency",
|
||||
currency: currency,
|
||||
}).format(total / 100)
|
||||
}
|
||||
|
||||
export function codeFromName(name: string, maxLength: number = 16) {
|
||||
const code = slugify(name, {
|
||||
replacement: "_",
|
||||
lower: true,
|
||||
strict: true,
|
||||
trim: true,
|
||||
})
|
||||
return code.slice(0, maxLength)
|
||||
}
|
||||
Reference in New Issue
Block a user