Files
accounted/lib/reports/__tests__/xlsx-export.test.ts
T
MattssonandClaude Opus 4.7 32d9978f1b Fix/chrome pdf preview csp (#572)
* feat: add option to exclude year-end closing entries in SIE export and related reports

* delete docs

* fix: allow Chrome's PDF viewer in verifikat document preview

The /api/documents/:id/inline route shipped with
`object-src 'none'` in its CSP, which blocked Chrome's built-in PDF
viewer (it renders inline PDFs via an internal <embed>). Users on
Chrome saw "Det här innehållet har blockerats" when expanding a PDF
attachment in the bookkeeping view; Firefox (PDF.js) and Edge (own
viewer) were unaffected, and JPGs worked because <img> isn't subject
to object-src.

Drops the CSP for this route to the minimum needed for embeddability:
`frame-ancestors 'self'`. X-Content-Type-Options: nosniff plus the
fixed Content-Type from the handler already block MIME confusion;
X-Frame-Options: SAMEORIGIN + frame-ancestors still block clickjacking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(auth): add webmail deep link to email confirmation screens

Mirrors Stripe's signup UX: after asking the user to verify their email,
detect their webmail provider from the domain and show a button that
opens the inbox in a new tab. Gmail gets a from:<sender> search
pre-populated; Outlook/Yahoo/iCloud/Proton open the inbox directly.
Unknown / custom domains fall back to the existing copy.

Sender address is configurable via NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM
(default noreply@gnubok.se) so white-label installs can match their
Supabase Auth SMTP config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): unblock first-time password set for BankID users with MFA

Supabase rejects updateUser({password}) and mfa.unenroll with "AAL2 session
is required" whenever a TOTP factor is enrolled. BankID magic-link logins
produce AAL1, and middleware skips MFA enforcement for bankid_linked users,
so they had no path to AAL2 — leaving them unable to set a backup password
or disable MFA without going through the email-recovery escape hatch.

- /api/account/password: branch on app_metadata.has_password. First-time set
  writes via service.auth.admin.updateUserById (no existing credential to
  protect, AAL2 guard does not apply). Change-password keeps the user-session
  updateUser so AAL2 still fires for credential rotation.
- /mfa/verify: accept a safeReturnTo query param and route there after
  successful verify, so step-up flows can land back where they came from.
- SecuritySettings: detect the AAL2 error from both change-password and
  mfa.unenroll and redirect through /mfa/verify?returnTo=/settings/account
  instead of toasting a dead-end error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add tests and rounding utility for öre precision in bokslut calculations

- Implemented `roundOre` function for rounding SEK amounts to two decimal places, ensuring consistent monetary calculations.
- Introduced `ORE_TOLERANCE` constant for comparing rounded amounts, facilitating invariant checks in financial entries.
- Created comprehensive tests for `roundOre`, covering typical cases, edge cases, and idempotency.
- Added year-end invariants tests to verify database-level guarantees for closing entries, ensuring they balance to the öre and reject discrepancies.
- Developed end-to-end tests for the dispositions chain, validating the correctness of calculations across various scenarios.

* fix: update PDF rendering to remove Swish QR code generation and set default to disable Swish visibility

* fix: enhance security by rejecting data URIs in safeReturnTo function tests

* fix: improve rounding logic in roundOre function and add customer_type migration

* fix: add customer_type column to customers and enforce CHECK constraint

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:29:41 +02:00

