feat: invoice generator

This commit is contained in:
Vasily Zubarev
2025-05-07 14:53:13 +02:00
parent 287abbb219
commit 8b5a2e8056
59 changed files with 2606 additions and 124 deletions

27
app/(app)/apps/common.ts Normal file
View File

@@ -0,0 +1,27 @@
import fs from "fs/promises"
import path from "path"
export type AppManifest = {
name: string
description: string
icon: string
}
export async function getApps(): Promise<{ id: string; manifest: AppManifest }[]> {
const appsDir = path.join(process.cwd(), "app/(app)/apps")
const items = await fs.readdir(appsDir, { withFileTypes: true })
const apps = await Promise.all(
items
.filter((item) => item.isDirectory() && item.name !== "apps")
.map(async (item) => {
const manifestModule = await import(`./${item.name}/manifest`)
return {
id: item.name,
manifest: manifestModule.manifest as AppManifest,
}
})
)
return apps
}

View File

@@ -0,0 +1,126 @@
"use server"
import { getCurrentUser, isSubscriptionExpired } from "@/lib/auth"
import {
getTransactionFileUploadPath,
getUserUploadsDirectory,
isEnoughStorageToUploadFile,
safePathJoin,
} from "@/lib/files"
import { getAppData, setAppData } from "@/models/apps"
import { createFile } from "@/models/files"
import { createTransaction, updateTransactionFiles } from "@/models/transactions"
import { Transaction, User } from "@/prisma/client"
import { renderToBuffer } from "@react-pdf/renderer"
import { randomUUID } from "crypto"
import { mkdir, writeFile } from "fs/promises"
import { revalidatePath } from "next/cache"
import path from "path"
import { createElement } from "react"
import { InvoiceFormData } from "./components/invoice-page"
import { InvoicePDF } from "./components/invoice-pdf"
import { InvoiceTemplate } from "./default-templates"
import { InvoiceAppData } from "./page"
export async function generateInvoicePDF(data: InvoiceFormData): Promise<Uint8Array> {
const pdfElement = createElement(InvoicePDF, { data })
const buffer = await renderToBuffer(pdfElement as any)
return new Uint8Array(buffer)
}
export async function addNewTemplateAction(user: User, template: InvoiceTemplate) {
const appData = (await getAppData(user, "invoices")) as InvoiceAppData | null
const updatedTemplates = [...(appData?.templates || []), template]
const appDataResult = await setAppData(user, "invoices", { ...appData, templates: updatedTemplates })
return { success: true, data: appDataResult }
}
export async function deleteTemplateAction(user: User, templateId: string) {
const appData = (await getAppData(user, "invoices")) as InvoiceAppData | null
if (!appData) return { success: false, error: "No app data found" }
const updatedTemplates = appData.templates.filter((t) => t.id !== templateId)
const appDataResult = await setAppData(user, "invoices", { ...appData, templates: updatedTemplates })
return { success: true, data: appDataResult }
}
export async function saveInvoiceAsTransactionAction(
formData: InvoiceFormData
): Promise<{ success: boolean; error?: string; data?: Transaction }> {
try {
const user = await getCurrentUser()
// Generate PDF
const pdfBuffer = await generateInvoicePDF(formData)
// Calculate total amount from items
const subtotal = formData.items.reduce((sum, item) => sum + item.subtotal, 0)
const taxes = formData.additionalTaxes.reduce((sum, tax) => sum + tax.amount, 0)
const fees = formData.additionalFees.reduce((sum, fee) => sum + fee.amount, 0)
const totalAmount = (formData.taxIncluded ? subtotal : subtotal + taxes) + fees
// Create transaction
const transaction = await createTransaction(user.id, {
name: `Invoice #${formData.invoiceNumber || "unknown"}`,
merchant: `${formData.billTo.split("\n")[0]}`,
total: totalAmount * 100,
currencyCode: formData.currency,
issuedAt: new Date(formData.date),
categoryCode: null,
projectCode: null,
type: "income",
status: "pending",
})
// Check storage limits
if (!isEnoughStorageToUploadFile(user, pdfBuffer.length)) {
return {
success: false,
error: "Insufficient storage to save invoice PDF",
}
}
if (isSubscriptionExpired(user)) {
return {
success: false,
error: "Your subscription has expired, please upgrade your account or buy new subscription plan",
}
}
// Save PDF file
const fileUuid = randomUUID()
const fileName = `invoice-${formData.invoiceNumber}.pdf`
const relativeFilePath = getTransactionFileUploadPath(fileUuid, fileName, transaction)
const userUploadsDirectory = getUserUploadsDirectory(user)
const fullFilePath = safePathJoin(userUploadsDirectory, relativeFilePath)
await mkdir(path.dirname(fullFilePath), { recursive: true })
await writeFile(fullFilePath, pdfBuffer)
// Create file record in database
const fileRecord = await createFile(user.id, {
id: fileUuid,
filename: fileName,
path: relativeFilePath,
mimetype: "application/pdf",
isReviewed: true,
metadata: {
size: pdfBuffer.length,
lastModified: Date.now(),
},
})
// Update transaction with the file ID
await updateTransactionFiles(transaction.id, user.id, [fileRecord.id])
revalidatePath("/transactions")
return { success: true, data: transaction }
} catch (error) {
console.error("Failed to save invoice as transaction:", error)
return {
success: false,
error: `Failed to save invoice as transaction: ${error}`,
}
}
}

View File

