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:
co-authored by
Claude Opus 4.7
parent
ba45b8f661
commit
c8461397c8
@@ -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)
|
||||
*
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user