* 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>
147 lines
6.0 KiB
TypeScript
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;')
|
|
})
|
|
})
|