@@ -0,0 +1,278 @@
"use client"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { fetchAsBase64 } from "@/lib/utils"
import { SettingsMap } from "@/models/settings"
import { Currency, User } from "@/prisma/client"
import { FileDown, Save, TextSelect, X } from "lucide-react"
import { useRouter } from "next/navigation"
import { startTransition, useMemo, useReducer, useState } from "react"
import {
addNewTemplateAction,
deleteTemplateAction,
generateInvoicePDF,
saveInvoiceAsTransactionAction,
} from "../actions"
import defaultTemplates, { InvoiceTemplate } from "../default-templates"
import { InvoiceAppData } from "../page"
import { InvoiceFormData, InvoicePage } from "./invoice-page"
function invoiceFormReducer(state: InvoiceFormData, action: any): InvoiceFormData {
switch (action.type) {
case "SET_FORM":
return action.payload
case "UPDATE_FIELD":
return { ...state, [action.field]: action.value }
case "ADD_ITEM":
return { ...state, items: [...state.items, { description: "", quantity: 1, unitPrice: 0, subtotal: 0 }] }
case "UPDATE_ITEM": {
const items = [...state.items]
items[action.index] = { ...items[action.index], [action.field]: action.value }
if (action.field === "quantity" || action.field === "unitPrice") {
items[action.index].subtotal = Number(items[action.index].quantity) * Number(items[action.index].unitPrice)
}
return { ...state, items }
}
case "REMOVE_ITEM":
return { ...state, items: state.items.filter((_, i) => i !== action.index) }
case "ADD_TAX":
return { ...state, additionalTaxes: [...state.additionalTaxes, { name: "", rate: 0, amount: 0 }] }
case "UPDATE_TAX": {
const taxes = [...state.additionalTaxes]
taxes[action.index] = { ...taxes[action.index], [action.field]: action.value }
if (action.field === "rate") {
const subtotal = state.items.reduce((sum, item) => sum + item.subtotal, 0)
taxes[action.index].amount = (subtotal * Number(action.value)) / 100
}
return { ...state, additionalTaxes: taxes }
}
case "REMOVE_TAX":
return { ...state, additionalTaxes: state.additionalTaxes.filter((_, i) => i !== action.index) }
case "ADD_FEE":
return { ...state, additionalFees: [...state.additionalFees, { name: "", amount: 0 }] }
case "UPDATE_FEE": {
const fees = [...state.additionalFees]
fees[action.index] = { ...fees[action.index], [action.field]: action.value }
return { ...state, additionalFees: fees }
}
case "REMOVE_FEE":
return { ...state, additionalFees: state.additionalFees.filter((_, i) => i !== action.index) }
default:
return state
}
}
export function InvoiceGenerator({
user,
settings,
currencies,
appData,
}: {
user: User
settings: SettingsMap
currencies: Currency[]
appData: InvoiceAppData | null
}) {
const templates: InvoiceTemplate[] = useMemo(
() => [...defaultTemplates(user, settings), ...(appData?.templates || [])],
[appData]
)
const [selectedTemplate, setSelectedTemplate] = useState<string>(templates[0].name)
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false)
const [newTemplateName, setNewTemplateName] = useState("")
const [formData, dispatch] = useReducer(invoiceFormReducer, templates[0].formData)
const router = useRouter()
// Function to handle template selection
const handleTemplateSelect = (templateName: string) => {
const template = templates.find((t) => t.name === templateName)
if (template) {
setSelectedTemplate(templateName)
dispatch({ type: "SET_FORM", payload: template.formData })
}
}
const handleGeneratePDF = async (e: React.FormEvent) => {
e.preventDefault()
if (formData.businessLogo) {
formData.businessLogo = await fetchAsBase64(formData.businessLogo)
}
try {
const pdfBuffer = await generateInvoicePDF(formData)
// Create a blob from the buffer
const blob = new Blob([pdfBuffer], { type: "application/pdf" })
// Create a URL for the blob
const url = URL.createObjectURL(blob)
// Create a temporary link element
const link = document.createElement("a")
link.href = url
link.download = `invoice-${formData.invoiceNumber}.pdf`
// Append the link to the document, click it, and remove it
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// Clean up the URL
URL.revokeObjectURL(url)
} catch (error) {
console.error("Error generating PDF:", error)
alert("Failed to generate PDF. Please try again.")
}
}
const handleSaveTemplate = async () => {
if (!newTemplateName.trim()) {
alert("Please enter a template name")
return
}
if (templates.some((t) => t.name === newTemplateName)) {
alert("A template with this name already exists")
return
}
try {
// Get existing templates
const result = await addNewTemplateAction(user, {
id: `tmpl_${Math.random().toString(36).substring(2, 15)}`,
name: newTemplateName,
formData: formData,
})
if (result.success) {
setIsTemplateDialogOpen(false)
setNewTemplateName("")
router.refresh()
} else {
alert("Failed to save template. Please try again.")
}
} catch (error) {
console.error("Error saving template:", error)
alert("Failed to save template. Please try again.")
}
}
const handleDeleteTemplate = async (templateId: string | undefined, e: React.MouseEvent) => {
e.stopPropagation()
if (!templateId) return // Don't allow deleting default templates
try {
const result = await deleteTemplateAction(user, templateId)
if (result.success) {
router.refresh()
}
} catch (error) {
console.error("Error deleting template:", error)
alert("Failed to delete template. Please try again.")
}
}
// Accept optional event, prevent default only if present
const handleSaveAsTransaction = async (e?: React.FormEvent) => {
if (e) e.preventDefault()
if (formData.businessLogo) {
formData.businessLogo = await fetchAsBase64(formData.businessLogo)
}
try {
const result = await saveInvoiceAsTransactionAction(formData)
if (result.success && result.data?.id) {
console.log("SUCCESS! REDIRECTING TO TRANSACTION", result.data?.id)
startTransition(() => {
router.push(`/transactions/${result.data?.id}`)
})
} else {
alert(result.error || "Failed to save as transaction")
}
} catch (error) {
console.error("Error saving as transaction:", error)
alert("Failed to save as transaction. Please try again.")
}
}
return (
<div className="flex flex-col gap-6">
{/* Templates Section */}
<div className="py-4 flex overflow-x-auto gap-2">
{templates.map((template) => (
<div key={template.name} className="relative group">
<Button
variant={selectedTemplate === template.name ? "default" : "outline"}
className={`
whitespace-nowrap p-4
${selectedTemplate === template.name ? "bg-black hover:bg-gray-900" : "border-gray-300 text-gray-700 hover:bg-gray-100"}
`}
onClick={() => handleTemplateSelect(template.name)}
>
{template.name}
</Button>
{template.id && (
<Button
variant="destructive"
size="icon"
className="absolute -top-2 -right-2 h-5 w-5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => handleDeleteTemplate(template.id, e)}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
<div className="flex flex-row flex-wrap justify-start items-start gap-4">
<InvoicePage invoiceData={formData} dispatch={dispatch} currencies={currencies} />
{/* Generate PDF Button */}
<div className="flex flex-col gap-4">
<Button onClick={handleGeneratePDF}>
<FileDown />
Download PDF
</Button>
<Button variant="secondary" onClick={() => setIsTemplateDialogOpen(true)}>
<TextSelect />
Make a Template
</Button>
<Button variant="secondary" onClick={handleSaveAsTransaction}>
<Save />
Save as Transaction
</Button>
</div>
</div>
{/* New Template Dialog */}
<Dialog open={isTemplateDialogOpen} onOpenChange={setIsTemplateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save as Template</DialogTitle>
</DialogHeader>
<div className="py-4">
<input
type="text"
value={newTemplateName}
onChange={(e) => setNewTemplateName(e.target.value)}
placeholder="Enter template name"
className="w-full px-3 py-2 border rounded-md"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsTemplateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSaveTemplate}>Save Template</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,584 @@
import { FormSelectCurrency } from "@/components/forms/select-currency"
import { FormAvatar, FormInput, FormTextarea } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { formatCurrency } from "@/lib/utils"
import { Currency } from "@/prisma/client"
import { X } from "lucide-react"
import { InputHTMLAttributes, memo, useCallback, useMemo } from "react"
export interface InvoiceItem {
description: string
quantity: number
unitPrice: number
subtotal: number
}
export interface AdditionalTax {
name: string
rate: number
amount: number
}
export interface AdditionalFee {
name: string
amount: number
}
export interface InvoiceFormData {
title: string
businessLogo: string | null
invoiceNumber: string
date: string
dueDate: string
currency: string
companyDetails: string
companyDetailsLabel: string
billTo: string
billToLabel: string
items: InvoiceItem[]
taxIncluded: boolean
additionalTaxes: AdditionalTax[]
additionalFees: AdditionalFee[]
notes: string
bankDetails: string
issueDateLabel: string
dueDateLabel: string
itemLabel: string
quantityLabel: string
unitPriceLabel: string
subtotalLabel: string
summarySubtotalLabel: string
summaryTotalLabel: string
}
interface InvoicePageProps {
invoiceData: InvoiceFormData
dispatch: React.Dispatch<any>
currencies: Currency[]
}
// Memoized row for invoice items
const ItemRow = memo(function ItemRow({
item,
index,
onChange,
onRemove,
currency,
}: {
item: InvoiceItem
index: number
onChange: (index: number, field: keyof InvoiceItem, value: string | number) => void
onRemove: (index: number) => void
currency: string
}) {
return (
<tr>
<td className="px-4 py-2">
<FormInput
type="text"
value={item.description}
onChange={(e) => onChange(index, "description", e.target.value)}
className="w-full min-w-64"
placeholder="Item description"
required
/>
</td>
<td className="px-4 py-2">
<FormInput
type="number"
min="1"
value={item.quantity}
onChange={(e) => onChange(index, "quantity", Number(e.target.value))}
className="w-20 text-right"
required
/>
</td>
<td className="px-4 py-2">
<FormInput
type="number"
step="0.01"
value={item.unitPrice}
onChange={(e) => onChange(index, "unitPrice", Number(e.target.value))}
className="w-24 text-right"
required
/>
</td>
<td className="px-4 py-2 text-right">{formatCurrency(item.subtotal * 100, currency)}</td>
<td className="px-4 py-2">
<Button variant="destructive" className="rounded-full p-1 h-5 w-5" onClick={() => onRemove(index)}>
<X />
</Button>
</td>
</tr>
)
})
// Memoized row for additional taxes
const TaxRow = memo(function TaxRow({
tax,
index,
onChange,
onRemove,
currency,
}: {
tax: AdditionalTax
index: number
onChange: (index: number, field: keyof AdditionalTax, value: string | number) => void
onRemove: (index: number) => void
currency: string
}) {
return (
<div className="flex justify-between items-center">
<div className="w-full flex flex-row gap-2 items-center">
<Button variant="destructive" className="rounded-full p-1 h-5 w-5" onClick={() => onRemove(index)}>
<X />
</Button>
<FormInput
type="text"
value={tax.name}
onChange={(e) => onChange(index, "name", e.target.value)}
placeholder="Tax name"
/>
<FormInput
type="number"
max="100"
value={tax.rate}
onChange={(e) => onChange(index, "rate", Number(e.target.value))}
className="w-12 text-right"
/>
<span className="text-sm text-gray-600">%</span>
<span className="text-sm text-nowrap">{formatCurrency(tax.amount * 100, currency)}</span>
</div>
</div>
)
})
// Memoized row for additional fees
const FeeRow = memo(function FeeRow({
fee,
index,
onChange,
onRemove,
currency,
}: {
fee: AdditionalFee
index: number
onChange: (index: number, field: keyof AdditionalFee, value: string | number) => void
onRemove: (index: number) => void
currency: string
}) {
return (
<div className="w-full flex justify-between items-center">
<div className="w-full flex flex-row gap-2 items-center justify-between">
<Button variant="destructive" className="rounded-full p-1 h-5 w-5" onClick={() => onRemove(index)}>
<X />
</Button>
<FormInput
type="text"
value={fee.name}
onChange={(e) => onChange(index, "name", e.target.value)}
placeholder="Fee or discount name"
/>
<FormInput
type="number"
step="0.01"
value={fee.amount}
onChange={(e) => onChange(index, "amount", Number(e.target.value))}
className="w-16 text-right"
/>
<span className="text-sm text-nowrap">{formatCurrency(fee.amount * 100, currency)}</span>
</div>
</div>
)
})
export function InvoicePage({ invoiceData, dispatch, currencies }: InvoicePageProps) {
const addItem = useCallback(() => dispatch({ type: "ADD_ITEM" }), [dispatch])
const removeItem = useCallback((index: number) => dispatch({ type: "REMOVE_ITEM", index }), [dispatch])
const updateItem = useCallback(
(index: number, field: keyof InvoiceItem, value: string | number) =>
dispatch({ type: "UPDATE_ITEM", index, field, value }),
[dispatch]
)
const addAdditionalTax = useCallback(() => dispatch({ type: "ADD_TAX" }), [dispatch])
const removeAdditionalTax = useCallback((index: number) => dispatch({ type: "REMOVE_TAX", index }), [dispatch])
const updateAdditionalTax = useCallback(
(index: number, field: keyof AdditionalTax, value: string | number) =>
dispatch({ type: "UPDATE_TAX", index, field, value }),
[dispatch]
)
const addAdditionalFee = useCallback(() => dispatch({ type: "ADD_FEE" }), [dispatch])
const removeAdditionalFee = useCallback((index: number) => dispatch({ type: "REMOVE_FEE", index }), [dispatch])
const updateAdditionalFee = useCallback(
(index: number, field: keyof AdditionalFee, value: string | number) =>
dispatch({ type: "UPDATE_FEE", index, field, value }),
[dispatch]
)
const subtotal = useMemo(() => invoiceData.items.reduce((sum, item) => sum + item.subtotal, 0), [invoiceData.items])
const taxes = useMemo(
() => invoiceData.additionalTaxes.reduce((sum, tax) => sum + tax.amount, 0),
[invoiceData.additionalTaxes]
)
const fees = useMemo(
() => invoiceData.additionalFees.reduce((sum, fee) => sum + fee.amount, 0),
[invoiceData.additionalFees]
)
const total = useMemo(
() => (invoiceData.taxIncluded ? subtotal : subtotal + taxes) + fees,
[invoiceData.taxIncluded, subtotal, taxes, fees]
)
return (
<div className="relative w-full max-w-[794px] sm:w-[794px] min-h-[297mm] bg-white shadow-lg p-2 sm:p-8 mb-8">
{/* Gradient Background */}
<div className="absolute top-0 left-0 right-0 h-[25%] bg-gradient-to-b from-indigo-100 to-indigo-0 opacity-70" />
{/* Invoice Header */}
<div className="flex flex-col sm:flex-row gap-4 sm:gap-8 justify-between items-start mb-8 relative">
<div className="w-full flex flex-col space-y-2">
<ShadyFormInput
type="text"
value={invoiceData.title}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "title", value: e.target.value })}
className="text-2xl sm:text-4xl font-extrabold"
placeholder="INVOICE"
required
/>
<FormInput
placeholder="Invoice ID or subtitle"
value={invoiceData.invoiceNumber}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "invoiceNumber", value: e.target.value })}
className="w-full sm:w-[200px] font-medium"
/>
</div>
<div className="flex flex-row items-center justify-end mt-4 sm:mt-0">
<FormAvatar
name="businessLogo"
className="w-[60px] h-[60px] sm:w-[100px] sm:h-[100px]"
defaultValue={invoiceData.businessLogo || ""}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
const objectUrl = URL.createObjectURL(file)
dispatch({ type: "UPDATE_FIELD", field: "businessLogo", value: objectUrl })
} else {
dispatch({ type: "UPDATE_FIELD", field: "businessLogo", value: null })
}
}}
/>
</div>
</div>
{/* Company and Bill To */}
<div className="relative grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-8 mb-8">
<div className="flex flex-col gap-1">
<ShadyFormInput
type="text"
value={invoiceData.companyDetailsLabel}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "companyDetailsLabel", value: e.target.value })}
className="text-xs sm:text-sm font-medium"
/>
<FormTextarea
value={invoiceData.companyDetails}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "companyDetails", value: e.target.value })}
rows={4}
placeholder="Your Company Name, Address, City, State, ZIP, Country, Tax ID"
required
/>
</div>
<div className="flex flex-col gap-1">
<ShadyFormInput
type="text"
value={invoiceData.billToLabel}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "billToLabel", value: e.target.value })}
className="text-xs sm:text-sm font-medium"
/>
<FormTextarea
value={invoiceData.billTo}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "billTo", value: e.target.value })}
rows={4}
placeholder="Client Name, Address, City, State, ZIP, Country, Tax ID"
required
/>
</div>
</div>
<div className="relative flex flex-col sm:flex-row items-start sm:items-end justify-between mb-8 gap-4">
<div className="flex flex-row items-center gap-4 w-full sm:w-auto">
<div className="flex flex-col gap-1 w-full">
<ShadyFormInput
type="text"
value={invoiceData.issueDateLabel}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "issueDateLabel", value: e.target.value })}
className="text-xs sm:text-sm font-medium"
/>
<FormInput
type="date"
value={invoiceData.date}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "date", value: e.target.value })}
className="w-full border-b border-gray-300 py-1"
required
/>
</div>
<div className="flex flex-col gap-1 w-full">
<ShadyFormInput
type="text"
value={invoiceData.dueDateLabel}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "dueDateLabel", value: e.target.value })}
className="text-xs sm:text-sm font-medium"
/>
<FormInput
type="date"
value={invoiceData.dueDate}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "dueDate", value: e.target.value })}
required
/>
</div>
</div>
<div className="w-full sm:w-auto flex justify-end">
<FormSelectCurrency
currencies={currencies}
value={invoiceData.currency}
onValueChange={(value) => dispatch({ type: "UPDATE_FIELD", field: "currency", value })}
/>
</div>
</div>
{/* Items Table */}
<div className="mb-8">
<div className="border rounded-lg overflow-hidden">
{/* Table for desktop/tablet */}
<div className="overflow-x-auto sm:block hidden">
<table className="min-w-[600px] w-full divide-y divide-gray-200 text-xs sm:text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-2 sm:px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<ShadyFormInput
type="text"
value={invoiceData.itemLabel}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "itemLabel", value: e.target.value })}
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
/>
</th>
<th className="px-2 sm:px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<ShadyFormInput
type="text"
value={invoiceData.quantityLabel}
onChange={(e) =>
dispatch({ type: "UPDATE_FIELD", field: "quantityLabel", value: e.target.value })
}
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
/>
</th>
<th className="px-2 sm:px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<ShadyFormInput
type="text"
value={invoiceData.unitPriceLabel}
onChange={(e) =>
dispatch({ type: "UPDATE_FIELD", field: "unitPriceLabel", value: e.target.value })
}
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
/>
</th>
<th className="px-2 sm:px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<ShadyFormInput
type="text"
value={invoiceData.subtotalLabel}
onChange={(e) =>
dispatch({ type: "UPDATE_FIELD", field: "subtotalLabel", value: e.target.value })
}
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
/>
</th>
<th className="px-2 sm:px-4 py-2"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{invoiceData.items.map((item, index) => (
<ItemRow
key={index}
item={item}
index={index}
onChange={updateItem}
onRemove={removeItem}
currency={invoiceData.currency}
/>
))}
</tbody>
</table>
</div>
{/* Flex list for mobile */}
<div className="sm:hidden flex flex-col gap-2 p-2">
{invoiceData.items.map((item, index) => (
<div key={index} className="flex flex-col gap-1 border rounded-lg p-3 bg-gray-50 relative">
<button
type="button"
className="absolute top-2 right-2 text-gray-400 hover:text-red-500"
onClick={() => removeItem(index)}
aria-label="Remove item"
>
<X size={18} />
</button>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium">{invoiceData.itemLabel}</label>
<FormInput
type="text"
value={item.description}
onChange={(e) => updateItem(index, "description", e.target.value)}
className="w-full min-w-0"
placeholder="Item description"
required
/>
</div>
<div className="flex gap-2 mt-2">
<div className="flex-1 flex flex-col">
<label className="text-xs text-gray-500 font-medium">{invoiceData.quantityLabel}</label>
<FormInput
type="number"
min="1"
value={item.quantity}
onChange={(e) => updateItem(index, "quantity", Number(e.target.value))}
className="w-full text-right"
required
/>
</div>
<div className="flex-1 flex flex-col">
<label className="text-xs text-gray-500 font-medium">{invoiceData.unitPriceLabel}</label>
<FormInput
type="number"
step="0.01"
value={item.unitPrice}
onChange={(e) => updateItem(index, "unitPrice", Number(e.target.value))}
className="w-full text-right"
required
/>
</div>
<div className="flex-1 flex flex-col items-end">
<label className="text-xs text-gray-500 font-medium">{invoiceData.subtotalLabel}</label>
<span className="text-sm font-semibold mt-2">
{formatCurrency(item.subtotal * 100, invoiceData.currency)}
</span>
</div>
</div>
</div>
))}
</div>
<Button onClick={addItem} className="m-2 sm:m-3 w-full sm:w-auto">
+ Add Item
</Button>
</div>
</div>
{/* Notes */}
<div className="mb-8">
<FormTextarea
value={invoiceData.notes}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "notes", value: e.target.value })}
className="w-full border border-gray-300 rounded p-2 text-xs sm:text-sm"
rows={3}
placeholder="Additional notes or terms"
/>
</div>
{/* Summary */}
<div className="flex justify-end">
<div className="w-full sm:w-72 space-y-2">
<div className="flex justify-between">
<ShadyFormInput
type="text"
value={invoiceData.summarySubtotalLabel}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "summarySubtotalLabel", value: e.target.value })}
className="text-xs sm:text-sm font-medium text-gray-600"
/>
<span className="text-xs sm:text-sm">{formatCurrency(subtotal * 100, invoiceData.currency)}</span>
</div>
<div className="flex flex-col gap-2 items-start">
{/* Additional Taxes */}
{invoiceData.additionalTaxes.map((tax, index) => (
<TaxRow
key={index}
tax={tax}
index={index}
onChange={updateAdditionalTax}
onRemove={removeAdditionalTax}
currency={invoiceData.currency}
/>
))}
<div className="w-full flex justify-end">
<Button onClick={addAdditionalTax} className="w-full sm:w-auto">
+ Add Tax
</Button>
</div>
{invoiceData.additionalFees.map((fee, index) => (
<FeeRow
key={index}
fee={fee}
index={index}
onChange={updateAdditionalFee}
onRemove={removeAdditionalFee}
currency={invoiceData.currency}
/>
))}
<div className="w-full flex justify-end">
<Button onClick={addAdditionalFee} className="w-full sm:w-auto">
+ Add Fee or Discount
</Button>
</div>
</div>
<label className="flex items-center space-x-1">
<input
type="checkbox"
checked={invoiceData.taxIncluded}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "taxIncluded", value: e.target.checked })}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<span className="text-gray-600 text-xs sm:text-sm">Taxes are included in price</span>
</label>
<div className="flex justify-between border-t pt-2">
<ShadyFormInput
type="text"
value={invoiceData.summaryTotalLabel}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "summaryTotalLabel", value: e.target.value })}
className="text-sm sm:text-md font-bold"
/>
<span className="text-sm sm:text-md font-bold text-nowrap">
{formatCurrency(total * 100, invoiceData.currency)}
</span>
</div>
</div>
</div>
{/* Bank Details Footer */}
<div className="mt-8 pt-8 border-t">
<textarea
value={invoiceData.bankDetails}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "bankDetails", value: e.target.value })}
className="text-center text-xs sm:text-sm text-muted-foreground w-full mx-auto border border-gray-300 rounded p-2"
rows={3}
placeholder="Bank and Payment Details: Account number, Bank name, IBAN, SWIFT/BIC, Your Email (optional)"
required
/>
</div>
</div>
)
}
function ShadyFormInput({ className = "", ...props }: { className?: string } & InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={`bg-transparent border border-transparent outline-none p-0 w-full hover:border-dashed hover:border-gray-200 hover:bg-gray-50 focus:bg-gray-50 hover:rounded-sm ${className}`}
{...props}
/>
)
}

