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>
This commit is contained in:
Mattsson
2026-05-14 01:10:44 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent ba45b8f661
commit c8461397c8
40 changed files with 3147 additions and 55 deletions
+11
View File
@@ -98,6 +98,7 @@ export const JournalEntrySourceTypeSchema = z.enum([
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'supplier_invoice_privately_paid',
'supplier_credit_note',
'currency_revaluation',
])
@@ -137,6 +138,8 @@ export const DeadlineSourceSchema = z.enum(['system', 'user'])
export const MomsPeriodSchema = z.enum(['monthly', 'quarterly', 'yearly'])
export const PsPeriodTypeSchema = z.enum(['monthly', 'quarterly'])
export const DocumentUploadSourceSchema = z.enum([
'camera', 'file_upload', 'email', 'e_invoice', 'scan', 'api', 'system',
])
@@ -263,6 +266,10 @@ export const CreateSupplierInvoiceSchema = z.object({
reverse_charge: z.boolean().optional(),
payment_reference: z.string().optional(),
notes: z.string().optional(),
paid_with_private_funds: z.boolean().optional(),
// For paid_with_private_funds: the date the owner paid out-of-pocket.
// Defaults to invoice_date (common for kvitto where the two coincide).
payment_date: isoDate.optional(),
items: z.array(CreateSupplierInvoiceItemSchema).min(1, 'At least one item is required'),
})
@@ -371,6 +378,10 @@ export const UpdateSettingsSchema = z.object({
vat_registered: z.boolean().optional(),
vat_number: z.string().regex(/^SE\d{12}$/, 'Momsregistreringsnummer måste vara SE följt av 12 siffror').nullable().optional(),
moms_period: MomsPeriodSchema.nullable().optional(),
periodisk_sammanstallning_period: PsPeriodTypeSchema.optional(),
tax_contact_name: z.string().max(200).nullable().optional(),
tax_contact_phone: z.string().max(40).nullable().optional(),
tax_contact_email: z.string().email().nullable().optional().or(z.literal('')),
fiscal_year_start_month: z.number().int().min(1).max(12).optional(),
preliminary_tax_monthly: z.number().nullable().optional(),
bank_name: z.string().max(100, 'Banknamn får vara max 100 tecken').optional(),
@@ -83,6 +83,7 @@ const {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
createSupplierCreditNoteEntry,
createSupplierInvoicePrivatelyPaidEntry,
} = await import('../supplier-invoice-entries')
function makeItem(overrides: Partial<SupplierInvoiceItem> = {}): SupplierInvoiceItem {
@@ -1140,3 +1141,164 @@ describe('createSupplierCreditNoteEntry', () => {
assertBalanced(input)
})
})
// ============================================================
// createSupplierInvoicePrivatelyPaidEntry — eget utlägg
// ============================================================
describe('createSupplierInvoicePrivatelyPaidEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('returns null when no fiscal period found', async () => {
mockedFindFiscalPeriod.mockResolvedValue(null)
const invoice = makeSupplierInvoice()
const items = [makeItem()]
const result = await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
expect(result).toBeNull()
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('AB: credits 2893 (D expense + D 2641 + C 2893)', async () => {
const invoice = makeSupplierInvoice({
subtotal: 400,
vat_amount: 100,
total: 500,
})
const items = [makeItem({ line_total: 400, account_number: '6110', vat_rate: 0.25 })]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag', 'Pressbyrån'
)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.source_type).toBe('supplier_invoice_privately_paid')
const debit6110 = findByAccount(input.lines, '6110')
expect(debit6110).toHaveLength(1)
expect(debit6110[0].debit_amount).toBe(400)
const debit2641 = findByAccount(input.lines, '2641')
expect(debit2641).toHaveLength(1)
expect(debit2641[0].debit_amount).toBe(100)
const credit2893 = findByAccount(input.lines, '2893')
expect(credit2893).toHaveLength(1)
expect(credit2893[0].credit_amount).toBe(500)
// AP account 2440 must NOT appear — privately-paid bypasses AP entirely.
expect(findByAccount(input.lines, '2440')).toHaveLength(0)
// Bank account 1930 must NOT appear — the owner paid, not the company.
expect(findByAccount(input.lines, '1930')).toHaveLength(0)
// EF owner account 2018 must NOT appear for AB.
expect(findByAccount(input.lines, '2018')).toHaveLength(0)
assertBalanced(input)
})
it('EF: credits 2018 instead of 2893', async () => {
const invoice = makeSupplierInvoice({
subtotal: 400,
vat_amount: 100,
total: 500,
})
const items = [makeItem({ line_total: 400, account_number: '6110', vat_rate: 0.25 })]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'enskild_firma', 'Pressbyrån'
)
const input = mockedCreateEntry.mock.calls[0][3]
const credit2018 = findByAccount(input.lines, '2018')
expect(credit2018).toHaveLength(1)
expect(credit2018[0].credit_amount).toBe(500)
expect(findByAccount(input.lines, '2893')).toHaveLength(0)
expect(findByAccount(input.lines, '2440')).toHaveLength(0)
assertBalanced(input)
})
it('skips 2641 line when invoice has zero VAT', async () => {
const invoice = makeSupplierInvoice({
subtotal: 500,
vat_amount: 0,
total: 500,
})
const items = [makeItem({ line_total: 500, account_number: '5460', vat_rate: 0 })]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '5460')[0].debit_amount).toBe(500)
expect(findByAccount(input.lines, '2641')).toHaveLength(0)
expect(findByAccount(input.lines, '2893')[0].credit_amount).toBe(500)
assertBalanced(input)
})
it('handles mixed-rate kvitto with separate 2641 lines per rate', async () => {
// Lunch (12%) + parking (25%) on the same kvitto
const invoice = makeSupplierInvoice({
subtotal: 200,
vat_amount: 36, // 100*0.12 + 100*0.25 = 12 + 25 = 37; off-by-one from rounding
total: 237,
})
const items = [
makeItem({ line_total: 100, account_number: '5810', vat_rate: 0.12 }),
makeItem({ line_total: 100, account_number: '5611', vat_rate: 0.25 }),
]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
const input = mockedCreateEntry.mock.calls[0][3]
// One 2641 line per rate
const vat2641 = findByAccount(input.lines, '2641')
expect(vat2641).toHaveLength(2)
// Credit 2893 = sum of all debits
const totalDebits = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const credit2893 = findByAccount(input.lines, '2893')[0]
expect(Math.round(credit2893.credit_amount * 100)).toBe(Math.round(totalDebits * 100))
assertBalanced(input)
})
it('aggregates expense lines per account number', async () => {
// Two items on the same expense account should collapse to one debit line
const invoice = makeSupplierInvoice({
subtotal: 600,
vat_amount: 150,
total: 750,
})
const items = [
makeItem({ line_total: 300, account_number: '6110', vat_rate: 0.25 }),
makeItem({ line_total: 300, account_number: '6110', vat_rate: 0.25 }),
]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
const input = mockedCreateEntry.mock.calls[0][3]
const debit6110 = findByAccount(input.lines, '6110')
expect(debit6110).toHaveLength(1)
expect(debit6110[0].debit_amount).toBe(600)
})
})
@@ -388,6 +388,92 @@ export async function createSupplierInvoiceCashEntry(
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* Create journal entry for an invoice paid with the owner's private funds
* (eget utlägg). The AP leg is bypassed entirely — instead of crediting 2440
* and later debiting it on mark-paid, the expense lines book straight against
* the owner's payable/equity account:
*
* Debit 5xxx/6xxx (per item) [line_total in SEK]
* Debit 2641 Ingående moms [VAT per rate]
* Credit 2893 / 2018 [total incl VAT]
*
* Reverse charge is intentionally not supported here. RC invoices are
* never "I paid this cash at a kiosk" cases — they're EU/byggtjänster from
* registered businesses with formal invoices, which always go through AP.
* The API route guards against this combo before calling us.
*/
export async function createSupplierInvoicePrivatelyPaidEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
entityType: 'aktiebolag' | 'enskild_firma',
supplierName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, invoice.invoice_date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for invoice date:', invoice.invoice_date)
return null
}
const ownerAccount = entityType === 'aktiebolag' ? '2893' : '2018'
const desc = buildSupplierDescription('Eget utlägg', invoice.supplier_invoice_number, supplierName, `(ankomst ${invoice.arrival_number})`)
const lines: CreateJournalEntryLineInput[] = []
// Debit: Expense accounts (in SEK), aggregated per account
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
expenseByAccount.set(item.account_number, current + itemSek)
}
for (const [accountNumber, amount] of expenseByAccount) {
lines.push({
account_number: accountNumber,
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: desc,
})
}
// Debit: Ingående moms per rate group (mixed-rate kvitto support)
if (invoice.vat_amount > 0) {
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
for (const [rate, amount] of vatByRate) {
if (amount > 0) {
lines.push({
account_number: '2641',
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
})
}
}
}
// Credit: Owner payable/equity — balance guarantee
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
lines.push({
account_number: ownerAccount,
debit_amount: 0,
credit_amount: Math.round(totalDebits * 100) / 100,
line_description: desc,
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: invoice.invoice_date,
description: desc,
source_type: 'supplier_invoice_privately_paid',
source_id: invoice.id,
lines,
}
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* Create journal entry for a supplier credit note (reversal of registration)
*
+44
View File
@@ -696,6 +696,44 @@ const VAT_REPORT: Record<string, StructuredErrorEntry> = {
},
}
const PS_REPORT: Record<string, StructuredErrorEntry> = {
PS_REPORT_MISSING_PARAMS: {
httpStatus: 400,
message_sv: 'periodType, year och period krävs.',
message_en: 'periodType, year and period query parameters are required.',
},
PS_REPORT_INVALID_PERIOD_TYPE: {
httpStatus: 400,
message_sv: 'periodType måste vara monthly eller quarterly.',
message_en: 'periodType must be monthly or quarterly.',
},
PS_REPORT_INVALID_YEAR: {
httpStatus: 400,
message_sv: 'year måste vara ett giltigt årtal mellan 2000 och 2100.',
message_en: 'year must be a number between 2000 and 2100.',
},
PS_REPORT_INVALID_PERIOD: {
httpStatus: 400,
message_sv: 'period är ogiltig för vald periodtyp.',
message_en: 'period is invalid for the chosen period type.',
},
PS_REPORT_GENERATION_FAILED: {
httpStatus: 500,
message_sv: 'Periodisk sammanställning kunde inte beräknas.',
message_en: 'Failed to generate periodisk sammanställning.',
},
PS_REPORT_CSV_BLOCKED_BY_ERRORS: {
httpStatus: 400,
message_sv: 'CSV kan inte laddas ner. Åtgärda blockerande fel först.',
message_en: 'CSV download blocked by validation errors. Fix them first.',
},
PS_REPORT_MISSING_FILER_INFO: {
httpStatus: 400,
message_sv: 'Kontaktuppgifter saknas. Fyll i namn, telefon och e-post under Inställningar.',
message_en: 'Tax contact information is missing on company_settings.',
},
}
const SIE_EXPORT: Record<string, StructuredErrorEntry> = {
SIE_EXPORT_COMPANY_NOT_FOUND: {
httpStatus: 404,
@@ -1167,6 +1205,11 @@ const SUPPLIER_INVOICE_WAVE4: Record<string, StructuredErrorEntry> = {
message_sv: 'Leverantörsfakturan kunde inte skapas.',
message_en: 'Failed to create supplier invoice.',
},
SI_CREATE_INVALID_INPUT: {
httpStatus: 400,
message_sv: 'Ogiltig kombination av fakturafält. Kontrollera formuläret och försök igen.',
message_en: 'Invalid combination of supplier invoice fields.',
},
SI_PAID_ALREADY: {
httpStatus: 409,
message_sv: 'Leverantörsfakturan är redan betald eller krediterad.',
@@ -1365,6 +1408,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
...FX,
...REPORT,
...VAT_REPORT,
...PS_REPORT,
...SIE_EXPORT,
...TAX_DECL,
...SIE_IMPORT,
@@ -120,6 +120,34 @@ describe('createExtensionContext', () => {
expect(supabase.from).toHaveBeenCalledWith('extension_data')
})
it('settings.set() throws when supabase returns an error', async () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: null, error: { message: 'null value in column "value" violates not-null constraint' } })
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'test-ext')
await expect(ctx.settings.set('my-key', null)).rejects.toThrow(/extension_data set failed/)
})
it('settings.clear() deletes from extension_data table', async () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: null, error: null })
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'test-ext')
await ctx.settings.clear('my-key')
expect(supabase.from).toHaveBeenCalledWith('extension_data')
})
it('settings.clear() throws when supabase returns an error', async () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: null, error: { message: 'permission denied' } })
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'test-ext')
await expect(ctx.settings.clear('my-key')).rejects.toThrow(/extension_data clear failed/)
})
it('storage.getPublicUrl() returns URL string', () => {
const { supabase } = createMockSupabase()
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'test-ext')
+16 -1
View File
@@ -50,7 +50,7 @@ function createSettings(
},
async set<T>(key: string, value: T): Promise<void> {
await supabase
const { error } = await supabase
.from('extension_data')
.upsert(
{
@@ -62,6 +62,21 @@ function createSettings(
},
{ onConflict: 'company_id,extension_id,key' }
)
if (error) {
throw new Error(`extension_data set failed for ${extensionId}/${key}: ${error.message}`)
}
},
async clear(key: string): Promise<void> {
const { error } = await supabase
.from('extension_data')
.delete()
.eq('company_id', companyId)
.eq('extension_id', extensionId)
.eq('key', key)
if (error) {
throw new Error(`extension_data clear failed for ${extensionId}/${key}: ${error.message}`)
}
},
}
}
+6
View File
@@ -149,6 +149,12 @@ export interface ExtensionLogger {
export interface ExtensionSettings {
get<T>(key?: string): Promise<T | null>
set<T>(key: string, value: T): Promise<void>
/**
* Remove a stored key. Use this to clear state instead of `set(key, null)`,
* which fails against the `value jsonb NOT NULL` constraint on extension_data.
* No-op when the key does not exist.
*/
clear(key: string): Promise<void>
}
/** Storage accessor wrapping Supabase storage */
@@ -0,0 +1,146 @@
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;')
})
})
@@ -0,0 +1,412 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue (mirrors vat-declaration.test.ts)
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
import {
generatePeriodiskSammanstallning,
normalizeVatNumber,
reconcilePsAgainstVatDeclaration,
} from '../periodisk-sammanstallning'
import { calculatePeriodDates, formatPeriodLabel } from '../period-dates'
let supabase: ReturnType<typeof makeClient>
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
supabase = makeClient()
})
// ============================================================
// Pure helpers
// ============================================================
describe('calculatePeriodDates', () => {
it('monthly January', () => {
expect(calculatePeriodDates('monthly', 2025, 1)).toEqual({ start: '2025-01-01', end: '2025-01-31' })
})
it('monthly December', () => {
expect(calculatePeriodDates('monthly', 2025, 12)).toEqual({ start: '2025-12-01', end: '2025-12-31' })
})
it('quarterly Q2', () => {
expect(calculatePeriodDates('quarterly', 2025, 2)).toEqual({ start: '2025-04-01', end: '2025-06-30' })
})
it('quarterly Q4', () => {
expect(calculatePeriodDates('quarterly', 2025, 4)).toEqual({ start: '2025-10-01', end: '2025-12-31' })
})
})
describe('formatPeriodLabel', () => {
it('monthly', () => expect(formatPeriodLabel('monthly', 2025, 5)).toBe('Maj 2025'))
it('quarterly', () => expect(formatPeriodLabel('quarterly', 2025, 2)).toBe('Kvartal 2 2025'))
})
describe('normalizeVatNumber', () => {
it('strips Swedish country prefix', () => {
expect(normalizeVatNumber('SE556677889901')).toBe('556677889901')
})
it('strips whitespace and uppercases', () => {
expect(normalizeVatNumber(' de 123456789 ')).toBe('123456789')
})
it('handles EL prefix', () => {
expect(normalizeVatNumber('EL123456789')).toBe('123456789')
})
it('handles already-stripped numbers', () => {
expect(normalizeVatNumber('556677889901')).toBe('556677889901')
})
it('handles null/empty', () => {
expect(normalizeVatNumber(null)).toBe('')
expect(normalizeVatNumber('')).toBe('')
})
})
// ============================================================
// Generator
// ============================================================
interface InvoiceFx {
id: string
customer: {
id: string
name: string
country: string | null
vat_number: string | null
vat_number_validated?: boolean
vat_number_validated_at?: string | null
} | null
}
// Recent validation so VIES_UNVALIDATED warnings don't fire by default.
const RECENT = new Date().toISOString()
function lineEU(account: string, credit: number, sourceId: string) {
return {
account_number: account,
debit_amount: 0,
credit_amount: credit,
journal_entries: {
company_id: 'c1',
entry_date: '2025-05-15',
status: 'posted',
source_type: 'invoice_created',
source_id: sourceId,
},
}
}
function lineCredit(account: string, debit: number, sourceId: string) {
return {
account_number: account,
debit_amount: debit,
credit_amount: 0,
journal_entries: {
company_id: 'c1',
entry_date: '2025-05-20',
status: 'posted',
source_type: 'credit_note',
source_id: sourceId,
},
}
}
function invDE(id = 'inv-de', customer = 'cust-de', name = 'DE Customer', vat = 'DE123456789'): InvoiceFx {
return {
id,
customer: {
id: customer,
name,
country: 'DE',
vat_number: vat,
vat_number_validated: true,
vat_number_validated_at: RECENT,
},
}
}
describe('generatePeriodiskSammanstallning', () => {
it('empty period returns zero rows and zero warnings', async () => {
results = [{ data: [], error: null }]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toEqual([])
expect(report.warnings).toEqual([])
expect(report.totals.rowCount).toBe(0)
expect(report.totals.grand).toBe(0)
expect(report.period.label).toBe('Maj 2025')
})
it('single EU service sale → 1 row, type 3 only', async () => {
results = [
{ data: [lineEU('3308', 10000, 'inv-de')], error: null },
{ data: [invDE()], error: null },
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toHaveLength(1)
expect(report.rows[0]).toMatchObject({
country: 'DE',
vatNumber: '123456789',
services: 10000,
goods: 0,
triangulation: 0,
})
expect(report.totals).toMatchObject({ services: 10000, goods: 0, triangulation: 0, grand: 10000, rowCount: 1 })
expect(report.warnings).toEqual([])
})
it('aggregates multiple invoices to same customer', async () => {
results = [
{
data: [
lineEU('3308', 4000, 'inv1'),
lineEU('3308', 3500, 'inv2'),
lineEU('3308', 2500, 'inv3'),
],
error: null,
},
{
data: [
{ ...invDE('inv1') },
{ ...invDE('inv2') },
{ ...invDE('inv3') },
],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toHaveLength(1)
expect(report.rows[0].services).toBe(10000)
})
it('one customer with both services and goods → 1 row with both filled', async () => {
results = [
{
data: [
lineEU('3308', 7000, 'inv1'),
lineEU('3108', 5000, 'inv2'),
],
error: null,
},
{
data: [invDE('inv1'), invDE('inv2')],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toHaveLength(1)
expect(report.rows[0]).toMatchObject({ services: 7000, goods: 5000, triangulation: 0 })
})
it('credit invoice nets against original in same period', async () => {
results = [
{
data: [
lineEU('3308', 10000, 'inv1'),
lineCredit('3308', 3000, 'cn1'),
],
error: null,
},
{
data: [invDE('inv1'), invDE('cn1')],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toHaveLength(1)
expect(report.rows[0].services).toBe(7000)
})
it('credit fully cancels → row excluded with ZERO_NET_EXCLUDED warning', async () => {
results = [
{
data: [
lineEU('3308', 10000, 'inv1'),
lineCredit('3308', 10000, 'cn1'),
],
error: null,
},
{ data: [invDE('inv1'), invDE('cn1')], error: null },
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toHaveLength(0)
expect(report.warnings.some(w => w.code === 'ZERO_NET_EXCLUDED')).toBe(true)
})
it('customer missing country → MISSING_COUNTRY error and row blocked', async () => {
results = [
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
{
data: [{
id: 'inv1',
customer: { id: 'c1', name: 'No Country', country: null, vat_number: 'DE123', vat_number_validated: true, vat_number_validated_at: RECENT },
}],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.warnings.some(w => w.code === 'MISSING_COUNTRY' && w.level === 'error')).toBe(true)
expect(report.rows[0]?.hasBlockingIssue).toBe(true)
})
it('customer missing vat_number → MISSING_VAT_NUMBER error', async () => {
results = [
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
{
data: [{
id: 'inv1',
customer: { id: 'c1', name: 'No VAT', country: 'DE', vat_number: null, vat_number_validated: false, vat_number_validated_at: null },
}],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.warnings.some(w => w.code === 'MISSING_VAT_NUMBER' && w.level === 'error')).toBe(true)
expect(report.rows[0]?.hasBlockingIssue).toBe(true)
})
it('VAT prefix mismatch surfaces COUNTRY_PREFIX_MISMATCH warning', async () => {
results = [
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
{
data: [{
id: 'inv1',
customer: { id: 'c1', name: 'Mixed', country: 'DE', vat_number: 'FR123456', vat_number_validated: true, vat_number_validated_at: RECENT },
}],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.warnings.some(w => w.code === 'COUNTRY_PREFIX_MISMATCH')).toBe(true)
expect(report.rows).toHaveLength(1)
})
it('non-EU country on EU account → NON_EU_COUNTRY_ON_EU_ACCOUNT and excluded from CSV', async () => {
results = [
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
{
data: [{
id: 'inv1',
customer: { id: 'c1', name: 'US Co', country: 'US', vat_number: 'US123', vat_number_validated: true, vat_number_validated_at: RECENT },
}],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.warnings.some(w => w.code === 'NON_EU_COUNTRY_ON_EU_ACCOUNT')).toBe(true)
expect(report.rows[0].hasBlockingIssue).toBe(true)
})
it('Greek customer → country code emitted as EL', async () => {
results = [
{ data: [lineEU('3308', 4200, 'inv1')], error: null },
{
data: [{
id: 'inv1',
customer: { id: 'c1', name: 'Hellas', country: 'GR', vat_number: 'EL123456', vat_number_validated: true, vat_number_validated_at: RECENT },
}],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows[0].country).toBe('EL')
})
it('goods sold in quarterly period → GOODS_SOLD_WITH_QUARTERLY_PERIOD warning', async () => {
results = [
{ data: [lineEU('3108', 9000, 'inv1')], error: null },
{ data: [{ ...invDE('inv1') }], error: null },
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'quarterly', 2025, 2)
expect(report.warnings.some(w => w.code === 'GOODS_SOLD_WITH_QUARTERLY_PERIOD')).toBe(true)
})
it('sorts rows by country then vat_number', async () => {
results = [
{
data: [
lineEU('3308', 1000, 'inv-fr'),
lineEU('3308', 2000, 'inv-de'),
lineEU('3308', 3000, 'inv-at'),
],
error: null,
},
{
data: [
{ id: 'inv-fr', customer: { id: 'fr', name: 'FR', country: 'FR', vat_number: 'FR999', vat_number_validated: true, vat_number_validated_at: RECENT } },
{ id: 'inv-de', customer: { id: 'de', name: 'DE', country: 'DE', vat_number: 'DE888', vat_number_validated: true, vat_number_validated_at: RECENT } },
{ id: 'inv-at', customer: { id: 'at', name: 'AT', country: 'AT', vat_number: 'ATU111', vat_number_validated: true, vat_number_validated_at: RECENT } },
],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows.map(r => r.country)).toEqual(['AT', 'DE', 'FR'])
})
it('rejects yearly period type', async () => {
await expect(
generatePeriodiskSammanstallning(supabase, 'c1', 'yearly' as 'monthly', 2025, 1),
).rejects.toThrow()
})
})
// ============================================================
// Reconciliation
// ============================================================
describe('reconcilePsAgainstVatDeclaration', () => {
it('returns null matches when periods do not coincide', async () => {
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'quarterly', 2025, 2)
// No data calls expected — function bails before invoking calculateVatDeclaration.
results = []
const reconciled = await reconcilePsAgainstVatDeclaration(supabase, 'c1', report, 'monthly')
expect(reconciled.reconciliation.matches).toBeNull()
})
})
+78
View File
@@ -0,0 +1,78 @@
/**
* Period date helpers shared between report generators (momsdeklaration,
* periodisk sammanställning, etc.). Kept tiny on purpose — anything domain-
* specific belongs in the calling module.
*/
export type PeriodType = 'monthly' | 'quarterly' | 'yearly'
/**
* Calculate inclusive start and end dates for a fiscal-calendar period.
*
* monthly: period 1-12, one calendar month
* quarterly: period 1-4, three calendar months
* yearly: period 1, full calendar year
*/
export function calculatePeriodDates(
periodType: PeriodType,
year: number,
period: number,
): { start: string; end: string } {
let startMonth: number
let endMonth: number
switch (periodType) {
case 'monthly':
startMonth = period
endMonth = period
break
case 'quarterly':
startMonth = (period - 1) * 3 + 1
endMonth = period * 3
break
case 'yearly':
startMonth = 1
endMonth = 12
break
default:
startMonth = 1
endMonth = 12
}
const startDate = new Date(year, startMonth - 1, 1)
const endDate = new Date(year, endMonth, 0)
return {
start: formatDate(startDate),
end: formatDate(endDate),
}
}
export function formatDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
const SWEDISH_MONTHS = [
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
] as const
export function formatPeriodLabel(
periodType: PeriodType,
year: number,
period: number,
): string {
switch (periodType) {
case 'monthly':
return `${SWEDISH_MONTHS[period - 1]} ${year}`
case 'quarterly':
return `Kvartal ${period} ${year}`
case 'yearly':
return `Helår ${year}`
default:
return `${year}`
}
}
@@ -0,0 +1,159 @@
/**
* SKV574008 CSV serializer for periodisk sammanställning.
*
* Format (Skatteverket):
*
* SKV574008;
* {orgnr};{period};{kontaktnamn};{telefon};{email}
* {landskod+vatnr};{tjänster};{varor};{trepartshandel};
* ...
*
* - Semicolon-separated, ISO-8859-1 (Latin-1) encoded, CRLF terminated.
* - Period codes: YYMM for monthly (e.g. "2505"), YY-Q for quarterly
* (e.g. "25-2"). Verified against Skatteverket's current spec.
* - Amounts are whole kronor, signed integers (negative allowed).
* - Empty buckets are blank fields (`;;`), never `0;0`.
* - Avropslager codes X/Y/Z are NOT emitted in v1 — only numeric amounts.
*
* The serializer refuses to build if any row has hasBlockingIssue=true or any
* warning is error-level. Caller (API route) returns 400 to the client.
*/
import type { PeriodiskSammanstallningReport } from './periodisk-sammanstallning'
export interface CsvFilerInfo {
organizationNumber: string // 10 or 12 digits, dashes/spaces stripped
contactName: string
contactPhone: string
contactEmail: string
}
export class PsCsvBuildError extends Error {
constructor(
public readonly reason:
| 'BLOCKING_WARNINGS'
| 'MISSING_FILER_INFO'
| 'INVALID_PERIOD_CODE',
message: string,
) {
super(message)
this.name = 'PsCsvBuildError'
}
}
const PERIOD_MONTHLY_RE = /^\d{2}(0[1-9]|1[0-2])$/
const PERIOD_QUARTERLY_RE = /^\d{2}-[1-4]$/
/**
* Encode the period for the file header.
* monthly: YYMM (e.g. 2505 for May 2025)
* quarterly: YY-Q (e.g. 25-2 for Q2 2025)
*/
export function formatPeriodCode(
type: 'monthly' | 'quarterly',
year: number,
period: number,
): string {
const yy = String(year % 100).padStart(2, '0')
if (type === 'monthly') {
if (period < 1 || period > 12) {
throw new PsCsvBuildError('INVALID_PERIOD_CODE', `Ogiltig månad: ${period}`)
}
return `${yy}${String(period).padStart(2, '0')}`
}
if (period < 1 || period > 4) {
throw new PsCsvBuildError('INVALID_PERIOD_CODE', `Ogiltigt kvartal: ${period}`)
}
return `${yy}-${period}`
}
function normalizeOrgNumber(raw: string): string {
return (raw ?? '').replace(/[^\d]/g, '')
}
function csvField(value: string): string {
// Skatteverket's CSV uses semicolon as separator. Any embedded semicolon or
// newline would corrupt the line; strip them rather than quote (Skatteverket
// does not document a quoting mechanism for SKV574008).
return value.replace(/[;\r\n]/g, ' ').trim()
}
function emptyOrAmount(n: number): string {
if (!Number.isFinite(n) || n === 0) return ''
return String(Math.trunc(n))
}
export function buildPeriodiskSammanstallningCsv(
report: PeriodiskSammanstallningReport,
filer: CsvFilerInfo,
): { filename: string; content: Buffer; mimeType: string } {
// 1. Filer info present?
const orgnr = normalizeOrgNumber(filer.organizationNumber)
if (!orgnr || !filer.contactName?.trim() || !filer.contactPhone?.trim() || !filer.contactEmail?.trim()) {
throw new PsCsvBuildError(
'MISSING_FILER_INFO',
'Kontaktuppgifter för skatterapportering saknas. Fyll i namn, telefon och e-post under Inställningar → Företag.',
)
}
// 2. No blocking warnings?
const blockingErrors = report.warnings.filter(w => w.level === 'error')
if (blockingErrors.length > 0) {
throw new PsCsvBuildError(
'BLOCKING_WARNINGS',
`${blockingErrors.length} blockerande fel måste åtgärdas innan CSV kan laddas ner.`,
)
}
// 3. Period code.
const periodCode = formatPeriodCode(report.period.type, report.period.year, report.period.period)
// Defensive — formatPeriodCode already validates, but double-check via regex.
const re = report.period.type === 'monthly' ? PERIOD_MONTHLY_RE : PERIOD_QUARTERLY_RE
if (!re.test(periodCode)) {
throw new PsCsvBuildError('INVALID_PERIOD_CODE', `Periodkod ${periodCode} matchar inte SKV-format.`)
}
// 4. Assemble lines.
const lines: string[] = []
lines.push('SKV574008;')
lines.push(
[
csvField(orgnr),
csvField(periodCode),
csvField(filer.contactName),
csvField(filer.contactPhone),
csvField(filer.contactEmail),
].join(';'),
)
for (const row of report.rows) {
// Skip rows where all three buckets round to zero (defensive — the
// generator already drops them, but a row could be passed in manually).
if (row.services === 0 && row.goods === 0 && row.triangulation === 0) continue
const vatField = `${row.country}${row.vatNumber}`
lines.push(
[
csvField(vatField),
emptyOrAmount(row.services),
emptyOrAmount(row.goods),
emptyOrAmount(row.triangulation),
'',
].join(';'),
)
}
// CRLF line endings, ISO-8859-1 encoded.
const text = lines.join('\r\n') + '\r\n'
const content = Buffer.from(text, 'latin1')
const periodLabel = report.period.type === 'monthly'
? periodCode // 2505
: periodCode.replace('-', 'Q') // 25Q2 for filename clarity
return {
filename: `Periodisk_sammanstallning_${orgnr}_${periodLabel}.csv`,
content,
mimeType: 'text/csv; charset=iso-8859-1',
}
}
+528
View File
@@ -0,0 +1,528 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { calculatePeriodDates, formatPeriodLabel } from './period-dates'
import { calculateVatDeclaration } from './vat-declaration'
/**
* Periodisk sammanställning (EC Sales List / SKV 5740).
*
* Pure projection from the general ledger: posted journal entry lines on the
* three EU revenue accounts (3308 services, 3108 goods, 3107 triangulation)
* are joined back to the originating invoice and customer to produce one row
* per (country, vat_number) with three amount buckets.
*
* Shares its source data with vat-declaration.ts so the PS totals and the
* momsdeklaration Ruta 35/38/39 can never drift. See §1.2 of the plan.
*
* Notes:
* - Account 3305/3105 (non-EU export) are NOT in this report — they go to
* Ruta 36/40 only.
* - Trepartshandel (3107) is included so the report works if someone posts
* there manually; the invoicing UI doesn't post there today (v2).
* - Avropslager codes X/Y/Z are deferred to v2 — the CSV serializer asserts
* only numeric amounts in v1.
*/
export type PsPeriodType = 'monthly' | 'quarterly'
export interface PsRow {
country: string // 2-char, EL for Grekland
vatNumber: string // normalized, no country prefix
services: number // typ 3 (account 3308), hela kronor
goods: number // typ 1 (account 3108)
triangulation: number // typ 2 (account 3107)
customerId: string | null
customerName: string | null
hasBlockingIssue: boolean
}
export type PsWarningCode =
| 'MISSING_COUNTRY'
| 'MISSING_VAT_NUMBER'
| 'VIES_UNVALIDATED'
| 'COUNTRY_PREFIX_MISMATCH'
| 'NON_EU_COUNTRY_ON_EU_ACCOUNT'
| 'CUSTOMER_NOT_FOUND'
| 'ZERO_NET_EXCLUDED'
| 'GOODS_SOLD_WITH_QUARTERLY_PERIOD'
export interface PsWarning {
level: 'error' | 'warning'
code: PsWarningCode
message: string
customerId?: string
customerName?: string
invoiceId?: string
amount?: number
}
export interface PeriodiskSammanstallningReport {
period: {
type: PsPeriodType
year: number
period: number
start: string
end: string
label: string
}
rows: PsRow[]
warnings: PsWarning[]
totals: {
services: number
goods: number
triangulation: number
grand: number
rowCount: number
}
reconciliation: {
ruta39: number | null
ruta35: number | null
ruta38: number | null
matches: boolean | null
tolerance: number
}
}
/** ISO 3166-1 alpha-2 codes for EU member states (post-Brexit, excl. UK). */
const EU_COUNTRIES = new Set([
'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI',
'FR', 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT',
'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK',
])
/** Skatteverket uses EL for Greece; ISO uses GR. */
function toVatCountryCode(iso: string): string {
return iso === 'GR' ? 'EL' : iso.toUpperCase()
}
const ACCOUNT_TO_BUCKET: Record<string, 'services' | 'goods' | 'triangulation'> = {
'3308': 'services',
'3108': 'goods',
'3107': 'triangulation',
}
const PS_ACCOUNTS = Object.keys(ACCOUNT_TO_BUCKET)
interface RawLine {
account_number: string
debit_amount: number | string
credit_amount: number | string
journal_entries: {
company_id: string
entry_date: string
status: string
source_type: string
source_id: string | null
} | null
}
interface RawInvoice {
id: string
customer_id: string | null
customer: {
id: string
name: string
country: string | null
vat_number: string | null
vat_number_validated: boolean | null
vat_number_validated_at: string | null
} | null
}
/**
* Strip optional leading country prefix and whitespace; uppercase the rest.
*
* Examples:
* "SE556677889901" → "556677889901"
* " de 123456789 " → "123456789"
* "el123" → "123"
*/
export function normalizeVatNumber(raw: string | null | undefined): string {
if (!raw) return ''
const stripped = raw.replace(/\s+/g, '').toUpperCase()
// Skatteverket prefixes are two letters; EL is intentionally treated the same.
if (/^[A-Z]{2}/.test(stripped)) return stripped.slice(2)
return stripped
}
function round(value: number): number {
return Math.round(value)
}
interface Accumulator {
country: string
vatNumber: string
customerId: string | null
customerName: string | null
services: number
goods: number
triangulation: number
blocking: boolean
/** True once we've seen any non-zero posting, even if it later nets to zero. */
sawActivity: boolean
}
export async function generatePeriodiskSammanstallning(
supabase: SupabaseClient,
companyId: string,
periodType: PsPeriodType,
year: number,
period: number,
): Promise<PeriodiskSammanstallningReport> {
if (periodType !== 'monthly' && periodType !== 'quarterly') {
throw new Error(`Invalid PS periodType: ${periodType}`)
}
if (periodType === 'monthly' && (period < 1 || period > 12)) {
throw new Error(`Invalid monthly period: ${period}`)
}
if (periodType === 'quarterly' && (period < 1 || period > 4)) {
throw new Error(`Invalid quarterly period: ${period}`)
}
const { start, end } = calculatePeriodDates(periodType, year, period)
const lines = await fetchAllRows<RawLine>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select(`
account_number,
debit_amount,
credit_amount,
journal_entries!inner (
company_id, entry_date, status, source_type, source_id
)
`)
.in('account_number', PS_ACCOUNTS)
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
// Cash sales on 3308/3108 are not a real flow (EU reverse-charge sales
// always go through AR); excluded to avoid phantom rows.
.in('journal_entries.source_type', ['invoice_created', 'credit_note'])
.gte('journal_entries.entry_date', start)
.lte('journal_entries.entry_date', end)
.range(from, to) as unknown as PromiseLike<{ data: RawLine[] | null; error: { message: string } | null }>,
)
const invoiceIds = Array.from(
new Set(
lines
.map(l => l.journal_entries?.source_id)
.filter((id): id is string => typeof id === 'string'),
),
)
const invoiceMap = new Map<string, RawInvoice>()
if (invoiceIds.length > 0) {
const invoices = await fetchAllRows<RawInvoice>(({ from, to }) =>
supabase
.from('invoices')
.select(`
id,
customer_id,
customer:customers (
id,
name,
country,
vat_number,
vat_number_validated,
vat_number_validated_at
)
`)
.in('id', invoiceIds)
.range(from, to) as unknown as PromiseLike<{ data: RawInvoice[] | null; error: { message: string } | null }>,
)
for (const inv of invoices) invoiceMap.set(inv.id, inv)
}
const accumulators = new Map<string, Accumulator>()
const warnings: PsWarning[] = []
let goodsLineSeen = false
for (const line of lines) {
const je = line.journal_entries
if (!je) continue
const sourceId = je.source_id
const invoice = sourceId ? invoiceMap.get(sourceId) : null
const bucket = ACCOUNT_TO_BUCKET[line.account_number]
if (!bucket) continue
if (bucket === 'goods' || bucket === 'triangulation') goodsLineSeen = true
const debit = Number(line.debit_amount) || 0
const credit = Number(line.credit_amount) || 0
const net = credit - debit
const customer = invoice?.customer ?? null
if (!invoice || !customer) {
warnings.push({
level: 'error',
code: 'CUSTOMER_NOT_FOUND',
message: 'Kund saknas på faktura. Kontakta support innan inlämning.',
invoiceId: invoice?.id,
amount: net,
})
bucketRow(accumulators, '??', '??', null, null, bucket, net, true)
continue
}
const isoCountry = (customer.country ?? '').trim().toUpperCase()
const vatCountry = isoCountry ? toVatCountryCode(isoCountry) : ''
const rawVat = customer.vat_number ?? ''
const normalizedVat = normalizeVatNumber(rawVat)
let blocking = false
if (!isoCountry) {
warnings.push({
level: 'error',
code: 'MISSING_COUNTRY',
message: `Kund "${customer.name}" saknar land. Uppdatera kunden innan CSV laddas ner.`,
customerId: customer.id,
customerName: customer.name,
invoiceId: invoice.id,
amount: net,
})
blocking = true
} else if (!EU_COUNTRIES.has(isoCountry)) {
warnings.push({
level: 'warning',
code: 'NON_EU_COUNTRY_ON_EU_ACCOUNT',
message:
`Konto ${line.account_number} men kund "${customer.name}" i ${isoCountry} ` +
'är inte EU-land. Kontrollera bokföringen.',
customerId: customer.id,
customerName: customer.name,
invoiceId: invoice.id,
amount: net,
})
blocking = true
}
if (!normalizedVat) {
warnings.push({
level: 'error',
code: 'MISSING_VAT_NUMBER',
message: `Kund "${customer.name}" saknar VAT-nummer.`,
customerId: customer.id,
customerName: customer.name,
invoiceId: invoice.id,
amount: net,
})
blocking = true
} else {
// VAT prefix check — if the raw VAT-number starts with a country code,
// it must match the customer.country. Skatteverket uses EL for Greece.
const rawUpper = rawVat.replace(/\s+/g, '').toUpperCase()
const prefixMatch = rawUpper.match(/^([A-Z]{2})/)
if (prefixMatch && isoCountry) {
const expected = toVatCountryCode(isoCountry)
if (prefixMatch[1] !== expected) {
warnings.push({
level: 'warning',
code: 'COUNTRY_PREFIX_MISMATCH',
message:
`VAT-nr för "${customer.name}" har prefix ${prefixMatch[1]} men ` +
`kunden är registrerad i ${isoCountry}.`,
customerId: customer.id,
customerName: customer.name,
invoiceId: invoice.id,
})
}
}
const validatedAt = customer.vat_number_validated_at
const stale = validatedAt
? (Date.now() - new Date(validatedAt).getTime()) / (1000 * 60 * 60 * 24) > 30
: true
if (!customer.vat_number_validated || stale) {
warnings.push({
level: 'warning',
code: 'VIES_UNVALIDATED',
message:
`Kund "${customer.name}" är inte VIES-validerad (eller validering äldre än 30 dagar). ` +
'Verifiera mot Skatteverkets VIES-tjänst.',
customerId: customer.id,
customerName: customer.name,
})
}
}
bucketRow(
accumulators,
vatCountry || isoCountry || '??',
normalizedVat || '??',
customer.id,
customer.name,
bucket,
net,
blocking,
)
}
// Goods-sold-with-quarterly-period — blocking under SFL 35 kap. 2 §.
// Companies selling goods intra-EU must file PS monthly; a quarterly filing
// is structurally non-compliant and must not be exportable as CSV.
if (goodsLineSeen && periodType === 'quarterly') {
warnings.push({
level: 'error',
code: 'GOODS_SOLD_WITH_QUARTERLY_PERIOD',
message:
'Du har varuförsäljning i perioden. Periodisk sammanställning för varor ska ' +
'rapporteras månadsvis (35 kap. 2 § SFL). Byt period eller kontakta Skatteverket.',
})
}
// Round, drop zero rows, sort.
const rows: PsRow[] = []
for (const acc of accumulators.values()) {
const services = round(acc.services)
const goods = round(acc.goods)
const triangulation = round(acc.triangulation)
if (services === 0 && goods === 0 && triangulation === 0) {
// Emit a warning only if there was actual rörelse (a credit note nets
// services back to zero — final values are 0 but we saw activity).
if (acc.sawActivity) {
warnings.push({
level: 'warning',
code: 'ZERO_NET_EXCLUDED',
message:
`Kund "${acc.customerName ?? acc.vatNumber}" nettar till 0 kr för perioden ` +
'(kreditfaktura tar ut original). Exkluderad från filen.',
customerId: acc.customerId ?? undefined,
customerName: acc.customerName ?? undefined,
})
}
continue
}
rows.push({
country: acc.country,
vatNumber: acc.vatNumber,
services,
goods,
triangulation,
customerId: acc.customerId,
customerName: acc.customerName,
hasBlockingIssue: acc.blocking,
})
}
rows.sort((a, b) => {
if (a.country !== b.country) return a.country.localeCompare(b.country)
return a.vatNumber.localeCompare(b.vatNumber)
})
const totals = {
services: rows.reduce((s, r) => s + r.services, 0),
goods: rows.reduce((s, r) => s + r.goods, 0),
triangulation: rows.reduce((s, r) => s + r.triangulation, 0),
grand: 0,
rowCount: rows.length,
}
totals.grand = totals.services + totals.goods + totals.triangulation
return {
period: {
type: periodType,
year,
period,
start,
end,
label: formatPeriodLabel(periodType, year, period),
},
rows,
warnings,
totals,
reconciliation: {
ruta39: null,
ruta35: null,
ruta38: null,
matches: null,
tolerance: Math.max(1, Math.ceil(rows.length / 2)),
},
}
}
function bucketRow(
map: Map<string, Accumulator>,
country: string,
vatNumber: string,
customerId: string | null,
customerName: string | null,
bucket: 'services' | 'goods' | 'triangulation',
amount: number,
blocking: boolean,
): void {
const key = `${country}|${vatNumber}|${customerId ?? ''}`
let acc = map.get(key)
if (!acc) {
acc = {
country,
vatNumber,
customerId,
customerName,
services: 0,
goods: 0,
triangulation: 0,
blocking: false,
sawActivity: false,
}
map.set(key, acc)
}
acc[bucket] += amount
if (amount !== 0) acc.sawActivity = true
if (blocking) acc.blocking = true
}
/**
* Cross-check PS totals against momsdeklaration Ruta 35/38/39.
*
* Only meaningful when the PS period coincides with the momsdeklaration period.
* Returns the report with reconciliation populated; matches=null indicates the
* caller asked for a check that doesn't make sense (different periods).
*/
export async function reconcilePsAgainstVatDeclaration(
supabase: SupabaseClient,
companyId: string,
report: PeriodiskSammanstallningReport,
momsPeriod: 'monthly' | 'quarterly' | 'yearly' | null,
): Promise<PeriodiskSammanstallningReport> {
// Reconciliation only valid when periods coincide. Yearly is never valid for PS.
const periodsCoincide =
(report.period.type === 'monthly' && momsPeriod === 'monthly') ||
(report.period.type === 'quarterly' && momsPeriod === 'quarterly')
if (!periodsCoincide) {
return report
}
const vat = await calculateVatDeclaration(
supabase,
companyId,
report.period.type,
report.period.year,
report.period.period,
)
const ruta35 = Math.round(vat.rutor.ruta35)
const ruta38 = Math.round(vat.rutor.ruta38 ?? 0)
const ruta39 = Math.round(vat.rutor.ruta39)
const tolerance = report.reconciliation.tolerance
const matches =
Math.abs(report.totals.services - ruta39) <= tolerance &&
Math.abs(report.totals.goods - ruta35) <= tolerance &&
Math.abs(report.totals.triangulation - ruta38) <= tolerance
return {
...report,
reconciliation: {
ruta39,
ruta35,
ruta38,
matches,
tolerance,
},
}
}
export { formatPeriodLabel } from './period-dates'