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,
|
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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user