View File

@@ -0,0 +1,403 @@
import { formatCurrency } from "@/lib/utils"
import { Document, Font, Image, Page, StyleSheet, Text, View } from "@react-pdf/renderer"
import { formatDate } from "date-fns"
import { ReactElement } from "react"
import { AdditionalFee, AdditionalTax, InvoiceFormData, InvoiceItem } from "./invoice-page"
Font.register({
family: "Inter",
fonts: [
{
src: "public/fonts/Inter/Inter-Regular.otf",
fontWeight: 400,
fontStyle: "normal",
},
{
src: "public/fonts/Inter/Inter-Medium.otf",
fontWeight: 500,
fontStyle: "normal",
},
{
src: "public/fonts/Inter/Inter-SemiBold.otf",
fontWeight: 600,
fontStyle: "normal",
},
{
src: "public/fonts/Inter/Inter-Bold.otf",
fontWeight: 700,
fontStyle: "normal",
},
{
src: "public/fonts/Inter/Inter-ExtraBold.otf",
fontWeight: 800,
fontStyle: "normal",
},
{
src: "public/fonts/Inter/Inter-Black.otf",
fontWeight: 900,
fontStyle: "normal",
},
{
src: "public/fonts/Inter-Italic.otf",
fontWeight: 400,
fontStyle: "italic",
},
{
src: "public/fonts/Inter/Inter-MediumItalic.otf",
fontWeight: 500,
fontStyle: "italic",
},
{
src: "public/fonts/Inter/Inter-SemiBoldItalic.otf",
fontWeight: 600,
fontStyle: "italic",
},
{
src: "public/fonts/Inter/Inter-BoldItalic.otf",
fontWeight: 700,
fontStyle: "italic",
},
{
src: "public/fonts/Inter/Inter-ExtraBoldItalic.otf",
fontWeight: 800,
fontStyle: "italic",
},
{
src: "public/fonts/Inter/Inter-BlackItalic.otf",
fontWeight: 900,
fontStyle: "italic",
},
],
})
Font.registerEmojiSource({
format: "png",
url: "https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/",
})
const styles = StyleSheet.create({
page: {
fontFamily: "Inter",
padding: 30,
backgroundColor: "#ffffff",
},
header: {
marginBottom: 30,
position: "relative",
},
gradientBackground: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "120px",
backgroundColor: "#eef2ff",
opacity: 0.8,
},
headerContent: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 30,
},
headerLeft: {
flex: 1,
},
headerRight: {
width: 110,
height: 60,
},
title: {
fontSize: 28,
marginBottom: 10,
fontWeight: "extrabold",
color: "#000000",
},
subtitle: {
fontSize: 13,
color: "#666666",
},
logo: {
width: "100%",
height: "100%",
objectFit: "contain",
},
companyDetails: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 30,
},
companySection: {
flex: 1,
marginRight: 30,
},
sectionLabel: {
fontSize: 12,
fontWeight: "bold",
marginBottom: 8,
color: "#000000",
},
sectionContent: {
fontSize: 12,
lineHeight: 1.3,
color: "#000000",
},
datesAndCurrency: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
marginBottom: 30,
},
dateGroup: {
flexDirection: "row",
gap: 20,
},
dateItem: {
marginRight: 20,
},
dateLabel: {
fontSize: 12,
fontWeight: "bold",
marginBottom: 8,
color: "#000000",
},
dateValue: {
fontSize: 12,
color: "#000000",
},
itemsTable: {
marginBottom: 30,
},
tableHeader: {
flexDirection: "row",
borderBottomWidth: 1,
borderBottomColor: "#E5E7EB",
paddingVertical: 8,
backgroundColor: "#F9FAFB",
textTransform: "uppercase",
},
tableRow: {
flexDirection: "row",
borderBottomWidth: 1,
borderBottomColor: "#E5E7EB",
paddingVertical: 8,
},
colDescription: {
flex: 2,
paddingHorizontal: 10,
},
colQuantity: {
flex: 1,
paddingHorizontal: 10,
textAlign: "right",
},
colPrice: {
flex: 1,
paddingHorizontal: 10,
textAlign: "right",
},
colSubtotal: {
flex: 1,
paddingHorizontal: 10,
textAlign: "right",
},
colHeader: {
fontSize: 10,
fontWeight: "bold",
color: "#6B7280",
},
colValue: {
fontSize: 12,
color: "#000000",
},
notes: {
marginBottom: 30,
fontSize: 12,
},
notesLabel: {
fontWeight: "bold",
marginBottom: 5,
color: "#000000",
},
summary: {
width: "50%",
alignSelf: "flex-end",
},
summaryRow: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 5,
},
summaryLabel: {
fontSize: 12,
color: "#4B5563",
},
summaryValue: {
fontSize: 12,
color: "#000000",
},
taxRow: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 5,
},
totalRow: {
flexDirection: "row",
justifyContent: "space-between",
marginTop: 10,
paddingTop: 10,
borderTopWidth: 1,
borderTopColor: "#000000",
},
totalLabel: {
fontSize: 14,
fontWeight: "bold",
color: "#000000",
},
totalValue: {
fontSize: 14,
fontWeight: "bold",
color: "#000000",
},
bankDetails: {
marginTop: 30,
paddingTop: 20,
borderTopWidth: 1,
borderTopColor: "#E5E7EB",
textAlign: "center",
fontSize: 11,
color: "#6B7280",
},
})
export function InvoicePDF({ data }: { data: InvoiceFormData }): ReactElement {
const calculateSubtotal = (): number => {
return data.items.reduce((sum: number, item: InvoiceItem) => sum + item.subtotal, 0)
}
const calculateTaxes = (): number => {
return data.additionalTaxes.reduce((sum: number, tax: AdditionalTax) => sum + tax.amount, 0)
}
const calculateTotal = (): number => {
const subtotal = calculateSubtotal()
const taxes = calculateTaxes()
return data.taxIncluded ? subtotal : subtotal + taxes
}
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.gradientBackground} />
{/* Header */}
<View style={styles.header}>
<View style={styles.headerContent}>
<View style={styles.headerLeft}>
<Text style={styles.title}>{data.title}</Text>
<Text style={styles.subtitle}>{data.invoiceNumber}</Text>
</View>
{data.businessLogo && (
<View style={styles.headerRight}>
<Image src={data.businessLogo} style={styles.logo} />
</View>
)}
</View>
</View>
{/* Company Details and Bill To */}
<View style={styles.companyDetails}>
<View style={styles.companySection}>
<Text style={styles.sectionLabel}>{data.companyDetailsLabel}</Text>
<Text style={styles.sectionContent}>{data.companyDetails}</Text>
</View>
<View style={styles.companySection}>
<Text style={styles.sectionLabel}>{data.billToLabel}</Text>
<Text style={styles.sectionContent}>{data.billTo}</Text>
</View>
</View>
{/* Dates and Currency */}
<View style={styles.datesAndCurrency}>
<View style={styles.dateGroup}>
<View style={styles.dateItem}>
<Text style={styles.dateLabel}>{data.issueDateLabel}</Text>
<Text style={styles.dateValue}>{formatDate(data.date, "yyyy-MM-dd")}</Text>
</View>
<View style={styles.dateItem}>
<Text style={styles.dateLabel}>{data.dueDateLabel}</Text>
<Text style={styles.dateValue}>{formatDate(data.dueDate, "yyyy-MM-dd")}</Text>
</View>
</View>
</View>
{/* Items Table */}
<View style={styles.itemsTable}>
<View style={styles.tableHeader}>
<Text style={[styles.colHeader, styles.colDescription]}>{data.itemLabel}</Text>
<Text style={[styles.colHeader, styles.colQuantity]}>{data.quantityLabel}</Text>
<Text style={[styles.colHeader, styles.colPrice]}>{data.unitPriceLabel}</Text>
<Text style={[styles.colHeader, styles.colSubtotal]}>{data.subtotalLabel}</Text>
</View>
{data.items.map((item: InvoiceItem, index: number) => (
<View key={index} style={styles.tableRow}>
<Text style={[styles.colValue, styles.colDescription]}>{item.description}</Text>
<Text style={[styles.colValue, styles.colQuantity]}>{item.quantity}</Text>
<Text style={[styles.colValue, styles.colPrice]}>
{formatCurrency(item.unitPrice * 100, data.currency)}
</Text>
<Text style={[styles.colValue, styles.colSubtotal]}>
{formatCurrency(item.subtotal * 100, data.currency)}
</Text>
</View>
))}
</View>
{/* Notes */}
{data.notes && (
<View style={styles.notes}>
<Text style={styles.notesLabel}>Notes:</Text>
<Text>{data.notes}</Text>
</View>
)}
{/* Summary */}
<View style={styles.summary}>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>{data.summarySubtotalLabel}</Text>
<Text style={styles.summaryValue}>{formatCurrency(calculateSubtotal() * 100, data.currency)}</Text>
</View>
{data.additionalTaxes.map((tax: AdditionalTax, index: number) => (
<View key={index} style={styles.taxRow}>
<Text style={styles.summaryLabel}>
{tax.name} ({tax.rate}%):
</Text>
<Text style={styles.summaryValue}>{formatCurrency(tax.amount * 100, data.currency)}</Text>
</View>
))}
{data.additionalFees.map((fee: AdditionalFee, index: number) => (
<View key={index} style={styles.taxRow}>
<Text style={styles.summaryLabel}>{fee.name}</Text>
<Text style={styles.summaryValue}>{formatCurrency(fee.amount * 100, data.currency)}</Text>
</View>
))}
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>{data.summaryTotalLabel}</Text>
<Text style={styles.totalValue}>{formatCurrency(calculateTotal() * 100, data.currency)}</Text>
</View>
</View>
{/* Bank Details */}
{data.bankDetails && (
<View style={styles.bankDetails}>
<Text>{data.bankDetails}</Text>
</View>
)}
</Page>
</Document>
)
}

