fix(import): attach underlag by the basename of a folder-picked upload (#2288)

A folder-picked Fortnox export failed 50 of 50 attaches with
UNDERLAG_REF_MISMATCH although the preview had matched every file. The
preview is built from File.name, a bare filename by spec, while the attach
route read the multipart filename, which Chrome fills with the folder-relative
path for folder selections (2026/06/Leverantorsfakturor/A166_x.pdf). The
guard that requires a file to land where the preview said compared the
previewed basename with a path the parser cannot read, and refused.

The route now reduces the multipart filename to its basename once, at the
boundary, before the resolver check and before archiving, so the archived
file_name is the name the user reviewed rather than a path. The parser keeps
its no-directory-stripping rule: the manual-reference box shares it, and a
typed 2024/01/31 there is a date, not voucher 31. Both separators are
stripped; nothing else is normalized.


Claude-Session: https://claude.ai/code/session_014uwXchJvF5YMgz8vRfuxLe

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-04 19:14:33 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 9618bab273
commit a870c7f03e
6 changed files with 128 additions and 4 deletions
@@ -198,6 +198,59 @@ describe('POST /api/import/documents/attach', () => {
expect(uploadDocumentMock).not.toHaveBeenCalled()
})
it('resolves the basename when the browser sends a folder-relative multipart filename', async () => {
// Chrome fills the multipart `filename` with webkitRelativePath for files
// picked through a folder selection, so the server sees the Fortnox export
// tree (<year>/<month>/<type>/<file>) while the preview, built from
// File.name, saw only the basename. The check must run on the name the
// user reviewed, and the archived document must carry that name, not the
// path. Long exporter suffixes after the ref are part of the same shape.
const res = await POST(
makeRequest({
fileName:
'2026/06/Leverantörsfakturor/A31_90493_62864442_Hetzner_2026-05-13_089000921156.pdf',
}),
emptyParams,
)
expect(res.status).toBe(200)
const [, , , file] = uploadDocumentMock.mock.calls[0]
expect(file).toMatchObject({
name: 'A31_90493_62864442_Hetzner_2026-05-13_089000921156.pdf',
})
})
it('still refuses a folder-relative filename whose basename points elsewhere', async () => {
// Normalizing the path must not loosen the guard: the basename is checked
// exactly as a bare filename would be.
vouchers = [{ ...VOUCHER, id: OTHER_ID }]
const res = await POST(
makeRequest({ fileName: '2026/06/Leverantörsfakturor/A31_kvitto.pdf' }),
emptyParams,
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(status).toBe(409)
expect(body.error.code).toBe('UNDERLAG_REF_MISMATCH')
expect(uploadDocumentMock).not.toHaveBeenCalled()
})
it('attaches several underlag to the same verifikat, one request each', async () => {
// Fortnox exports one file per attachment, so a verifikat with six
// receipts is six files sharing a prefix. Each lands on the same target
// under the same entry-scoped idempotency key; the content hash keeps
// them apart as separate documents.
for (const fileName of ['A31_90470_1_faktura.pdf', 'A31_90470_2_kvitto.pdf']) {
expect((await POST(makeRequest({ fileName }), emptyParams)).status).toBe(200)
}
expect(uploadDocumentMock).toHaveBeenCalledTimes(2)
for (const call of uploadDocumentMock.mock.calls) {
expect(call[4]).toMatchObject({ journal_entry_id: TARGET_ID, idempotency_key: TARGET_ID })
}
})
it('returns 400 when the declared fiscal year is missing or not a uuid', async () => {
expect((await POST(makeRequest({ declaredPeriodId: null }), emptyParams)).status).toBe(400)
expect((await POST(makeRequest({ declaredPeriodId: '2024' }), emptyParams)).status).toBe(400)
+18 -3
View File
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { uploadDocument, validateDocumentFile } from '@/lib/core/documents/document-service'
import { planPermitsAttach } from '@/lib/documents/underlag-import'
import { uploadedFileBaseName } from '@/lib/documents/upload-file-name'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -65,6 +66,14 @@ export const POST = withRouteContext(
return errorResponseFromCode('DOC_UPLOAD_NO_FILE', log, { requestId })
}
// The multipart `filename` is not `File.name`. For a folder selection
// Chrome writes the relative path (`2026/06/Leverantörsfakturor/A166_x.pdf`)
// while the preview the user approved was built from `File.name`
// (`A166_x.pdf`). Every use below, the resolver check and the archived
// name alike, must see the name the user reviewed, so it is normalized
// once, here, at the boundary.
const fileName = uploadedFileBaseName(file.name)
const fields = AttachFieldsSchema.safeParse({
journal_entry_id: formData.get('journal_entry_id'),
fiscal_period_id: formData.get('fiscal_period_id'),
@@ -98,7 +107,13 @@ export const POST = withRouteContext(
})
}
const opLog = log.child({ filename: file.name, journalEntryId })
const opLog = log.child({
filename: fileName,
journalEntryId,
// Kept only when the browser sent something else, so a refusal can be
// read against exactly what arrived on the wire.
...(file.name !== fileName ? { uploadedAs: file.name } : {}),
})
// Tenant check first and explicitly: RLS covers the cookie session, but the
// link is irreversible, so the route never takes the client's word for which
@@ -156,7 +171,7 @@ export const POST = withRouteContext(
const permitted = await planPermitsAttach(
supabase,
companyId!,
file.name,
fileName,
journalEntryId,
fiscalPeriodId,
override,
@@ -173,7 +188,7 @@ export const POST = withRouteContext(
supabase,
user.id,
companyId!,
{ name: file.name, buffer, type: file.type },
{ name: fileName, buffer, type: file.type },
{
upload_source: 'file_upload',
journal_entry_id: journalEntryId,