Fixed user issues (#559)

* Fixed user issues

* feat: add personal_number column to customers for individual identification

* feat: add personal_number field to makeCustomer function for enhanced customer identification

* feat: add personal_number column with constraint check for customer identification
This commit is contained in:
Mattsson
2026-05-22 12:22:15 +02:00
committed by GitHub
parent b34de598e3
commit 64bbeb4021
25 changed files with 1376 additions and 133 deletions
+5
View File
@@ -261,6 +261,11 @@ export const CreateCustomerSchema = z.object({
country: z.string().optional(),
org_number: z.string().optional(),
vat_number: z.string().optional(),
personal_number: z
.string()
.regex(/^(\d{6}|\d{8})[-+]?\d{4}$/, 'Invalid personal number')
.optional()
.nullable(),
default_payment_terms: z.number().int().positive().optional(),
notes: z.string().optional(),
})
@@ -135,6 +135,11 @@ describe('generateFullArchive', () => {
file_name: 'receipt.pdf',
storage_path: 'documents/user-1/receipt.pdf',
journal_entry_id: 'entry-1',
journal_entries: {
voucher_number: 17,
voucher_series: 'A',
entry_date: '2024-03-15',
},
},
],
},
@@ -162,6 +167,10 @@ describe('generateFullArchive', () => {
expect(manifest[0].status).toBe('error')
expect(manifest[0].error).toBe('File not found')
expect(manifest[0].fiscal_period_id).toBe(PERIOD_2024.id)
// New manifest fields populated even on error (path is computed before download)
expect(manifest[0].voucher_number).toBe('A17')
expect(manifest[0].entry_date).toBe('2024-03-15')
expect(manifest[0].zip_path).toBe('dokument/2024/A17_receipt.pdf')
})
it('skips documents when include_documents is false', async () => {
@@ -264,8 +273,20 @@ describe('generateFullArchive', () => {
{ data: [PERIOD_2023, PERIOD_2024] },
{
data: [
{ id: 'doc-2023', file_name: 'r23.pdf', storage_path: 'p/r23.pdf', journal_entry_id: 'e-2023' },
{ id: 'doc-2024', file_name: 'r24.pdf', storage_path: 'p/r24.pdf', journal_entry_id: 'e-2024' },
{
id: 'doc-2023',
file_name: 'r23.pdf',
storage_path: 'p/r23.pdf',
journal_entry_id: 'e-2023',
journal_entries: { voucher_number: 7, voucher_series: 'A', entry_date: '2023-06-01' },
},
{
id: 'doc-2024',
file_name: 'r24.pdf',
storage_path: 'p/r24.pdf',
journal_entry_id: 'e-2024',
journal_entries: { voucher_number: 12, voucher_series: 'B', entry_date: '2024-08-20' },
},
],
},
{
@@ -287,13 +308,94 @@ describe('generateFullArchive', () => {
const manifest = JSON.parse(await manifestFile!.async('text'))
expect(manifest).toHaveLength(2)
const byId = Object.fromEntries(
(manifest as Array<{ document_id: string; fiscal_period_id: string | null }>).map((m) => [
m.document_id,
m.fiscal_period_id,
])
(
manifest as Array<{
document_id: string
fiscal_period_id: string | null
voucher_number: string | null
zip_path: string | null
}>
).map((m) => [m.document_id, m])
)
expect(byId['doc-2023']).toBe(PERIOD_2023.id)
expect(byId['doc-2024']).toBe(PERIOD_2024.id)
expect(byId['doc-2023'].fiscal_period_id).toBe(PERIOD_2023.id)
expect(byId['doc-2024'].fiscal_period_id).toBe(PERIOD_2024.id)
expect(byId['doc-2023'].voucher_number).toBe('A7')
expect(byId['doc-2024'].voucher_number).toBe('B12')
expect(byId['doc-2023'].zip_path).toBe('dokument/2023/A7_r23.pdf')
expect(byId['doc-2024'].zip_path).toBe('dokument/2024/B12_r24.pdf')
// Files actually written under the new path
expect(zip.file('dokument/2023/A7_r23.pdf')).not.toBeNull()
expect(zip.file('dokument/2024/B12_r24.pdf')).not.toBeNull()
})
it('routes draft entries and orphans to dokument/_okopplade and disambiguates collisions', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: [PERIOD_2024] },
{
data: [
// Draft entry — journal_entry_id present but voucher_number is null
{
id: 'doc-draft',
file_name: 'invoice.pdf',
storage_path: 'p/invoice.pdf',
journal_entry_id: 'e-draft',
journal_entries: { voucher_number: null, voucher_series: 'A', entry_date: null },
},
// Two posted docs that collide on the same voucher+filename
{
id: 'doc-collide-1234abcd-ee',
file_name: 'kvitto.pdf',
storage_path: 'p/kvitto-1.pdf',
journal_entry_id: 'e-posted',
journal_entries: { voucher_number: 5, voucher_series: 'A', entry_date: '2024-05-01' },
},
{
id: 'doc-collide-5678efgh-ff',
file_name: 'kvitto.pdf',
storage_path: 'p/kvitto-2.pdf',
journal_entry_id: 'e-posted',
journal_entries: { voucher_number: 5, voucher_series: 'A', entry_date: '2024-05-01' },
},
],
},
// entryIdToPeriodId map — draft and posted both resolve to PERIOD_2024
{
data: [
{ id: 'e-draft', fiscal_period_id: PERIOD_2024.id },
{ id: 'e-posted', fiscal_period_id: PERIOD_2024.id },
],
},
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
})
const zip = await JSZip.loadAsync(buffer)
const manifest = JSON.parse(await zip.file('dokument/manifest.json')!.async('text')) as Array<{
document_id: string
voucher_number: string | null
zip_path: string | null
}>
const byId = Object.fromEntries(manifest.map((m) => [m.document_id, m]))
// Draft -> _okopplade, no voucher prefix
expect(byId['doc-draft'].voucher_number).toBeNull()
expect(byId['doc-draft'].zip_path).toBe('dokument/_okopplade/invoice.pdf')
// First posted doc gets the canonical path
expect(byId['doc-collide-1234abcd-ee'].zip_path).toBe('dokument/2024/A5_kvitto.pdf')
// Second posted doc gets the id-suffix disambiguation before the extension
expect(byId['doc-collide-5678efgh-ff'].zip_path).toBe(
'dokument/2024/A5_kvitto_doc-coll.pdf'
)
// Both files exist in the ZIP
expect(zip.file('dokument/_okopplade/invoice.pdf')).not.toBeNull()
expect(zip.file('dokument/2024/A5_kvitto.pdf')).not.toBeNull()
expect(zip.file('dokument/2024/A5_kvitto_doc-coll.pdf')).not.toBeNull()
})
it('throws when no fiscal periods exist', async () => {
+98 -4
View File
@@ -30,6 +30,10 @@ interface DocumentManifestEntry {
upload_source: string | null
mime_type: string | null
file_size_bytes: number | null
// New fields (added to make ZIP entries sortable by verifikatnummer)
voucher_number: string | null
entry_date: string | null
zip_path: string | null
status: 'downloaded' | 'missing' | 'error'
error?: string
}
@@ -58,6 +62,14 @@ interface DocumentRow {
upload_source: string | null
mime_type: string | null
file_size_bytes: number | null
// Joined from journal_entries via journal_entry_id. May be null when the
// entry is a draft (no voucher_number yet) or when the doc is orphaned.
// PostgREST returns a single row as an object, not an array, when the FK
// is many-to-one — but we tolerate both shapes defensively.
journal_entries?:
| { voucher_number: number | null; voucher_series: string | null; entry_date: string | null }
| { voucher_number: number | null; voucher_series: string | null; entry_date: string | null }[]
| null
}
interface PeriodReports {
@@ -324,7 +336,7 @@ async function writeDocuments(
supabase
.from('document_attachments')
.select(
'id, file_name, storage_path, journal_entry_id, sha256_hash, version, digitization_date, upload_source, mime_type, file_size_bytes'
'id, file_name, storage_path, journal_entry_id, sha256_hash, version, digitization_date, upload_source, mime_type, file_size_bytes, journal_entries:journal_entry_id(voucher_number, voucher_series, entry_date)'
)
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
@@ -339,12 +351,20 @@ async function writeDocuments(
? documents.filter((d) => d.journal_entry_id && entryIdToPeriodId.has(d.journal_entry_id))
: documents.filter((d) => d.journal_entry_id) // all-mode: keep every linked doc
// Track used paths so we can disambiguate collisions (two documents with
// identical voucher prefix + filename) by appending a short id suffix.
const usedPaths = new Set<string>()
for (const doc of inScopeDocuments) {
const fiscalPeriodId = doc.journal_entry_id
? entryIdToPeriodId.get(doc.journal_entry_id) ?? null
: null
const baseManifest = {
const entryInfo = extractJoinedEntry(doc.journal_entries)
const voucherLabel = formatVoucherLabel(entryInfo)
const zipPath = buildDocumentZipPath(doc, voucherLabel, entryInfo?.entry_date ?? null, usedPaths)
const baseManifest: Omit<DocumentManifestEntry, 'status'> = {
document_id: doc.id,
file_name: doc.file_name,
storage_path: doc.storage_path,
@@ -356,6 +376,9 @@ async function writeDocuments(
upload_source: doc.upload_source,
mime_type: doc.mime_type,
file_size_bytes: doc.file_size_bytes,
voucher_number: voucherLabel,
entry_date: entryInfo?.entry_date ?? null,
zip_path: zipPath,
}
try {
@@ -373,8 +396,10 @@ async function writeDocuments(
}
const buffer = await fileData.arrayBuffer()
const zipFileName = `${doc.id}_${doc.file_name}`
dokument.file(zipFileName, buffer)
// zipPath is fully qualified (`dokument/<year>/<voucher>_<file>` etc.),
// so write at the archive root — calling `dokument.file(zipPath)`
// would double-prefix to `dokument/dokument/...`.
zip.file(zipPath, buffer)
manifest.push({ ...baseManifest, status: 'downloaded' })
} catch (err) {
manifest.push({
@@ -392,6 +417,75 @@ async function writeDocuments(
dokument.file('manifest.json', JSON.stringify(manifest, null, 2))
}
/**
* PostgREST returns a many-to-one embedded resource as either an object or an
* array depending on schema introspection (FK is unique vs not). Normalize.
*/
function extractJoinedEntry(
raw: DocumentRow['journal_entries']
): { voucher_number: number | null; voucher_series: string | null; entry_date: string | null } | null {
if (!raw) return null
if (Array.isArray(raw)) return raw[0] ?? null
return raw
}
/**
* Format the voucher label as `<series><number>` (e.g. `A23`, `B12`). Returns
* null if the entry is a draft (no voucher_number assigned yet), in which case
* the doc is treated as orphaned in the ZIP layout.
*/
function formatVoucherLabel(
entry: { voucher_number: number | null; voucher_series: string | null } | null
): string | null {
if (!entry || entry.voucher_number == null) return null
const series = entry.voucher_series ?? ''
return `${series}${entry.voucher_number}`
}
/**
* Build the in-ZIP path for a document.
*
* - Linked to a posted entry with a date: `dokument/<year>/<voucher>_<file>`
* - Linked to a posted entry without a date (defensive): `dokument/_okant-ar/<voucher>_<file>`
* - Orphan (no entry) or draft (no voucher_number): `dokument/_okopplade/<file>`
*
* Collisions are resolved by appending `_<short-id>` before the file extension.
*/
function buildDocumentZipPath(
doc: { id: string; file_name: string },
voucherLabel: string | null,
entryDate: string | null,
usedPaths: Set<string>
): string {
const safeName = sanitizeFileName(doc.file_name || `${doc.id}.bin`)
let folder: string
let prefix: string
if (voucherLabel) {
const year = entryDate ? new Date(entryDate).getUTCFullYear() : NaN
folder = Number.isFinite(year) ? `dokument/${year}` : 'dokument/_okant-ar'
prefix = `${voucherLabel}_`
} else {
folder = 'dokument/_okopplade'
prefix = ''
}
const candidate = `${folder}/${prefix}${safeName}`
if (!usedPaths.has(candidate)) {
usedPaths.add(candidate)
return candidate
}
// Collision — disambiguate with a short id suffix before the extension.
const dotIdx = safeName.lastIndexOf('.')
const stem = dotIdx > 0 ? safeName.slice(0, dotIdx) : safeName
const ext = dotIdx > 0 ? safeName.slice(dotIdx) : ''
const suffix = doc.id.slice(0, 8)
const disambiguated = `${folder}/${prefix}${stem}_${suffix}${ext}`
usedPaths.add(disambiguated)
return disambiguated
}
interface SieImportRow {
id: string
filename: string | null