View File

@@ -0,0 +1,71 @@
import { SettingsMap } from "@/models/settings"
import { User } from "@/prisma/client"
import { addDays, format } from "date-fns"
import { InvoiceFormData } from "./components/invoice-page"
export interface InvoiceTemplate {
id?: string
name: string
formData: InvoiceFormData
}
export default function defaultTemplates(user: User, settings: SettingsMap): InvoiceTemplate[] {
const defaultTemplate: InvoiceFormData = {
title: "INVOICE",
businessLogo: user.businessLogo,
invoiceNumber: "",
date: format(new Date(), "yyyy-MM-dd"),
dueDate: format(addDays(new Date(), 30), "yyyy-MM-dd"),
currency: settings.default_currency || "EUR",
companyDetails: `${user.businessName}\n${user.businessAddress || ""}`,
companyDetailsLabel: "Bill From",
billTo: "",
billToLabel: "Bill To",
items: [{ description: "", quantity: 1, unitPrice: 0, subtotal: 0 }],
taxIncluded: true,
additionalTaxes: [{ name: "VAT", rate: 0, amount: 0 }],
additionalFees: [],
notes: "",
bankDetails: user.businessBankDetails || "",
issueDateLabel: "Issue Date",
dueDateLabel: "Due Date",
itemLabel: "Item",
quantityLabel: "Quantity",
unitPriceLabel: "Unit Price",
subtotalLabel: "Subtotal",
summarySubtotalLabel: "Subtotal:",
summaryTotalLabel: "Total:",
}
const germanTemplate: InvoiceFormData = {
title: "RECHNUNG",
businessLogo: user.businessLogo,
invoiceNumber: "",
date: format(new Date(), "yyyy-MM-dd"),
dueDate: format(addDays(new Date(), 30), "yyyy-MM-dd"),
currency: "EUR",
companyDetails: `${user.businessName}\n${user.businessAddress || ""}`,
companyDetailsLabel: "Rechnungssteller",
billTo: "",
billToLabel: "Rechnungsempfänger",
items: [{ description: "", quantity: 1, unitPrice: 0, subtotal: 0 }],
taxIncluded: true,
additionalTaxes: [{ name: "MwSt", rate: 19, amount: 0 }],
additionalFees: [],
notes: "",
bankDetails: user.businessBankDetails || "",
issueDateLabel: "Rechnungsdatum",
dueDateLabel: "Fälligkeitsdatum",
itemLabel: "Position",
quantityLabel: "Menge",
unitPriceLabel: "Einzelpreis",
subtotalLabel: "Zwischensumme",
summarySubtotalLabel: "Zwischensumme:",
summaryTotalLabel: "Gesamtbetrag:",
}
return [
{ name: "Default", formData: defaultTemplate },
{ name: "DE", formData: germanTemplate },
]
}

