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
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { uploadedFileBaseName } from '@/lib/documents/upload-file-name'
describe('uploadedFileBaseName', () => {
it('returns a bare filename unchanged', () => {
expect(uploadedFileBaseName('A31_8c2db060.pdf')).toBe('A31_8c2db060.pdf')
})
it('strips the folder-relative path Chrome writes for a folder selection', () => {
// Real shape from a Fortnox export: <year>/<month>/<type>/<file>.
expect(
uploadedFileBaseName(
'2026/06/Leverantörsfakturor/A166_90493_62864442_Hetzner_2026-05-13_089000921156.pdf',
),
).toBe('A166_90493_62864442_Hetzner_2026-05-13_089000921156.pdf')
})
it('strips Windows-style separators too', () => {
expect(uploadedFileBaseName('2026\\01\\Verifikationer\\A17_kvitto.pdf')).toBe('A17_kvitto.pdf')
})
it('keeps dots, spaces and Swedish characters inside the name', () => {
expect(uploadedFileBaseName('2026/01/A17_Förhandsavi 2026 Årsavgift 734 314 922.pdf')).toBe(
'A17_Förhandsavi 2026 Årsavgift 734 314 922.pdf',
)
expect(uploadedFileBaseName('A31.kvitto.v2.pdf')).toBe('A31.kvitto.v2.pdf')
})
})
+5 -1
View File
@@ -129,7 +129,11 @@ const YEAR_LIKE_RE = /^(?:19|20)\d{2}$/
* Trim only. Directory components are NOT stripped: `file.name` from an
* `<input type=file>` never carries a path, while the manual-reference box
* feeds arbitrary user text through this same parser, where splitting on `/`
* would quietly turn the typed date `2024/01/31` into voucher 31.
* would quietly turn the typed date `2024/01/31` into voucher 31. The one
* place a path does show up is the multipart `filename` of an upload (Chrome
* writes the folder-relative path for folder selections); the attach route
* reduces that to a basename before it reaches here, see
* `lib/documents/upload-file-name.ts`.
*/
function baseName(fileName: string): string {
return fileName.trim()
+23
View File
@@ -0,0 +1,23 @@
/**
* The name of an uploaded file as the user saw it: the last path segment of
* whatever the browser wrote into the multipart `filename` parameter.
*
* `File.name` in the browser is a bare filename by spec. The `filename` the
* same browser writes into multipart/form-data is not guaranteed to be that
* string: Chrome fills it with `webkitRelativePath` for files that came from
* a folder selection. A receipt picked out of a Fortnox export as
* `2026/06/Leverantörsfakturor/A166_Hetzner.pdf` therefore arrives server-side
* under that whole path, while every client-side read of `file.name`, and so
* every preview the user approved, said `A166_Hetzner.pdf`.
*
* Any server logic that compares an uploaded name to something the client
* computed from `File.name`, or stores the name for the user to read back,
* must go through this first. Both separators are stripped: Chrome writes `/`
* on every platform, legacy Windows clients sent `\`. Nothing else is
* normalized, on purpose: the voucher-ref parser must see the name exactly as
* the exporting system wrote it.
*/
export function uploadedFileBaseName(name: string): string {
const cut = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\'))
return cut === -1 ? name : name.slice(cut + 1)
}