236 lines
6.9 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import * as XLSX from 'xlsx'
import {
reportToWorkbook,
textColumn,
currencyColumn,
dateColumn,
integerColumn,
percentColumn,
slugifyCompanyName,
xlsxFilename,
type SheetSpec,
} from '../xlsx-export'
/**
* Helper: parse a workbook buffer back into a usable shape so we can assert on
* cell values, number formats, and per-sheet content.
*
* `cellDates: true` so dates round-trip as Date objects rather than serial
* numbers, matching how Excel/Numbers will read the file.
*/
function parseBuffer(buffer: Buffer): XLSX.WorkBook {
return XLSX.read(buffer, { type: 'buffer', cellNF: true, cellDates: true })
}
describe('reportToWorkbook', () => {
it('writes a single sheet with header row and body cells', () => {
type Row = { account: string; amount: number }
const spec: SheetSpec<Row> = {
name: 'Test',
columns: [textColumn('Konto'), currencyColumn('Belopp')],
rows: [
{ account: '1930', amount: 1500.5 },
{ account: '2440', amount: -750.25 },
],
mapRow: (r) => [r.account, r.amount],
}
const buffer = reportToWorkbook([spec])
expect(buffer).toBeInstanceOf(Buffer)
expect(buffer.byteLength).toBeGreaterThan(0)
const wb = parseBuffer(buffer)
expect(wb.SheetNames).toEqual(['Test'])
const sheet = wb.Sheets['Test']
expect(sheet['A1'].v).toBe('Konto')
expect(sheet['B1'].v).toBe('Belopp')
expect(sheet['A2'].v).toBe('1930')
expect(sheet['B2'].v).toBe(1500.5)
expect(sheet['A3'].v).toBe('2440')
expect(sheet['B3'].v).toBe(-750.25)
})
it('applies currency format to currency columns', () => {
type Row = { name: string; total: number }
const buffer = reportToWorkbook<Row>([
{
name: 'Currency',
columns: [textColumn('Name'), currencyColumn('Total')],
rows: [{ name: 'Foo', total: 1234.56 }],
mapRow: (r) => [r.name, r.total],
},
])
const wb = parseBuffer(buffer)
const sheet = wb.Sheets['Currency']
expect(sheet['B2'].z).toBe('#,##0.00 " kr"')
// The text column does not get our custom format applied. xlsx may add
// a default 'General' format on read, so we only assert it isn't ours.
expect(sheet['A2'].z).not.toBe('#,##0.00 " kr"')
})
it('applies date format to date columns', () => {
type Row = { date: Date }
const buffer = reportToWorkbook<Row>([
{
name: 'Dates',
columns: [dateColumn('Datum')],
rows: [{ date: new Date('2026-03-15T00:00:00Z') }],
mapRow: (r) => [r.date],
},
])
const wb = parseBuffer(buffer)
const sheet = wb.Sheets['Dates']
expect(sheet['A2'].z).toBe('yyyy-mm-dd')
expect(sheet['A2'].t).toBe('d')
})
it('applies integer and percent formats', () => {
const buffer = reportToWorkbook([
{
name: 'Numeric',
columns: [integerColumn('Antal'), percentColumn('Andel')],
rows: [{ count: 42, share: 0.255 }],
mapRow: (r) => [r.count, r.share],
},
])
const wb = parseBuffer(buffer)
const sheet = wb.Sheets['Numeric']
expect(sheet['A2'].z).toBe('#,##0')
expect(sheet['B2'].z).toBe('0.00%')
expect(sheet['A2'].v).toBe(42)
expect(sheet['B2'].v).toBe(0.255)
})
it('produces multiple sheets with independent column shapes', () => {
const buffer = reportToWorkbook([
{
name: 'Saldo',
columns: [textColumn('Konto'), currencyColumn('Belopp')],
rows: [{ account: '1930', amount: 100 }],
mapRow: (r) => [r.account, r.amount],
},
{
name: 'Period',
columns: [dateColumn('Datum'), integerColumn('Antal')],
rows: [{ date: new Date('2026-01-15'), count: 5 }],
mapRow: (r) => [r.date, r.count],
},
])
const wb = parseBuffer(buffer)
expect(wb.SheetNames).toEqual(['Saldo', 'Period'])
expect(wb.Sheets['Saldo']['B1'].v).toBe('Belopp')
expect(wb.Sheets['Period']['A1'].v).toBe('Datum')
expect(wb.Sheets['Period']['A2'].z).toBe('yyyy-mm-dd')
})
it('handles empty row arrays (header-only sheet)', () => {
const buffer = reportToWorkbook([
{
name: 'Empty',
columns: [textColumn('Konto'), currencyColumn('Belopp')],
rows: [],
mapRow: () => ['unused', 0],
},
])
expect(buffer).toBeInstanceOf(Buffer)
const wb = parseBuffer(buffer)
const sheet = wb.Sheets['Empty']
expect(sheet['A1'].v).toBe('Konto')
expect(sheet['B1'].v).toBe('Belopp')
// No body cells emitted.
expect(sheet['A2']).toBeUndefined()
expect(sheet['B2']).toBeUndefined()
})
it('truncates sheet names to Excel 31-char limit', () => {
const longName = 'A'.repeat(40)
const buffer = reportToWorkbook([
{
name: longName,
columns: [textColumn('x')],
rows: [],
mapRow: () => [''],
},
])
const wb = parseBuffer(buffer)
expect(wb.SheetNames[0]).toHaveLength(31)
})
it('treats undefined cells as blank', () => {
const buffer = reportToWorkbook([
{
name: 'Sparse',
columns: [textColumn('A'), textColumn('B')],
rows: [{ a: 'x', b: undefined }],
mapRow: (r) => [r.a, r.b ?? null],
},
])
const wb = parseBuffer(buffer)
const sheet = wb.Sheets['Sparse']
expect(sheet['A2'].v).toBe('x')
expect(sheet['B2']).toBeUndefined()
})
it('throws when row length does not match column count', () => {
expect(() =>
reportToWorkbook([
{
name: 'Bad',
columns: [textColumn('A'), textColumn('B')],
rows: [{ x: 1 }],
mapRow: () => ['only one'],
},
]),
).toThrow(/row length 1 does not match column count 2/)
})
it('throws when given zero sheets', () => {
expect(() => reportToWorkbook([])).toThrow(/at least one sheet/)
})
})
describe('slugifyCompanyName', () => {
it('lowercases and dasherizes', () => {
expect(slugifyCompanyName('Acme Bookkeeping AB')).toBe('acme-bookkeeping-ab')
})
it('replaces Swedish characters', () => {
expect(slugifyCompanyName('Räksmörgås & Co')).toBe('raksmorgas-co')
})
it('falls back to "foretag" when empty', () => {
expect(slugifyCompanyName('')).toBe('foretag')
expect(slugifyCompanyName('!!!')).toBe('foretag')
})
it('collapses repeated separators', () => {
expect(slugifyCompanyName('Foo Bar___Baz')).toBe('foo-bar-baz')
})
})
describe('xlsxFilename', () => {
it('combines slug, company, and compact period', () => {
expect(xlsxFilename('trial-balance', 'Acme AB', '2026-03-31')).toBe(
'trial-balance-acme-ab-20260331.xlsx',
)
})
it('handles missing period gracefully', () => {
expect(xlsxFilename('kpi', 'Test', '')).toBe('kpi-test.xlsx')
})
it('slugifies Swedish company names', () => {
expect(xlsxFilename('vat-declaration', 'Räk AB', '2026-12-31')).toBe(
'vat-declaration-rak-ab-20261231.xlsx',
)
})
})