View File

@@ -0,0 +1,7 @@
import { AppManifest } from "../common"
export const manifest: AppManifest = {
name: "Invoice Generator",
description: "Generate custom invoices and send them to your customers",
icon: "🧾",
}

View File

@@ -0,0 +1,31 @@
import { getCurrentUser } from "@/lib/auth"
import { getAppData } from "@/models/apps"
import { getCurrencies } from "@/models/currencies"
import { getSettings } from "@/models/settings"
import { InvoiceGenerator } from "./components/invoice-generator"
import { InvoiceTemplate } from "./default-templates"
import { manifest } from "./manifest"
export type InvoiceAppData = {
templates: InvoiceTemplate[]
}
export default async function InvoicesApp() {
const user = await getCurrentUser()
const settings = await getSettings(user.id)
const currencies = await getCurrencies(user.id)
const appData = (await getAppData(user, "invoices")) as InvoiceAppData | null
return (
<div>
<header className="flex flex-wrap items-center justify-between gap-2 mb-8">
<h2 className="flex flex-row gap-3 md:gap-5">
<span className="text-3xl font-bold tracking-tight">
{manifest.icon} {manifest.name}
</span>
</h2>
</header>
<InvoiceGenerator user={user} settings={settings} currencies={currencies} appData={appData} />
</div>
)
}

