fix(invoices): ROT/RUT credit notes; verifikat amount sort, HTML underlag, source chip (#1523)
* feat(invoice-inbox): store HTML mails as underlag, expandable field editor Body-only mails and .html attachments (including forwarded .eml bodies) no longer dead-end as "Fel vid bearbetning": the mail body is wrapped into a self-contained text/html document, stored through the normal upload/extract pipeline, and extracted via a new HTML-to-text Bedrock path, so the mail itself can serve as bookable underlag. Empty mails keep the error row, unsupported types are still rejected, and webhook retries dedupe on resend_email_id. Mail HTML is attacker-controlled, so rendering is fully sandboxed: iframe sandbox in the workspace preview and a CSP sandbox header on /api/documents/:id/inline for text/html. The type is accepted only from the email pipeline (EMAIL_ALLOWED_MIME_TYPES), never from manual upload. The "Extraherade falt" rail gains an expand button opening a centered dialog with the same autosaving field editor at a readable size (two columns), which also gives every failed or skipped extraction a manual fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): sortable verifikat list headers with amount sort - clickable sort toggles on the verifikat list headers (asc -> desc -> default) - total_amount computed column + sort_by total/description on the list route - failed list loads render an error card with retry, never the empty-ledger state Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(import): decode bank CSV as Windows-1252 fallback in column mapping The client read the uploaded file with file.text(), which is UTF-8-only, so Windows-1252 exports (e.g. Handelsbanken) rendered and re-parsed with U+FFFD in place of Swedish characters. Decode from bytes with the shared decodeFileContent() helper, matching what the server parse route does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): stackable sort keys on verifikat list headers - shift-click adds a column as secondary/tertiary sort key (max 3), plain click keeps the single-key tri-state cycle - sort_by accepts a comma-separated priority list; single tokens stay valid - voucher tiebreak follows the last key's direction (#972 parity) - priority numbers on stacked headers; hint text in the filter dialog Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): keep ROT/RUT deduction fields positive on credit notes Crediting an invoice with a ROT/RUT deduction failed 100% of the time: the credit-note path negated deduction_total (and per-item deduction_amount) like the other amounts, but both columns carry CHECK (>= 0), so Postgres rejected the insert and the user only saw 'Kunde inte skapa kreditfaktura'. Store the deduction fields as positive magnitudes, matching the convention everywhere else. The stored sign is inert on credit notes: the reversing verifikat recomputes the ROT/RUT split from the items, and the PDF and amount-to-pay logic skip deductions on credit notes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(transactions): share the source chip across inbox and history modes Move SourceFilter to transaction-types.ts (widened with 'bank:other' and 'acct:<id>'), render the one toolbar ContextPicker in both view modes, and drop the narrower duplicate chip inside TransactionHistoryList. The history list now applies the acct:/bank:other narrowing itself and hides skattekonto rows under any bank-side selection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(deps): bump js-yaml to 4.3.1 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(schema): recognize PostgREST computed columns in the migration parser The verifikat amount sort orders by total_amount, a PostgREST computed column (a function on the journal_entries row type, migration 20260811100000). The schema guard only modeled real columns, so no-phantom-columns flagged the order as a phantom. Teach the parser that a function whose only argument is a table's row type joins that table's column set, with DROP FUNCTION retraction when the signature names the row type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: resolve PR #1523 review findings - journal-entries route: append the globally unique id tiebreak to every direct-query sort; voucher series+number repeat across fiscal years, so the all-years scope could duplicate or drop rows at page boundaries. Existing order assertions updated, new all-years tiebreak test. - documents inline route: CSP source policy on HTML previews; sandbox alone still loads remote resources, letting a tracking pixel notify the sender on open. New route test asserts the full header. - JournalEntryList: catch rejected list requests so loading cannot stick forever, and gate every post-await state write behind a request generation so a slow earlier request cannot overwrite the current sort. - TransactionHistoryList: pagination follows the selected source scope (reachable with zero matches on the current page, hidden for the skattekonto scope it cannot affect). - transactions page: bank:other picker availability derives from history rows too, not only the pending inbox dataset. - DECISIONS.md: mark the superseded single-sort decision; record the credit-note deduction positive-magnitude invariant and its verified reader inventory (Swedish review flag). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: guard metadata refetches behind the list request generation fetchAttachmentCounts and fetchRattelseFlags write state after their own awaits; a stale list request's late completion could overwrite attachment counts and rattelse flags for rows a newer request just rendered, showing false missing-underlag warnings. Both helpers now take the caller's generation guard and discard stale completions, including the attachment-counts loaded flag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f2d9e98af3
commit
9dbaebcc50
@@ -7,7 +7,7 @@ import {
|
||||
} from '@/tests/helpers'
|
||||
|
||||
// Mock dependencies
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
@@ -156,6 +156,130 @@ describe('GET /api/bookkeeping/journal-entries', () => {
|
||||
expect(mockSupabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('orders by the total_amount computed column on amount sort, bypassing the RPC', async () => {
|
||||
enqueue({ data: [], error: null, count: 0 })
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
||||
searchParams: { period_id: 'period-1', sort_by: 'total_desc' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
// Amount sort orders by a PostgREST computed column (migration
|
||||
// 20260811100000) that the include_related RPC can't express, so the
|
||||
// route must fall through to the direct query (strict period view),
|
||||
// exactly like voucher sort.
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('journal_entries')
|
||||
expect(mockSupabase.rpc).not.toHaveBeenCalled()
|
||||
expect(findCalls('journal_entries', 'order')[0]).toEqual([
|
||||
'total_amount',
|
||||
{ ascending: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('orders by description on description sort, bypassing the RPC', async () => {
|
||||
enqueue({ data: [], error: null, count: 0 })
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
||||
searchParams: { period_id: 'period-1', sort_by: 'description_asc' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockSupabase.rpc).not.toHaveBeenCalled()
|
||||
expect(findCalls('journal_entries', 'order')[0]).toEqual([
|
||||
'description',
|
||||
{ ascending: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('chains stacked sort keys in priority order and bypasses the RPC', async () => {
|
||||
enqueue({ data: [], error: null, count: 0 })
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
||||
searchParams: { period_id: 'period-1', sort_by: 'total_desc,description_asc' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockSupabase.rpc).not.toHaveBeenCalled()
|
||||
// Priority order preserved, then the voucher tiebreak in the LAST key's
|
||||
// direction (ascending here), then the globally unique id tiebreak:
|
||||
// series+number repeat across fiscal years, so equal keys need a total
|
||||
// order for stable pagination.
|
||||
expect(findCalls('journal_entries', 'order')).toEqual([
|
||||
['total_amount', { ascending: false }],
|
||||
['description', { ascending: true }],
|
||||
['voucher_series', { ascending: true }],
|
||||
['voucher_number', { ascending: true }],
|
||||
['id', { ascending: true }],
|
||||
])
|
||||
})
|
||||
|
||||
it('dedupes repeated sort columns and caps the stack at three keys', async () => {
|
||||
enqueue({ data: [], error: null, count: 0 })
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
||||
searchParams: {
|
||||
include_related: 'false',
|
||||
sort_by: 'date_desc,date_asc,nonsense,voucher_desc,total_asc,description_asc',
|
||||
},
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
// date deduped (first token wins), the unknown token is ignored, and the
|
||||
// stack caps at three keys (date, voucher, total): description never
|
||||
// makes it in. Voucher is in the stack, so no series+number tiebreak is
|
||||
// appended; the id tiebreak always is (duplicate series+number across
|
||||
// fiscal years on the all-years scope).
|
||||
expect(findCalls('journal_entries', 'order')).toEqual([
|
||||
['entry_date', { ascending: false }],
|
||||
['voucher_series', { ascending: false }],
|
||||
['voucher_number', { ascending: false }],
|
||||
['total_amount', { ascending: true }],
|
||||
['id', { ascending: true }],
|
||||
])
|
||||
})
|
||||
|
||||
it('appends the id tiebreak on the all-years scope where voucher identifiers repeat', async () => {
|
||||
enqueue({ data: [], error: null, count: 0 })
|
||||
|
||||
// No period_id: the "Alla räkenskapsår" scope, where A-1 exists once per
|
||||
// fiscal year. Without a globally unique final key, rows with equal
|
||||
// (entry_date, series, number) can swap between page requests and be
|
||||
// duplicated or dropped at page boundaries.
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
||||
searchParams: { include_related: 'false', sort_by: 'date_desc' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
const orderCalls = findCalls('journal_entries', 'order')
|
||||
expect(orderCalls[orderCalls.length - 1]).toEqual(['id', { ascending: false }])
|
||||
})
|
||||
|
||||
it('still serves a single date sort key through the include_related RPC', async () => {
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
||||
searchParams: { period_id: 'period-1', sort_by: 'date_asc' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockSupabase.rpc).toHaveBeenCalledWith(
|
||||
'list_fiscal_period_entries_with_related',
|
||||
expect.objectContaining({ p_sort_date: 'asc' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a large limit (the "Alla" page size) and a negative offset without erroring', async () => {
|
||||
enqueue({ data: [], error: null, count: 0 })
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
|
||||
// Covers the total_amount(journal_entries) computed column (migration
|
||||
// 20260811100000): the verifikat list's amount sort orders by it through
|
||||
// PostgREST, so the function must return the debit-side sum (= credit side on
|
||||
// every balanced entry) and 0 for an entry with no lines yet.
|
||||
describe('total_amount computed column', () => {
|
||||
// Header and balanced lines in ONE transaction: check_balance_on_posted_insert
|
||||
// is deferred to commit, so a posted header committed alone is rejected.
|
||||
async function insertEntry(p: {
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
voucherNumber: number
|
||||
description: string
|
||||
lines: Array<{ account: string; debit: number; credit: number }>
|
||||
status?: 'draft' | 'posted'
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
const client = await getPool().connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(
|
||||
`INSERT INTO public.journal_entries
|
||||
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
||||
entry_date, description, source_type, status)
|
||||
VALUES ($1,$2,$3,$4,$5,'A','2026-06-01',$6,'manual',$7)`,
|
||||
[
|
||||
id,
|
||||
p.userId,
|
||||
p.companyId,
|
||||
p.fiscalPeriodId,
|
||||
p.voucherNumber,
|
||||
p.description,
|
||||
p.status ?? 'posted',
|
||||
],
|
||||
)
|
||||
for (const line of p.lines) {
|
||||
await client.query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[id, line.account, line.debit, line.credit],
|
||||
)
|
||||
}
|
||||
await client.query('COMMIT')
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK').catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
it('returns the debit-side sum, including öre amounts and multi-line entries', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
|
||||
const single = await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 1, description: 'Single line pair',
|
||||
lines: [
|
||||
{ account: '1930', debit: 250.5, credit: 0 },
|
||||
{ account: '3001', debit: 0, credit: 250.5 },
|
||||
],
|
||||
})
|
||||
// Multi-line: total = sum of the debit side (100 + 200), not the line count.
|
||||
const multi = await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 2, description: 'Multi line',
|
||||
lines: [
|
||||
{ account: '1930', debit: 100, credit: 0 },
|
||||
{ account: '5410', debit: 200, credit: 0 },
|
||||
{ account: '2440', debit: 0, credit: 300 },
|
||||
],
|
||||
})
|
||||
|
||||
const { rows } = await getPool().query<{ id: string; total: string }>(
|
||||
`SELECT je.id, public.total_amount(je.*)::text AS total
|
||||
FROM public.journal_entries je
|
||||
WHERE je.id = ANY($1::uuid[])`,
|
||||
[[single, multi]],
|
||||
)
|
||||
const byId = new Map(rows.map((r) => [r.id, Number(r.total)]))
|
||||
expect(byId.get(single)).toBe(250.5)
|
||||
expect(byId.get(multi)).toBe(300)
|
||||
})
|
||||
|
||||
it('returns 0 for an entry without lines and orders a query correctly', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
|
||||
const mid = await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 1, description: 'Mid',
|
||||
lines: [
|
||||
{ account: '1930', debit: 200, credit: 0 },
|
||||
{ account: '3001', debit: 0, credit: 200 },
|
||||
],
|
||||
})
|
||||
const big = await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 2, description: 'Big',
|
||||
lines: [
|
||||
{ account: '1930', debit: 900, credit: 0 },
|
||||
{ account: '3001', debit: 0, credit: 900 },
|
||||
],
|
||||
})
|
||||
// A draft with no lines yet must sort as 0, not error or drop out.
|
||||
const empty = await insertEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 0, description: 'Empty draft',
|
||||
status: 'draft', lines: [],
|
||||
})
|
||||
|
||||
const { rows } = await getPool().query<{ id: string }>(
|
||||
`SELECT je.id
|
||||
FROM public.journal_entries je
|
||||
WHERE je.company_id = $1
|
||||
ORDER BY public.total_amount(je.*) DESC, je.voucher_number ASC`,
|
||||
[companyId],
|
||||
)
|
||||
expect(rows.map((r) => r.id)).toEqual([big, mid, empty])
|
||||
})
|
||||
})
|
||||
@@ -47,30 +47,47 @@ export const GET = withRouteContext('bookkeeping.journal_entries.list', async (r
|
||||
// year's series (the BFL-compliant per-year view). It narrows the period, it
|
||||
// never widens it.
|
||||
const search = searchParams.get('search')?.trim() || null
|
||||
// 'date_desc' (default) | 'date_asc' | 'voucher_asc' | 'voucher_desc'
|
||||
// sort_by overrides sort_date when present. sort_date is kept for backwards
|
||||
// compatibility with older clients.
|
||||
const sortBy = searchParams.get('sort_by')
|
||||
const isVoucherSort = sortBy === 'voucher_asc' || sortBy === 'voucher_desc'
|
||||
// sort_by is a comma-separated priority list of `${column}_${direction}`
|
||||
// tokens over date | voucher | total | description (single tokens, the old
|
||||
// format, stay valid). Unknown tokens are ignored, repeated columns are
|
||||
// deduped, and the list is capped at 3 keys to bound DB work. Amount keys
|
||||
// order by the total_amount computed column (sum of debit lines, migration
|
||||
// 20260811100000): PostgREST evaluates it per row, which the RPC path
|
||||
// below cannot express. sort_by overrides sort_date when present;
|
||||
// sort_date is kept for backwards compatibility with older clients.
|
||||
const SORT_TOKEN_RE = /^(date|voucher|total|description)_(asc|desc)$/
|
||||
const MAX_SORT_KEYS = 3
|
||||
const sortKeys: { column: 'date' | 'voucher' | 'total' | 'description'; ascending: boolean }[] = []
|
||||
for (const token of (searchParams.get('sort_by') ?? '').split(',')) {
|
||||
const m = SORT_TOKEN_RE.exec(token.trim())
|
||||
if (!m) continue
|
||||
const column = m[1] as (typeof sortKeys)[number]['column']
|
||||
if (sortKeys.some((k) => k.column === column)) continue
|
||||
sortKeys.push({ column, ascending: m[2] === 'asc' })
|
||||
if (sortKeys.length === MAX_SORT_KEYS) break
|
||||
}
|
||||
// A single date key (or no keys at all) is the only shape the RPC path can
|
||||
// serve, via p_sort_date. Everything else falls through to the direct query.
|
||||
const soloDateKey = sortKeys.length === 1 && sortKeys[0].column === 'date' ? sortKeys[0] : null
|
||||
// Default on: when a fiscal period is selected, include follow-up entries
|
||||
// booked in later periods whose source aggregate (invoice, supplier invoice)
|
||||
// is dated inside the selected period. Pass include_related=false to
|
||||
// restore strict fiscal_period_id filtering.
|
||||
const includeRelated = searchParams.get('include_related') !== 'false'
|
||||
|
||||
const dateAscending = sortDate === 'asc' || sortBy === 'date_asc'
|
||||
const sortDateParam = sortBy === 'date_asc' || sortDate === 'asc' ? 'asc' : 'desc'
|
||||
const dateAscending = sortDate === 'asc'
|
||||
const sortDateParam = soloDateKey ? (soloDateKey.ascending ? 'asc' : 'desc') : sortDate === 'asc' ? 'asc' : 'desc'
|
||||
|
||||
// Voucher-sort path: include_related RPC doesn't support voucher ordering,
|
||||
// so fall through to the direct query below. This means voucher sort is
|
||||
// *strict by fiscal_period_id*: cross-period follow-up entries that the
|
||||
// RPC normally surfaces under date sort are excluded under voucher sort.
|
||||
// Non-date sorts (and stacked sorts): the include_related RPC only orders
|
||||
// by date, so fall through to the direct query below. This means these
|
||||
// sorts are *strict by fiscal_period_id*: cross-period follow-up entries
|
||||
// that the RPC normally surfaces under date sort are excluded.
|
||||
// That's intentional: voucher numbers are series-scoped within a fiscal
|
||||
// year (BFL 5 kap 6-7 §§), so showing series A1, A2 … alongside entries
|
||||
// belonging to a different year's series would be misleading. The trade-off
|
||||
// is that the visible row count may differ between sort modes for the same
|
||||
// period; the strict count is the BFL-compliant view of that year.
|
||||
if (periodId && includeRelated && !isVoucherSort && !search) {
|
||||
if (periodId && includeRelated && (sortKeys.length === 0 || soloDateKey) && !search) {
|
||||
const { data, error } = await supabase.rpc('list_fiscal_period_entries_with_related', {
|
||||
p_company_id: companyId,
|
||||
p_period_id: periodId,
|
||||
@@ -108,22 +125,53 @@ export const GET = withRouteContext('bookkeeping.journal_entries.list', async (r
|
||||
.select('*, lines:journal_entry_lines(*)', { count: 'exact' })
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (isVoucherSort) {
|
||||
const voucherAscending = sortBy === 'voucher_asc'
|
||||
query = query
|
||||
.order('voucher_series', { ascending: voucherAscending })
|
||||
.order('voucher_number', { ascending: voucherAscending })
|
||||
} else if (sortDate === 'asc' || sortDate === 'desc' || sortBy === 'date_asc' || sortBy === 'date_desc') {
|
||||
// Tiebreak same-date vouchers in the SAME direction as the date sort, and
|
||||
// by series before number so the order matches the RPC path (#972).
|
||||
if (sortKeys.length > 0) {
|
||||
// Apply the priority list in order. total_amount is a computed column (a
|
||||
// function on the row type), which PostgREST accepts in order=.
|
||||
for (const key of sortKeys) {
|
||||
switch (key.column) {
|
||||
case 'voucher':
|
||||
query = query
|
||||
.order('voucher_series', { ascending: key.ascending })
|
||||
.order('voucher_number', { ascending: key.ascending })
|
||||
break
|
||||
case 'date':
|
||||
query = query.order('entry_date', { ascending: key.ascending })
|
||||
break
|
||||
case 'total':
|
||||
query = query.order('total_amount', { ascending: key.ascending })
|
||||
break
|
||||
case 'description':
|
||||
query = query.order('description', { ascending: key.ascending })
|
||||
break
|
||||
}
|
||||
}
|
||||
// Stable pagination needs a total order: unless voucher is already a key,
|
||||
// tiebreak by series+number in the LAST key's direction, so a plain date
|
||||
// sort keeps same-date vouchers running the same way as the RPC (#972).
|
||||
if (!sortKeys.some((k) => k.column === 'voucher')) {
|
||||
const tiebreakAscending = sortKeys[sortKeys.length - 1].ascending
|
||||
query = query
|
||||
.order('voucher_series', { ascending: tiebreakAscending })
|
||||
.order('voucher_number', { ascending: tiebreakAscending })
|
||||
}
|
||||
// Final id tiebreak: series+number repeat across fiscal years, so on an
|
||||
// all-years scope equal sort keys could reshuffle between page requests
|
||||
// and duplicate or drop rows at page boundaries.
|
||||
query = query.order('id', { ascending: sortKeys[sortKeys.length - 1].ascending })
|
||||
} else if (sortDate === 'asc' || sortDate === 'desc') {
|
||||
// Legacy sort_date param (older clients). Tiebreak same-date vouchers in
|
||||
// the SAME direction as the date sort (#972).
|
||||
query = query
|
||||
.order('entry_date', { ascending: dateAscending })
|
||||
.order('voucher_series', { ascending: dateAscending })
|
||||
.order('voucher_number', { ascending: dateAscending })
|
||||
.order('id', { ascending: dateAscending })
|
||||
} else {
|
||||
query = query
|
||||
.order('voucher_series', { ascending: true })
|
||||
.order('voucher_number', { ascending: true })
|
||||
.order('id', { ascending: true })
|
||||
}
|
||||
|
||||
query = query.range(offset, offset + limit - 1)
|
||||
|
||||
@@ -110,5 +110,31 @@ describe('GET /api/documents/[id]/inline', () => {
|
||||
expect(disposition).toContain('filename="kvitto f_rvaring.pdf"')
|
||||
expect(res.headers.get('Content-Type')).toBe('application/pdf')
|
||||
expect(res.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
// The mail-body CSP is HTML-only: it must not restrict PDF rendering.
|
||||
expect(res.headers.get('Content-Security-Policy')).toBeNull()
|
||||
})
|
||||
|
||||
it('serves HTML documents with a sandboxing CSP that blocks outbound requests', async () => {
|
||||
enqueue({
|
||||
data: makeDoc({ file_name: 'faktura.html', mime_type: 'text/html' }),
|
||||
error: null,
|
||||
})
|
||||
downloadMock.mockResolvedValue({
|
||||
data: new Blob(['<img src="https://tracker.example/pixel.gif">']),
|
||||
error: null,
|
||||
})
|
||||
|
||||
const res = await GET(makeReq(), createMockRouteParams({ id: 'doc-1' }))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Content-Type')).toBe('text/html')
|
||||
// sandbox alone neutralizes scripts but still loads remote resources: a
|
||||
// tracking pixel in a mail body would notify the sender on preview. The
|
||||
// source policy confines the document to inline styles and embedded
|
||||
// data:/blob: images.
|
||||
expect(res.headers.get('Content-Security-Policy')).toBe(
|
||||
"sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:",
|
||||
)
|
||||
expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,10 +76,12 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
)
|
||||
}
|
||||
|
||||
const contentType = resolveContentType(doc.file_name, doc.mime_type)
|
||||
|
||||
return new NextResponse(blob, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': resolveContentType(doc.file_name, doc.mime_type),
|
||||
'Content-Type': contentType,
|
||||
// RFC 5987 dual form: NFD filenames from macOS/iOS uploads contain
|
||||
// combining marks (> 0xFF), which undici Headers reject as non-
|
||||
// ByteString values; splicing the raw name here 500ed the route.
|
||||
@@ -90,6 +92,20 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
// content. Without nosniff a tampered file_name extension could
|
||||
// serve a stored document under an attacker-chosen MIME type.
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
// text/html documents are attacker-controlled mail bodies from the
|
||||
// invoice inbox. Served inline on the app origin they would execute
|
||||
// scripts with our origin's authority: CSP sandbox (no tokens) makes
|
||||
// the rendered document opaque-origin and script-free wherever it is
|
||||
// opened, iframe or direct tab. The source policy blocks outbound
|
||||
// requests on top of that: sandbox alone still loads remote images,
|
||||
// so a tracking pixel would notify the sender when the preview is
|
||||
// opened. Inline styles and embedded data:/blob: images keep working.
|
||||
...(contentType === 'text/html'
|
||||
? {
|
||||
'Content-Security-Policy':
|
||||
"sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const { supabase: mockSupabase, enqueue, reset, findCall } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
@@ -550,6 +550,81 @@ describe('POST /api/invoices (create credit note)', () => {
|
||||
expect(mockSupabase.from).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
// Regression: crediting a ROT/RUT invoice used to negate deduction_total and
|
||||
// deduction_amount like the other amounts, which the DB refuses (both columns
|
||||
// carry CHECK >= 0), so no deduction-carrying invoice could be credited. The
|
||||
// stored deduction fields are positive magnitudes on credit notes too.
|
||||
it('keeps deduction fields positive when crediting a ROT invoice', async () => {
|
||||
const original = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
subtotal: 60000,
|
||||
vat_amount: 15000,
|
||||
total: 75000,
|
||||
deduction_total: 22500,
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
invoice_id: VALID_UUID,
|
||||
sort_order: 0,
|
||||
description: 'Snickeri',
|
||||
quantity: 30,
|
||||
unit: 'tim',
|
||||
unit_price: 2000,
|
||||
line_total: 60000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 15000,
|
||||
deduction_type: 'rot',
|
||||
deduction_amount: 22500,
|
||||
created_at: '2026-08-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
const creditNote = makeInvoice({
|
||||
id: 'cn-rot',
|
||||
credited_invoice_id: VALID_UUID,
|
||||
status: 'draft',
|
||||
})
|
||||
|
||||
// Fetch original invoice
|
||||
enqueue({ data: original, error: null })
|
||||
// No existing credit-note draft
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert credit note
|
||||
enqueue({ data: creditNote, error: null })
|
||||
// Insert credit note items
|
||||
enqueue({ data: null, error: null })
|
||||
// Mark creation complete
|
||||
enqueue({ data: null, error: null })
|
||||
// Fetch complete credit note
|
||||
enqueue({ data: { ...creditNote, items: [] }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
const [invoiceInsert] = findCall('invoices', 'insert') ?? []
|
||||
expect(invoiceInsert).toMatchObject({
|
||||
total: -75000,
|
||||
subtotal: -60000,
|
||||
vat_amount: -15000,
|
||||
deduction_total: 22500,
|
||||
})
|
||||
const [itemsInsert] = findCall('invoice_items', 'insert') ?? []
|
||||
expect(itemsInsert).toMatchObject([
|
||||
{
|
||||
line_total: -60000,
|
||||
vat_amount: -15000,
|
||||
deduction_type: 'rot',
|
||||
deduction_amount: 22500,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an existing credit-note draft instead of creating a duplicate', async () => {
|
||||
const original = makeInvoice({ id: VALID_UUID, status: 'sent' })
|
||||
const existing = makeInvoice({
|
||||
|
||||
@@ -381,8 +381,11 @@ async function createCreditNote(
|
||||
reverse_charge_text: originalInvoice.reverse_charge_text,
|
||||
your_reference: originalInvoice.your_reference,
|
||||
our_reference: originalInvoice.our_reference,
|
||||
// Positive magnitude, unlike the negated amounts above: the DB has
|
||||
// CHECK (deduction_total >= 0), and every reader either recomputes the
|
||||
// ROT/RUT amount from the items or skips credit notes entirely.
|
||||
deduction_total: originalInvoice.deduction_total
|
||||
? -Math.abs(originalInvoice.deduction_total)
|
||||
? Math.abs(originalInvoice.deduction_total)
|
||||
: 0,
|
||||
deduction_personnummer_encrypted: originalInvoice.deduction_personnummer_encrypted ?? null,
|
||||
deduction_personnummer_last4: originalInvoice.deduction_personnummer_last4 ?? null,
|
||||
|
||||
Reference in New Issue
Block a user