Files
accounted/lib/reports/__tests__/periodisk-sammanstallning-csv.test.ts
T
MattssonandClaude Opus 4.7 c8461397c8 Bug/accounting ps eu (#474)
* feat(api): implement commit functionality for journal entries

* fix(extensions): make ExtensionSettings.clear() a real delete so disconnect flows work

The 2026-03-30 multi-tenant refactor dropped all RLS policies on extension_data
and recreated only SELECT/INSERT/UPDATE. Combined with `value jsonb NOT NULL`,
every extension that called `settings.set(key, null)` to clear stored state
(cloud-backup disconnect, skatteverket OAuth/AGI cleanup, arcim-migration
consent reset) silently failed — the upsert hit the NOT NULL constraint and
the error was swallowed, leaving users stuck with stale connection rows.

Adds an `extension_data_delete` RLS policy, a `clear(key)` method backed by a
real DELETE, switches the four affected handlers, and makes `set()` throw on
Supabase error so this class of silent failure can't recur.

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

* feat(journal-entries): add draft saving functionality to journal entry form

* feat: add periodisk sammanställning report generation and CSV export

- Implemented period date helpers in `period-dates.ts` for calculating start and end dates based on period type (monthly, quarterly, yearly).
- Created `periodisk-sammanstallning.ts` to generate the periodisk sammanställning report, including data fetching, validation, and warning handling.
- Developed CSV serializer in `periodisk-sammanstallning-csv.ts` for exporting the report in SKV574008 format.
- Added new columns to `company_settings` for storing periodisk sammanställning settings and tax contact information via migration.
- Introduced a new migration to add a `paid_with_private_funds` flag to `supplier_invoices` for tracking out-of-pocket expenses.
- Updated journal entries to include the new source type for privately paid supplier invoices.

* feat(migrations): add paid_with_private_funds flag to supplier_invoices and expand journal_entries.source_type CHECK

* fix(ai_requests): drop existing policies and trigger before creating new ones

* fix(migrations): ensure extension_data has a proper DELETE policy for ExtensionSettings.clear()

* fix(supplier-invoices): update error handling for invalid input in POST request

* fix: correct capitalization in project title

* fix(migrations): resolve duplicate version 20260513120000

Two migrations shared the same timestamp prefix, causing
schema_migrations_pkey collision on Supabase preview branches.
Bump extension_data_delete_policy to 20260513120001.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:10:44 +02:00

147 lines
6.0 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
buildPeriodiskSammanstallningCsv,
formatPeriodCode,
PsCsvBuildError,
} from '../periodisk-sammanstallning-csv'
import type { PeriodiskSammanstallningReport } from '../periodisk-sammanstallning'
function mkReport(partial: Partial<PeriodiskSammanstallningReport> = {}): PeriodiskSammanstallningReport {
return {
period: { type: 'monthly', year: 2025, period: 5, start: '2025-05-01', end: '2025-05-31', label: 'Maj 2025' },
rows: [],
warnings: [],
totals: { services: 0, goods: 0, triangulation: 0, grand: 0, rowCount: 0 },
reconciliation: { ruta39: null, ruta35: null, ruta38: null, matches: null, tolerance: 1 },
...partial,
}
}
const FILER = {
organizationNumber: '5560000167',
contactName: 'Per Persson',
contactPhone: '0123-45690',
contactEmail: 'post@filmkopia.se',
}
describe('formatPeriodCode', () => {
it('monthly: YYMM zero-padded', () => {
expect(formatPeriodCode('monthly', 2025, 5)).toBe('2505')
expect(formatPeriodCode('monthly', 2020, 1)).toBe('2001')
expect(formatPeriodCode('monthly', 2025, 12)).toBe('2512')
})
it('quarterly: YY-Q with hyphen', () => {
expect(formatPeriodCode('quarterly', 2025, 2)).toBe('25-2')
expect(formatPeriodCode('quarterly', 2022, 4)).toBe('22-4')
})
it('throws on invalid month', () => {
expect(() => formatPeriodCode('monthly', 2025, 13)).toThrow(PsCsvBuildError)
expect(() => formatPeriodCode('monthly', 2025, 0)).toThrow()
})
it('throws on invalid quarter', () => {
expect(() => formatPeriodCode('quarterly', 2025, 5)).toThrow()
})
})
describe('buildPeriodiskSammanstallningCsv', () => {
it('emits SKV574008 header + filer line + rows', () => {
const report = mkReport({
rows: [
{ country: 'DE', vatNumber: '123456789', services: 10000, goods: 0, triangulation: 0, customerId: null, customerName: null, hasBlockingIssue: false },
{ country: 'FI', vatNumber: '01409351', services: 0, goods: 5000, triangulation: 0, customerId: null, customerName: null, hasBlockingIssue: false },
],
totals: { services: 10000, goods: 5000, triangulation: 0, grand: 15000, rowCount: 2 },
})
const { content } = buildPeriodiskSammanstallningCsv(report, FILER)
const text = content.toString('latin1')
const expected =
'SKV574008;\r\n' +
'5560000167;2505;Per Persson;0123-45690;post@filmkopia.se\r\n' +
'DE123456789;10000;;;\r\n' +
'FI01409351;;5000;;\r\n'
expect(text).toBe(expected)
})
it('emits empty fields rather than zeros', () => {
const report = mkReport({
rows: [{ country: 'DE', vatNumber: '123', services: 1000, goods: 0, triangulation: 0, customerId: null, customerName: null, hasBlockingIssue: false }],
totals: { services: 1000, goods: 0, triangulation: 0, grand: 1000, rowCount: 1 },
})
const text = buildPeriodiskSammanstallningCsv(report, FILER).content.toString('latin1')
expect(text).toContain('DE123;1000;;;')
expect(text).not.toContain(';0;')
})
it('renders negative amounts as signed integers', () => {
const report = mkReport({
rows: [{ country: 'DE', vatNumber: '123', services: -1234, goods: 0, triangulation: 0, customerId: null, customerName: null, hasBlockingIssue: false }],
totals: { services: -1234, goods: 0, triangulation: 0, grand: -1234, rowCount: 1 },
})
const text = buildPeriodiskSammanstallningCsv(report, FILER).content.toString('latin1')
expect(text).toContain('DE123;-1234;;;')
})
it('latin1 encoding preserves Swedish characters', () => {
const report = mkReport()
const filer = { ...FILER, contactName: 'Åsa Östberg' }
const { content } = buildPeriodiskSammanstallningCsv(report, filer)
// Round-trip via latin1.
const text = content.toString('latin1')
expect(text).toContain('Åsa Östberg')
// Confirm bytes are single-byte latin1, not utf-8 (Å in utf-8 is 0xC3 0x85; in latin1 it's 0xC5).
const aRingByte = content.indexOf(0xC5)
expect(aRingByte).toBeGreaterThan(0)
})
it('refuses to build with blocking errors', () => {
const report = mkReport({
warnings: [{ level: 'error', code: 'MISSING_COUNTRY', message: 'oops' }],
})
expect(() => buildPeriodiskSammanstallningCsv(report, FILER)).toThrow(PsCsvBuildError)
})
it('refuses to build when filer info missing', () => {
const report = mkReport()
expect(() =>
buildPeriodiskSammanstallningCsv(report, { ...FILER, contactEmail: '' }),
).toThrow(PsCsvBuildError)
})
it('skips rows that round to zero in all buckets', () => {
const report = mkReport({
rows: [
{ country: 'DE', vatNumber: '111', services: 100, goods: 0, triangulation: 0, customerId: null, customerName: null, hasBlockingIssue: false },
{ country: 'FR', vatNumber: '222', services: 0, goods: 0, triangulation: 0, customerId: null, customerName: null, hasBlockingIssue: false },
],
totals: { services: 100, goods: 0, triangulation: 0, grand: 100, rowCount: 2 },
})
const text = buildPeriodiskSammanstallningCsv(report, FILER).content.toString('latin1')
expect(text).toContain('DE111')
expect(text).not.toContain('FR222')
})
it('filename uses orgnr + YYMM for monthly', () => {
const report = mkReport()
const { filename } = buildPeriodiskSammanstallningCsv(report, FILER)
expect(filename).toBe('Periodisk_sammanstallning_5560000167_2505.csv')
})
it('filename uses orgnr + YYQQ for quarterly', () => {
const report = mkReport({
period: { type: 'quarterly', year: 2025, period: 2, start: '2025-04-01', end: '2025-06-30', label: 'Kvartal 2 2025' },
})
const { filename } = buildPeriodiskSammanstallningCsv(report, FILER)
expect(filename).toBe('Periodisk_sammanstallning_5560000167_25Q2.csv')
})
it('strips formatting from organization number', () => {
const { filename, content } = buildPeriodiskSammanstallningCsv(mkReport(), {
...FILER,
organizationNumber: '556000-0167',
})
expect(filename).toContain('5560000167')
expect(content.toString('latin1')).toContain('5560000167;')
})
})