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'
+ }
+ });
+};