From 34aab722b4236059cd07c11cd34aa38d3a2f760c Mon Sep 17 00:00:00 2001 From: grabowski Date: Mon, 20 Apr 2026 11:54:13 +0700 Subject: [PATCH] Add expense invoice upload with Paperless push + paperless URL link Expenses now show Pending Invoice badge when no file/link attached. Upload action saves file via existing uploads helper, optionally pushes to Paperless-ngx if PAPERLESS_URL + PAPERLESS_TOKEN env set. Download endpoint serves attached invoice with attachment disposition. Paperless URL link provides a zero-integration alternative. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/server/paperless/index.ts | 59 ++++++++++ .../[companyId]/expenses/+page.server.ts | 107 ++++++++++++++++++ .../[companyId]/expenses/+page.svelte | 68 +++++++++++ .../expenses/[expenseId]/invoice/+server.ts | 43 +++++++ 4 files changed, 277 insertions(+) create mode 100644 src/lib/server/paperless/index.ts create mode 100644 src/routes/(app)/companies/[companyId]/expenses/[expenseId]/invoice/+server.ts diff --git a/src/lib/server/paperless/index.ts b/src/lib/server/paperless/index.ts new file mode 100644 index 0000000..ac52945 --- /dev/null +++ b/src/lib/server/paperless/index.ts @@ -0,0 +1,59 @@ +import { env } from '$env/dynamic/private'; + +const FETCH_TIMEOUT_MS = 20_000; + +export function isPaperlessEnabled(): boolean { + return Boolean(env.PAPERLESS_URL && env.PAPERLESS_TOKEN); +} + +function baseUrl(): string { + const raw = (env.PAPERLESS_URL ?? '').trim(); + return raw.endsWith('/') ? raw.slice(0, -1) : raw; +} + +/** + * Upload a File blob to Paperless-ngx. + * Returns the task ID string if accepted; null on failure or if disabled. + * + * Paperless accepts multipart/form-data at /api/documents/post_document/ + * and returns a task UUID (string) — the doc ID itself is assigned asynchronously + * after OCR. Callers can store the task ID as a reference. + */ +export async function uploadToPaperless( + file: File, + title?: string +): Promise<{ taskId: string } | null> { + if (!isPaperlessEnabled()) return null; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const form = new FormData(); + form.append('document', file, file.name); + if (title) form.append('title', title); + + const res = await fetch(`${baseUrl()}/api/documents/post_document/`, { + method: 'POST', + signal: controller.signal, + headers: { + Authorization: `Token ${env.PAPERLESS_TOKEN}` + }, + body: form + }); + + if (!res.ok) { + console.error('[paperless] upload failed', res.status, await res.text().catch(() => '')); + return null; + } + + // Paperless returns a quoted task-id string in the body. + const raw = (await res.text()).trim().replace(/^"|"$/g, ''); + return { taskId: raw }; + } catch (err) { + console.error('[paperless] upload error', err); + return null; + } finally { + clearTimeout(timer); + } +} diff --git a/src/routes/(app)/companies/[companyId]/expenses/+page.server.ts b/src/routes/(app)/companies/[companyId]/expenses/+page.server.ts index 8622a10..bd6c84f 100644 --- a/src/routes/(app)/companies/[companyId]/expenses/+page.server.ts +++ b/src/routes/(app)/companies/[companyId]/expenses/+page.server.ts @@ -18,6 +18,8 @@ import { postExpenseTransaction, removeExpenseTransaction } from '$lib/server/accounts/ledger.js'; +import { saveCompanyFile, isAllowedMime, MAX_BYTES } from '$lib/server/uploads/index.js'; +import { uploadToPaperless, isPaperlessEnabled } from '$lib/server/paperless/index.js'; export const load: PageServerLoad = async ({ parent, params, url }) => { await parent(); @@ -43,6 +45,9 @@ export const load: PageServerLoad = async ({ parent, params, url }) => { accountId: expenses.accountId, accountName: companyAccounts.name, invoiceId: expenses.invoiceId, + invoiceFileUrl: expenses.invoiceFileUrl, + invoiceFileName: expenses.invoiceFileName, + paperlessUrl: expenses.paperlessUrl, createdAt: expenses.createdAt }) .from(expenses) @@ -365,5 +370,107 @@ export const actions: Actions = { .where(eq(expenses.id, expenseId)); return { success: true }; + }, + + uploadExpenseInvoice: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', 'manager', 'accountant' + ]); + const fd = await request.formData(); + const expenseId = fd.get('expenseId')?.toString(); + const file = fd.get('file') as File | null; + + if (!expenseId) return fail(400, { error: 'Expense ID required' }); + if (!file || !(file instanceof File) || file.size === 0) { + return fail(400, { action: 'uploadExpenseInvoice', error: 'File is required' }); + } + if (file.size > MAX_BYTES) { + return fail(400, { + action: 'uploadExpenseInvoice', + error: `File too large (max ${Math.round(MAX_BYTES / 1024 / 1024)} MB)` + }); + } + const mime = file.type || 'application/octet-stream'; + if (!isAllowedMime(mime)) { + return fail(400, { + action: 'uploadExpenseInvoice', + error: `File type not allowed: ${mime}` + }); + } + + // Verify expense belongs to this company + const [exp] = await db + .select({ id: expenses.id, title: expenses.title }) + .from(expenses) + .innerJoin(projects, eq(expenses.projectId, projects.id)) + .where(and(eq(expenses.id, expenseId), eq(projects.companyId, params.companyId))) + .limit(1); + if (!exp) return fail(404, { error: 'Expense not found' }); + + let saved; + try { + saved = await saveCompanyFile(params.companyId, file); + } catch (err) { + console.error('saveCompanyFile failed', err); + return fail(500, { action: 'uploadExpenseInvoice', error: 'Failed to save file' }); + } + + // Fire-and-forget Paperless push if configured + let paperlessTaskId: string | null = null; + if (isPaperlessEnabled()) { + const paperlessResult = await uploadToPaperless(file, exp.title); + paperlessTaskId = paperlessResult?.taskId ?? null; + } + + await db + .update(expenses) + .set({ + invoiceFileUrl: saved.storedPath, + invoiceFileName: file.name, + updatedAt: new Date() + }) + .where(eq(expenses.id, expenseId)); + + await logCompanyEvent( + params.companyId, + user.id, + 'expense_invoice_uploaded', + `Invoice attached to expense "${exp.title}"`, + { expenseId, fileName: file.name, paperlessTaskId } + ); + + return { success: true, action: 'uploadExpenseInvoice' }; + }, + + setExpensePaperlessLink: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', 'manager', 'accountant' + ]); + const fd = await request.formData(); + const expenseId = fd.get('expenseId')?.toString(); + const url = fd.get('paperlessUrl')?.toString().trim() || null; + + if (!expenseId) return fail(400, { error: 'Expense ID required' }); + if (url && !url.startsWith('http://') && !url.startsWith('https://')) { + return fail(400, { + action: 'setExpensePaperlessLink', + error: 'URL must start with http:// or https://' + }); + } + + const [exp] = await db + .select({ id: expenses.id, title: expenses.title }) + .from(expenses) + .innerJoin(projects, eq(expenses.projectId, projects.id)) + .where(and(eq(expenses.id, expenseId), eq(projects.companyId, params.companyId))) + .limit(1); + if (!exp) return fail(404, { error: 'Expense not found' }); + + await db + .update(expenses) + .set({ paperlessUrl: url, updatedAt: new Date() }) + .where(eq(expenses.id, expenseId)); + + return { success: true, action: 'setExpensePaperlessLink' }; } }; diff --git a/src/routes/(app)/companies/[companyId]/expenses/+page.svelte b/src/routes/(app)/companies/[companyId]/expenses/+page.svelte index dc53156..7a117fd 100644 --- a/src/routes/(app)/companies/[companyId]/expenses/+page.svelte +++ b/src/routes/(app)/companies/[companyId]/expenses/+page.svelte @@ -172,6 +172,31 @@

{/if} {/if} +

+ {#if expense.invoiceFileUrl} + + 📄 {expense.invoiceFileName ?? 'Invoice file'} + + {/if} + {#if expense.paperlessUrl} + + 🗂 Paperless + + {/if} + {#if !expense.invoiceFileUrl && !expense.paperlessUrl} + + Pending invoice + + {/if} +

{formatCurrency(expense.amount, expense.currency)}

@@ -253,6 +278,49 @@ {/if} + + {#if canAssignAccount} +
+ + {expense.invoiceFileUrl || expense.paperlessUrl ? 'Manage invoice' : '+ Attach invoice'} + +
+
+ + + + +
+
+ + + + +
+
+
+ {/if}
{/each} diff --git a/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/invoice/+server.ts b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/invoice/+server.ts new file mode 100644 index 0000000..b71c32c --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/invoice/+server.ts @@ -0,0 +1,43 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { expenses, projects } from '$lib/server/db/schema.js'; +import { requireCompanyRoleAny } from '$lib/server/authorization.js'; +import { readCompanyFile } from '$lib/server/uploads/index.js'; +import { and, eq } from 'drizzle-orm'; + +export const GET: RequestHandler = async ({ locals, params }) => { + await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', 'manager', 'user', 'accountant' + ]); + + const [row] = await db + .select({ + invoiceFileUrl: expenses.invoiceFileUrl, + invoiceFileName: expenses.invoiceFileName + }) + .from(expenses) + .innerJoin(projects, eq(expenses.projectId, projects.id)) + .where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId))) + .limit(1); + + if (!row || !row.invoiceFileUrl) error(404, 'Invoice file not found'); + + let buf: Buffer; + try { + buf = await readCompanyFile(row.invoiceFileUrl); + } catch (err) { + console.error('readCompanyFile failed', err); + error(404, 'File missing on disk'); + } + + const safeName = (row.invoiceFileName ?? 'invoice').replace(/[\r\n"\\]/g, '_'); + + return new Response(new Blob([buf as BlobPart]), { + headers: { + 'Content-Disposition': `attachment; filename="${safeName}"`, + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff' + } + }); +};