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) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 11:54:13 +07:00
parent bbfab9faaa
commit 34aab722b4
4 changed files with 277 additions and 0 deletions
+59
View File
@@ -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);
}
}
@@ -18,6 +18,8 @@ import {
postExpenseTransaction, postExpenseTransaction,
removeExpenseTransaction removeExpenseTransaction
} from '$lib/server/accounts/ledger.js'; } 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 }) => { export const load: PageServerLoad = async ({ parent, params, url }) => {
await parent(); await parent();
@@ -43,6 +45,9 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
accountId: expenses.accountId, accountId: expenses.accountId,
accountName: companyAccounts.name, accountName: companyAccounts.name,
invoiceId: expenses.invoiceId, invoiceId: expenses.invoiceId,
invoiceFileUrl: expenses.invoiceFileUrl,
invoiceFileName: expenses.invoiceFileName,
paperlessUrl: expenses.paperlessUrl,
createdAt: expenses.createdAt createdAt: expenses.createdAt
}) })
.from(expenses) .from(expenses)
@@ -365,5 +370,107 @@ export const actions: Actions = {
.where(eq(expenses.id, expenseId)); .where(eq(expenses.id, expenseId));
return { success: true }; 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' };
} }
}; };
@@ -172,6 +172,31 @@
</p> </p>
{/if} {/if}
{/if} {/if}
<p class="mt-1 flex flex-wrap items-center gap-2 text-xs">
{#if expense.invoiceFileUrl}
<a
href={`/companies/${data.company.id}/expenses/${expense.id}/invoice`}
class="rounded-full bg-emerald-100 px-2 py-0.5 font-medium text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-300"
>
📄 {expense.invoiceFileName ?? 'Invoice file'}
</a>
{/if}
{#if expense.paperlessUrl}
<a
href={expense.paperlessUrl}
target="_blank"
rel="noopener noreferrer"
class="rounded-full bg-purple-100 px-2 py-0.5 font-medium text-purple-700 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300"
>
🗂 Paperless
</a>
{/if}
{#if !expense.invoiceFileUrl && !expense.paperlessUrl}
<span class="rounded-full bg-amber-100 px-2 py-0.5 font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
Pending invoice
</span>
{/if}
</p>
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p> <p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p>
@@ -253,6 +278,49 @@
</button> </button>
</form> </form>
{/if} {/if}
{#if canAssignAccount}
<details class="mt-3 border-t border-gray-100 pt-3 dark:border-gray-700">
<summary class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
{expense.invoiceFileUrl || expense.paperlessUrl ? 'Manage invoice' : '+ Attach invoice'}
</summary>
<div class="mt-2 space-y-2">
<form
method="POST"
action="?/uploadExpenseInvoice"
enctype="multipart/form-data"
use:enhance
class="flex flex-wrap items-center gap-2 text-xs"
>
<input type="hidden" name="expenseId" value={expense.id} />
<label class="text-gray-500 dark:text-gray-400" for="inv-file-{expense.id}">File:</label>
<input id="inv-file-{expense.id}" name="file" type="file" accept="application/pdf,image/*" required
class="text-xs text-gray-700 dark:text-gray-300" />
<button type="submit"
class="rounded-md bg-blue-600 px-2 py-1 text-xs font-medium text-white hover:bg-blue-700">
Upload
</button>
</form>
<form
method="POST"
action="?/setExpensePaperlessLink"
use:enhance
class="flex flex-wrap items-center gap-2 text-xs"
>
<input type="hidden" name="expenseId" value={expense.id} />
<label class="text-gray-500 dark:text-gray-400" for="pless-{expense.id}">Paperless URL:</label>
<input id="pless-{expense.id}" name="paperlessUrl" type="url"
value={expense.paperlessUrl ?? ''}
placeholder="https://paperless.example.com/documents/123"
class="flex-1 rounded border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
<button type="submit"
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200">
Save link
</button>
</form>
</div>
</details>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@@ -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'
}
});
};