View File

@@ -0,0 +1,3 @@
export default async function AppsLayout({ children }: { children: React.ReactNode }) {
return <div className="flex flex-col gap-4 p-4">{children}</div>
}

35
app/(app)/apps/page.tsx Normal file
View File

@@ -0,0 +1,35 @@
import Link from "next/link"
import { getApps } from "./common"
export default async function AppsPage() {
const apps = await getApps()
return (
<>
<header className="flex flex-wrap items-center justify-between gap-2 mb-8">
<h2 className="flex flex-row gap-3 md:gap-5">
<span className="text-3xl font-bold tracking-tight">Apps</span>
<span className="text-3xl tracking-tight opacity-20">{apps.length}</span>
</h2>
</header>
<main className="flex flex-row gap-4 flex-wrap">
{apps.map((app) => (
<Link
key={app.id}
href={`/apps/${app.id}`}
className="block shadow-xl max-w-[320px] p-6 bg-white rounded-lg hover:shadow-md transition-shadow border-4 border-gray-100"
>
<div className="flex flex-col gap-4">
<div className="flex flex-row items-center gap-4">
<div className="text-4xl">{app.manifest.icon}</div>
<div className="text-2xl font-semibold">{app.manifest.name}</div>
</div>
<div className="text-sm">{app.manifest.description}</div>
</div>
</Link>
))}
</main>
</>
)
}

