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:
@@ -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,
|
||||
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' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -172,6 +172,31 @@
|
||||
</p>
|
||||
{/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 class="text-right">
|
||||
<p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p>
|
||||
@@ -253,6 +278,49 @@
|
||||
</button>
|
||||
</form>
|
||||
{/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>
|
||||
{/each}
|
||||
</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'
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user