View File

@@ -97,7 +97,7 @@ export async function GET(request: Request) {
}
for (const file of transactionFiles) {
const fullFilePath = await fullPathForFile(user, file)
const fullFilePath = fullPathForFile(user, file)
if (await fileExists(fullFilePath)) {
const fileData = await fs.readFile(fullFilePath)
const fileExtension = path.extname(fullFilePath)

View File

@@ -2,7 +2,13 @@
import { ActionState } from "@/lib/actions"
import { getCurrentUser, isSubscriptionExpired } from "@/lib/auth"
import { getDirectorySize, getUserUploadsDirectory, isEnoughStorageToUploadFile, unsortedFilePath } from "@/lib/files"
import {
getDirectorySize,
getUserUploadsDirectory,
isEnoughStorageToUploadFile,
safePathJoin,
unsortedFilePath,
} from "@/lib/files"
import { createFile } from "@/models/files"
import { updateUser } from "@/models/users"
import { randomUUID } from "crypto"
@@ -15,7 +21,7 @@ export async function uploadFilesAction(formData: FormData): Promise<ActionState
const files = formData.getAll("files") as File[]
// Make sure upload dir exists
const userUploadsDirectory = await getUserUploadsDirectory(user)
const userUploadsDirectory = getUserUploadsDirectory(user)
// Check limits
const totalFileSize = files.reduce((acc, file) => acc + file.size, 0)
@@ -39,11 +45,11 @@ export async function uploadFilesAction(formData: FormData): Promise<ActionState
// Save file to filesystem
const fileUuid = randomUUID()
const relativeFilePath = await unsortedFilePath(fileUuid, file.name)
const relativeFilePath = unsortedFilePath(fileUuid, file.name)
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const fullFilePath = path.join(userUploadsDirectory, relativeFilePath)
const fullFilePath = safePathJoin(userUploadsDirectory, relativeFilePath)
await mkdir(path.dirname(fullFilePath), { recursive: true })
await writeFile(fullFilePath, buffer)
@@ -65,7 +71,7 @@ export async function uploadFilesAction(formData: FormData): Promise<ActionState
)
// Update user storage used
const storageUsed = await getDirectorySize(await getUserUploadsDirectory(user))
const storageUsed = await getDirectorySize(getUserUploadsDirectory(user))
await updateUser(user.id, { storageUsed })
console.log("uploadedFiles", uploadedFiles)

View File

@@ -21,7 +21,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ file
}
// Check if file exists
const fullFilePath = await fullPathForFile(user, file)
const fullFilePath = fullPathForFile(user, file)
const isFileExists = await fileExists(fullFilePath)
if (!isFileExists) {
return new NextResponse(`File not found on disk: ${file.path}`, { status: 404 })

View File

@@ -26,7 +26,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ file
}
// Check if file exists on disk
const fullFilePath = await fullPathForFile(user, file)
const fullFilePath = fullPathForFile(user, file)
const isFileExists = await fileExists(fullFilePath)
if (!isFileExists) {
return new NextResponse(`File not found on disk: ${file.path}`, { status: 404 })

View File

@@ -0,0 +1,35 @@
import { getCurrentUser } from "@/lib/auth"
import { fileExists, getStaticDirectory, safePathJoin } from "@/lib/files"
import fs from "fs/promises"
import lookup from "mime-types"
import { NextResponse } from "next/server"
export async function GET(request: Request, { params }: { params: Promise<{ filename: string }> }) {
const { filename } = await params
const user = await getCurrentUser()
if (!filename) {
return new NextResponse("No filename provided", { status: 400 })
}
const staticFilesDirectory = getStaticDirectory(user)
try {
const fullFilePath = safePathJoin(staticFilesDirectory, filename)
const isFileExists = await fileExists(fullFilePath)
if (!isFileExists) {
return new NextResponse(`File not found for user: ${filename}`, { status: 404 })
}
const fileBuffer = await fs.readFile(fullFilePath)
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": lookup.lookup(filename) || "application/octet-stream",
},
})
} catch (error) {
console.error("Error serving file:", error)
return new NextResponse("Internal Server Error", { status: 500 })
}
}

View File

@@ -38,6 +38,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
name: user.name || "",
email: user.email,
avatar: user.avatar || undefined,
membershipPlan: user.membershipPlan || "unlimited",
storageUsed: user.storageUsed || 0,
storageLimit: user.storageLimit || -1,
aiBalance: user.aiBalance || 0,

View File

@@ -10,6 +10,7 @@ import {
import { userFormSchema } from "@/forms/users"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth"
import { uploadStaticImage } from "@/lib/uploads"
import { codeFromName, randomHexColor } from "@/lib/utils"
import { createCategory, deleteCategory, updateCategory } from "@/models/categories"
import { createCurrency, deleteCurrency, updateCurrency } from "@/models/currencies"
@@ -19,7 +20,7 @@ import { SettingsMap, updateSettings } from "@/models/settings"
import { updateUser } from "@/models/users"
import { Prisma, User } from "@/prisma/client"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import path from "path"
export async function saveSettingsAction(
_prevState: ActionState<SettingsMap> | null,
@@ -40,8 +41,7 @@ export async function saveSettingsAction(
}
revalidatePath("/settings")
redirect("/settings")
// return { success: true }
return { success: true }
}
export async function saveProfileAction(
@@ -55,12 +55,47 @@ export async function saveProfileAction(
return { success: false, error: validatedForm.error.message }
}
// Upload avatar
let avatarUrl = user.avatar
const avatarFile = formData.get("avatar") as File | null
if (avatarFile instanceof File && avatarFile.size > 0) {
try {
const uploadedAvatarPath = await uploadStaticImage(user, avatarFile, "avatar.webp", 500, 500)
avatarUrl = `/files/static/${path.basename(uploadedAvatarPath)}`
} catch (error) {
return { success: false, error: "Failed to upload avatar: " + error }
}
}
// Upload business logo
let businessLogoUrl = user.businessLogo
const businessLogoFile = formData.get("businessLogo") as File | null
if (businessLogoFile instanceof File && businessLogoFile.size > 0) {
try {
const uploadedBusinessLogoPath = await uploadStaticImage(user, businessLogoFile, "businessLogo.png", 500, 500)
businessLogoUrl = `/files/static/${path.basename(uploadedBusinessLogoPath)}`
} catch (error) {
return { success: false, error: "Failed to upload business logo: " + error }
}
}
// Update user
await updateUser(user.id, {
name: validatedForm.data.name,
name: validatedForm.data.name !== undefined ? validatedForm.data.name : user.name,
avatar: avatarUrl,
businessName: validatedForm.data.businessName !== undefined ? validatedForm.data.businessName : user.businessName,
businessAddress:
validatedForm.data.businessAddress !== undefined ? validatedForm.data.businessAddress : user.businessAddress,
businessBankDetails:
validatedForm.data.businessBankDetails !== undefined
? validatedForm.data.businessBankDetails
: user.businessBankDetails,
businessLogo: businessLogoUrl,
})
revalidatePath("/settings/profile")
redirect("/settings/profile")
revalidatePath("/settings/business")
return { success: true }
}
export async function addProjectAction(userId: string, data: Prisma.ProjectCreateInput) {

View File

@@ -3,7 +3,7 @@
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth"
import { prisma } from "@/lib/db"
import { getUserUploadsDirectory } from "@/lib/files"
import { getUserUploadsDirectory, safePathJoin } from "@/lib/files"
import { MODEL_BACKUP, modelFromJSON } from "@/models/backups"
import fs from "fs/promises"
import JSZip from "jszip"
@@ -22,7 +22,7 @@ export async function restoreBackupAction(
formData: FormData
): Promise<ActionState<BackupRestoreResult>> {
const user = await getCurrentUser()
const userUploadsDirectory = await getUserUploadsDirectory(user)
const userUploadsDirectory = getUserUploadsDirectory(user)
const file = formData.get("file") as File
if (!file || file.size === 0) {
@@ -98,7 +98,7 @@ export async function restoreBackupAction(
},
})
const userUploadsDirectory = await getUserUploadsDirectory(user)
const userUploadsDirectory = getUserUploadsDirectory(user)
for (const file of files) {
const filePathWithoutPrefix = path.normalize(file.path.replace(/^.*\/uploads\//, ""))
@@ -110,7 +110,7 @@ export async function restoreBackupAction(
}
const fileContents = await zipFile.async("nodebuffer")
const fullFilePath = path.join(userUploadsDirectory, filePathWithoutPrefix)
const fullFilePath = safePathJoin(userUploadsDirectory, filePathWithoutPrefix)
if (!fullFilePath.startsWith(path.normalize(userUploadsDirectory))) {
console.error(`Attempted path traversal detected for file ${file.path}`)
continue

View File

@@ -11,7 +11,7 @@ const BACKUP_VERSION = "1.0"
export async function GET() {
const user = await getCurrentUser()
const userUploadsDirectory = await getUserUploadsDirectory(user)
const userUploadsDirectory = getUserUploadsDirectory(user)
try {
const zip = new JSZip()

View File

@@ -0,0 +1,14 @@
import BusinessSettingsForm from "@/components/settings/business-settings-form"
import { getCurrentUser } from "@/lib/auth"
export default async function BusinessSettingsPage() {
const user = await getCurrentUser()
return (
<>
<div className="w-full max-w-2xl">
<BusinessSettingsForm user={user} />
</div>
</>
)
}

View File

@@ -16,6 +16,10 @@ const settingsCategories = [
title: "Profile & Plan",
href: "/settings/profile",
},
{
title: "Business Details",
href: "/settings/business",
},
{
title: "LLM settings",
href: "/settings/llm",

View File

@@ -46,7 +46,6 @@ export default async function TransactionPage({ params }: { params: Promise<{ tr
<Card className="flex items-stretch p-2 max-w-6xl">
<div className="flex-1">
<FormTextarea
title=""
name="text"
defaultValue={transaction.text || ""}
hideIfEmpty={true}

View File

@@ -8,6 +8,7 @@ import {
getTransactionFileUploadPath,
getUserUploadsDirectory,
isEnoughStorageToUploadFile,
safePathJoin,
} from "@/lib/files"
import { updateField } from "@/models/fields"
import { createFile, deleteFile } from "@/models/files"
@@ -114,7 +115,7 @@ export async function deleteTransactionFileAction(
await deleteFile(fileId, user.id)
// Update user storage used
const storageUsed = await getDirectorySize(await getUserUploadsDirectory(user))
const storageUsed = await getDirectorySize(getUserUploadsDirectory(user))
await updateUser(user.id, { storageUsed })
revalidatePath(`/transactions/${transactionId}`)
@@ -136,7 +137,7 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise<
return { success: false, error: "Transaction not found" }
}
const userUploadsDirectory = await getUserUploadsDirectory(user)
const userUploadsDirectory = getUserUploadsDirectory(user)
// Check limits
const totalFileSize = files.reduce((acc, file) => acc + file.size, 0)
@@ -154,17 +155,13 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise<
const fileRecords = await Promise.all(
files.map(async (file) => {
const fileUuid = randomUUID()
const relativeFilePath = await getTransactionFileUploadPath(fileUuid, file.name, transaction)
const relativeFilePath = getTransactionFileUploadPath(fileUuid, file.name, transaction)
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const fullFilePath = path.join(userUploadsDirectory, relativeFilePath)
const fullFilePath = safePathJoin(userUploadsDirectory, relativeFilePath)
await mkdir(path.dirname(fullFilePath), { recursive: true })
console.log("userUploadsDirectory", userUploadsDirectory)
console.log("relativeFilePath", relativeFilePath)
console.log("fullFilePath", fullFilePath)
await writeFile(fullFilePath, buffer)
// Create file record in database
@@ -194,7 +191,7 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise<
)
// Update user storage used
const storageUsed = await getDirectorySize(await getUserUploadsDirectory(user))
const storageUsed = await getDirectorySize(getUserUploadsDirectory(user))
await updateUser(user.id, { storageUsed })
revalidatePath(`/transactions/${transactionId}`)

View File

@@ -8,7 +8,7 @@ import { transactionFormSchema } from "@/forms/transactions"
import { ActionState } from "@/lib/actions"
import { getCurrentUser, isAiBalanceExhausted, isSubscriptionExpired } from "@/lib/auth"
import config from "@/lib/config"
import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files"
import { getTransactionFileUploadPath, getUserUploadsDirectory, safePathJoin } from "@/lib/files"
import { DEFAULT_PROMPT_ANALYSE_NEW_FILE } from "@/models/defaults"
import { deleteFile, getFileById, updateFile } from "@/models/files"
import { createTransaction, updateTransactionFiles } from "@/models/transactions"
@@ -99,13 +99,13 @@ export async function saveFileAsTransactionAction(
const transaction = await createTransaction(user.id, validatedForm.data)
// Move file to processed location
const userUploadsDirectory = await getUserUploadsDirectory(user)
const userUploadsDirectory = getUserUploadsDirectory(user)
const originalFileName = path.basename(file.path)
const newRelativeFilePath = await getTransactionFileUploadPath(file.id, originalFileName, transaction)
const newRelativeFilePath = getTransactionFileUploadPath(file.id, originalFileName, transaction)
// Move file to new location and name
const oldFullFilePath = path.join(userUploadsDirectory, path.normalize(file.path))
const newFullFilePath = path.join(userUploadsDirectory, path.normalize(newRelativeFilePath))
const oldFullFilePath = safePathJoin(userUploadsDirectory, file.path)
const newFullFilePath = safePathJoin(userUploadsDirectory, newRelativeFilePath)
await mkdir(path.dirname(newFullFilePath), { recursive: true })
await rename(path.resolve(oldFullFilePath), path.resolve(newFullFilePath))