diff --git a/CLAUDE.md b/CLAUDE.md index 3572f86a..46e598c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# CLAUDE.md — gnubok +# CLAUDE.md — Gnubok ## Project Overview diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index e910d819..031a25a3 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -31,6 +31,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i const [showCorrection, setShowCorrection] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [isDeleting, setIsDeleting] = useState(false) + const [isCommitting, setIsCommitting] = useState(false) const [isLastInSeries, setIsLastInSeries] = useState(false) const [attachmentCount, setAttachmentCount] = useState(0) const [editingNotes, setEditingNotes] = useState(false) @@ -79,6 +80,28 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i } }, [id, toast]) + const handleCommit = useCallback(async () => { + setIsCommitting(true) + try { + const res = await fetch(`/api/bookkeeping/journal-entries/${id}/commit`, { method: 'POST' }) + const result = await res.json() + if (res.ok) { + const posted = result.data + toast({ + title: 'Verifikat bokfört', + description: `Verifikat ${posted?.voucher_series ?? ''}${posted?.voucher_number ?? ''} har bokförts.`, + }) + await fetchData() + } else { + toast({ title: 'Kunde inte bokföra', description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' }) + } + } catch { + toast({ title: 'Kunde inte bokföra verifikat', variant: 'destructive' }) + } finally { + setIsCommitting(false) + } + }, [id, toast, fetchData]) + const handleDelete = useCallback(async () => { setIsDeleting(true) try { @@ -184,6 +207,18 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i {(entry.status === 'posted' || entry.status === 'draft') && (
+ {entry.status === 'draft' && ( + + )} {(entry.status === 'draft' || isLastInSeries) && ( - + {!watchedPaidPrivately && ( + + )} +
+ + +
{(!description || !selectedPeriod || isUploading || periodMismatch || incompleteLineCount > 0 || (!isBalanced && submittableLines.length < 2)) && (
{!description &&

Ange en beskrivning

} diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 7e95b602..b41c4a07 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' -import { ChevronDown, ChevronRight, Paperclip, AlertTriangle, Loader2, BookOpen, X, Copy } from 'lucide-react' +import { ChevronDown, ChevronRight, Paperclip, AlertTriangle, Loader2, BookOpen, X, Copy, Lock } from 'lucide-react' import { formatDate } from '@/lib/utils' import { Input } from '@/components/ui/input' import { AccountNumber } from '@/components/ui/account-number' @@ -17,6 +17,9 @@ import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions' import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments' import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog' import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge' +import { useToast } from '@/components/ui/use-toast' +import { useCanWrite } from '@/lib/hooks/use-can-write' +import { getErrorMessage } from '@/lib/errors/get-error-message' import type { JournalEntry, JournalEntryLine } from '@/types' const NEEDS_ATTACHMENT = new Set([ @@ -34,7 +37,10 @@ interface Props { export default function JournalEntryList({ periodId }: Props) { const router = useRouter() + const { toast } = useToast() + const { canWrite } = useCanWrite() const [entries, setEntries] = useState([]) + const [committingId, setCommittingId] = useState(null) const [loading, setLoading] = useState(true) const [expandedId, setExpandedId] = useState(null) const [count, setCount] = useState(0) @@ -142,6 +148,28 @@ export default function JournalEntryList({ periodId }: Props) { setExpandedId(expandedId === id ? null : id) } + const handleCommit = async (entryId: string) => { + setCommittingId(entryId) + try { + const res = await fetch(`/api/bookkeeping/journal-entries/${entryId}/commit`, { method: 'POST' }) + const result = await res.json() + if (res.ok) { + const posted = result.data + toast({ + title: 'Verifikat bokfört', + description: `Verifikat ${posted?.voucher_series ?? ''}${posted?.voucher_number ?? ''} har bokförts.`, + }) + await fetchEntries() + } else { + toast({ title: 'Kunde inte bokföra', description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' }) + } + } catch { + toast({ title: 'Kunde inte bokföra verifikat', variant: 'destructive' }) + } finally { + setCommittingId(null) + } + } + if (loading) { return ( @@ -443,6 +471,18 @@ export default function JournalEntryList({ periodId }: Props) { />
+ {entry.status === 'draft' && ( + + )} diff --git a/components/reports/PeriodiskSammanstallningView.tsx b/components/reports/PeriodiskSammanstallningView.tsx new file mode 100644 index 00000000..48a90bcc --- /dev/null +++ b/components/reports/PeriodiskSammanstallningView.tsx @@ -0,0 +1,402 @@ +'use client' + +import { useState, useEffect } from 'react' +import Link from 'next/link' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Label } from '@/components/ui/label' +import { EmptyState } from '@/components/ui/empty-state' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableHeader, + TableHead, + TableRow, + TableCell, + TableBody, +} from '@/components/ui/table' +import { Download, AlertCircle, AlertTriangle, FileText, ExternalLink } from 'lucide-react' +import { formatCurrency } from '@/lib/utils' +import type { + PeriodiskSammanstallningReport, + PsPeriodType, + PsWarning, +} from '@/lib/reports/periodisk-sammanstallning' + +function formatAmount(amount: number): string { + // Hela kronor — SKV 5740 har inga öre. + return amount.toLocaleString('sv-SE', { maximumFractionDigits: 0 }) +} + +function typeBadge(row: { services: number; goods: number; triangulation: number }) { + const types: string[] = [] + if (row.services !== 0) types.push('Tjänster') + if (row.goods !== 0) types.push('Varor') + if (row.triangulation !== 0) types.push('Trepart') + return types.join(' + ') || '—' +} + +function deadlineLabel(end: string): string { + // Inlämnas digitalt senast den 25:e månaden efter perioden. + const endDate = new Date(end) + const deadline = new Date(endDate.getFullYear(), endDate.getMonth() + 1, 25) + const months = [ + 'januari', 'februari', 'mars', 'april', 'maj', 'juni', + 'juli', 'augusti', 'september', 'oktober', 'november', 'december', + ] + return `${deadline.getDate()} ${months[deadline.getMonth()]} ${deadline.getFullYear()}` +} + +export function PeriodiskSammanstallningView() { + const currentYear = new Date().getFullYear() + const currentMonth = new Date().getMonth() + 1 + const currentQuarter = Math.ceil(currentMonth / 3) + + const [periodType, setPeriodType] = useState('quarterly') + const [year, setYear] = useState(currentYear) + const [period, setPeriod] = useState(currentQuarter) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i) + + const periodOptions = periodType === 'monthly' + ? [ + { value: 1, label: 'Januari' }, { value: 2, label: 'Februari' }, + { value: 3, label: 'Mars' }, { value: 4, label: 'April' }, + { value: 5, label: 'Maj' }, { value: 6, label: 'Juni' }, + { value: 7, label: 'Juli' }, { value: 8, label: 'Augusti' }, + { value: 9, label: 'September' }, { value: 10, label: 'Oktober' }, + { value: 11, label: 'November' }, { value: 12, label: 'December' }, + ] + : [ + { value: 1, label: 'Kvartal 1 (jan-mar)' }, + { value: 2, label: 'Kvartal 2 (apr-jun)' }, + { value: 3, label: 'Kvartal 3 (jul-sep)' }, + { value: 4, label: 'Kvartal 4 (okt-dec)' }, + ] + + useEffect(() => { + setPeriod(periodType === 'monthly' ? currentMonth : currentQuarter) + }, [periodType, currentMonth, currentQuarter]) + + const fetchReport = async () => { + setLoading(true) + setError(null) + try { + const res = await fetch( + `/api/reports/periodisk-sammanstallning?periodType=${periodType}&year=${year}&period=${period}`, + ) + const result = await res.json() + if (result.error) { + setError(typeof result.error === 'string' ? result.error : result.error.message_sv ?? 'Något gick fel.') + } else { + setData(result.data) + } + } catch { + setError('Kunde inte hämta periodisk sammanställning.') + } finally { + setLoading(false) + } + } + + const downloadCsv = () => { + window.open( + `/api/reports/periodisk-sammanstallning/csv?periodType=${periodType}&year=${year}&period=${period}`, + '_blank', + ) + } + + const errors = data?.warnings.filter(w => w.level === 'error') ?? [] + const cautions = data?.warnings.filter(w => w.level === 'warning') ?? [] + const hasBlockingErrors = errors.length > 0 + + return ( +
+ {/* Period selection */} + + + Välj period + + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + {error && ( + + + +
{error}
+
+
+ )} + + {loading && ( + + + + + + + )} + + {data && !loading && ( + <> + {/* Summary */} + + +
+ + Periodisk sammanställning — {data.period.label} + + {data.totals.rowCount > 0 && ( + + {data.totals.rowCount} {data.totals.rowCount === 1 ? 'rad' : 'rader'} + + )} +
+
+ +
+
+
Tjänster (typ 3)
+
+ {formatAmount(data.totals.services)} kr +
+
+
+
Varor (typ 1)
+
+ {formatAmount(data.totals.goods)} kr +
+
+
+
Trepart (typ 2)
+
+ {formatAmount(data.totals.triangulation)} kr +
+
+
+ + {/* Reconciliation */} + {data.reconciliation.matches !== null && data.totals.rowCount > 0 && ( +
+ {data.reconciliation.matches ? ( + + ✓ Stämmer mot momsdeklarationen + {' '}(Ruta 39: {formatAmount(data.reconciliation.ruta39 ?? 0)} kr, + {' '}Ruta 35: {formatAmount(data.reconciliation.ruta35 ?? 0)} kr, + {' '}Ruta 38: {formatAmount(data.reconciliation.ruta38 ?? 0)} kr) + + ) : ( + + ⚠ Avviker från momsdeklarationen — kontrollera bokföringen. + {' '}Ruta 39: {formatAmount(data.reconciliation.ruta39 ?? 0)} kr, + {' '}Ruta 35: {formatAmount(data.reconciliation.ruta35 ?? 0)} kr, + {' '}Ruta 38: {formatAmount(data.reconciliation.ruta38 ?? 0)} kr. + + )} +
+ )} + {data.reconciliation.matches === null && data.totals.rowCount > 0 && ( +
+ Avstämning mot momsdeklarationen tillgänglig endast när PS-perioden sammanfaller med momsperioden. +
+ )} + + {/* Deadline banner */} + {data.totals.rowCount > 0 && ( +
+ Inlämnas digitalt senast den {deadlineLabel(data.period.end)} + {' '}via e-tjänsten hos Skatteverket. Förseningsavgift: 1 250 kr. +
+ )} +
+
+ + {/* Errors */} + {errors.length > 0 && ( + + + + + {errors.length} {errors.length === 1 ? 'fel måste' : 'fel måste'} åtgärdas + + + + {errors.map((w, i) => )} +
+ CSV-nedladdning är blockerad tills alla fel åtgärdats. +
+
+
+ )} + + {/* Warnings */} + {cautions.length > 0 && ( + + + + + Varningar ({cautions.length}) + + + + {cautions.map((w, i) => )} + + + )} + + {/* Table */} + {data.rows.length > 0 ? ( + + + Rader + + + + + + + Land + VAT-nummer + Tjänster + Varor + Trepart + Typ + Kund + + + + {data.rows.map((row, i) => ( + + {row.country} + {row.vatNumber} + + {row.services !== 0 ? formatAmount(row.services) : '—'} + + + {row.goods !== 0 ? formatAmount(row.goods) : '—'} + + + {row.triangulation !== 0 ? formatAmount(row.triangulation) : '—'} + + {typeBadge(row)} + + {row.customerId + ? {row.customerName ?? '—'} + : — + } + + + ))} + + Totalt + {formatAmount(data.totals.services)} + {formatAmount(data.totals.goods)} + {formatAmount(data.totals.triangulation)} + + + +
+
+
+ ) : ( + + + Läs mer hos Skatteverket + + + + )} + + )} +
+ ) +} + +function WarningItem({ warning }: { warning: PsWarning }) { + return ( +
+ • +
+
{warning.message}
+ {(warning.customerId || warning.invoiceId) && ( +
+ {warning.customerId && ( + + Öppna kund + + )} + {warning.invoiceId && ( + + Öppna faktura + + )} + {warning.amount !== undefined && ( + {formatCurrency(warning.amount)} + )} +
+ )} +
+
+ ) +} diff --git a/components/reports/ReportsNav.tsx b/components/reports/ReportsNav.tsx index 00e11df6..a63f379e 100644 --- a/components/reports/ReportsNav.tsx +++ b/components/reports/ReportsNav.tsx @@ -43,6 +43,7 @@ const CATEGORIES: ReportCategory[] = [ label: 'Skatt & moms', items: [ { value: 'vat-declaration', label: 'Momsdeklaration' }, + { value: 'periodisk-sammanstallning', label: 'Periodisk sammanställning' }, { value: 'ne-declaration', label: 'NE-bilaga', entityType: 'enskild_firma' }, { value: 'ink2-declaration', label: 'INK2', entityType: 'aktiebolag' }, ], diff --git a/components/settings/SettingsFormWrapper.tsx b/components/settings/SettingsFormWrapper.tsx index f813e6f0..cba4c399 100644 --- a/components/settings/SettingsFormWrapper.tsx +++ b/components/settings/SettingsFormWrapper.tsx @@ -56,6 +56,21 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor const result = await response.json() if (!response.ok) { + // Surface the specific Zod field message when the API sent a + // validation_error envelope — generic "Validation failed" is useless + // to the user. + if ( + result?.type === 'validation_error' + && Array.isArray(result.errors) + && result.errors.length > 0 + ) { + const messages = result.errors + .map((e: { message?: string }) => e.message) + .filter((m: unknown): m is string => typeof m === 'string' && m.length > 0) + if (messages.length > 0) { + throw new Error(messages.join(' • ')) + } + } throw new Error(result.error || 'Kunde inte spara inställningar') } diff --git a/components/settings/TaxSettingsForm.tsx b/components/settings/TaxSettingsForm.tsx index 108285b1..1e939078 100644 --- a/components/settings/TaxSettingsForm.tsx +++ b/components/settings/TaxSettingsForm.tsx @@ -107,11 +107,72 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) { Enligt beslut från Skatteverket.

+ +
+ + +

+ Varuförsäljning till EU ska normalt rapporteras månadsvis (35 kap. 2 § SFL). + Kvartal kräver tillstånd från Skatteverket och gäller endast tjänsteförsäljning. +

+
)}
+ {/* Tax contact — required for SKV-filings */} +
+

+ Kontaktperson för skatteärenden +

+

+ Används som avsändare på filer till Skatteverket (periodisk sammanställning m.m.). +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ {/* Fiscal year & salaries */}

diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index bec3079b..231bf8ed 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -1094,8 +1094,8 @@ export const arcimMigrationExtension: Extension = { await deleteConsent(consentId) if (ctx?.settings) { - await ctx.settings.set('consent_id', null) - await ctx.settings.set('provider', null) + await ctx.settings.clear('consent_id') + await ctx.settings.clear('provider') } return NextResponse.json({ success: true }) diff --git a/extensions/general/cloud-backup/index.ts b/extensions/general/cloud-backup/index.ts index b24195f9..7c45b2d6 100644 --- a/extensions/general/cloud-backup/index.ts +++ b/extensions/general/cloud-backup/index.ts @@ -151,9 +151,9 @@ export const cloudBackupExtension: Extension = { ctx.log.warn('token revoke failed (continuing)', err) } } - await ctx.settings.set(CONNECTION_KEY, null) - await ctx.settings.set(LAST_SYNC_KEY, null) - await ctx.settings.set(SCHEDULE_KEY, null) + await ctx.settings.clear(CONNECTION_KEY) + await ctx.settings.clear(LAST_SYNC_KEY) + await ctx.settings.clear(SCHEDULE_KEY) return NextResponse.json({ ok: true }) } catch (err) { ctx.log.error('disconnect failed', err) diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index 0f896d3b..8ed49e9f 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -125,7 +125,7 @@ export const skatteverketExtension: Extension = { await ctx.settings.set('oauth_redirect_uri', redirectUri) await ctx.settings.set('oauth_code_verifier', pkce.verifier) if (returnTo) await ctx.settings.set('oauth_return_to', returnTo) - else await ctx.settings.set('oauth_return_to', null) + else await ctx.settings.clear('oauth_return_to') const authorizeUrl = buildAuthorizeUrl(redirectUri, state, { codeChallenge: pkce.challenge, @@ -540,7 +540,7 @@ export const skatteverketExtension: Extension = { ) } - await ctx.settings.set(`submission_${redovisningsperiod}`, null) + await ctx.settings.clear(`submission_${redovisningsperiod}`) return NextResponse.json({ success: true }) } catch (err) { return handleSkvError(err) @@ -974,7 +974,7 @@ export const skatteverketExtension: Extension = { // showing `underlag_submitted` for an inlamning that no longer // exists at SKV. Direct path: caller passed period. if (period) { - await ctx.settings.set(`agi_submission_${period}`, null) + await ctx.settings.clear(`agi_submission_${period}`) } else { // Fallback: find the period by matching inlamningId across // recent submission keys. Cheap because there's at most one @@ -1037,7 +1037,7 @@ export const skatteverketExtension: Extension = { { status: result.status }, ) } - await ctx.settings.set(`agi_submission_${period}`, null) + await ctx.settings.clear(`agi_submission_${period}`) return NextResponse.json({ success: true }) } catch (err) { return handleSkvError(err) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index c8c4da0f..700e9c93 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), diff --git a/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts b/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts index 4d3f2c7b..8c0d8676 100644 --- a/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts +++ b/lib/bookkeeping/__tests__/supplier-invoice-entries.test.ts @@ -83,6 +83,7 @@ const { createSupplierInvoicePaymentEntry, createSupplierInvoiceCashEntry, createSupplierCreditNoteEntry, + createSupplierInvoicePrivatelyPaidEntry, } = await import('../supplier-invoice-entries') function makeItem(overrides: Partial = {}): 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) + }) +}) diff --git a/lib/bookkeeping/supplier-invoice-entries.ts b/lib/bookkeeping/supplier-invoice-entries.ts index 2f952351..e6b1e052 100644 --- a/lib/bookkeeping/supplier-invoice-entries.ts +++ b/lib/bookkeeping/supplier-invoice-entries.ts @@ -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 { + 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() + 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) * diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index ba06e62e..4a7d0876 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -696,6 +696,44 @@ const VAT_REPORT: Record = { }, } +const PS_REPORT: Record = { + 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 = { SIE_EXPORT_COMPANY_NOT_FOUND: { httpStatus: 404, @@ -1167,6 +1205,11 @@ const SUPPLIER_INVOICE_WAVE4: Record = { 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 = { ...FX, ...REPORT, ...VAT_REPORT, + ...PS_REPORT, ...SIE_EXPORT, ...TAX_DECL, ...SIE_IMPORT, diff --git a/lib/extensions/__tests__/context-factory.test.ts b/lib/extensions/__tests__/context-factory.test.ts index 5e967e7f..88b4e777 100644 --- a/lib/extensions/__tests__/context-factory.test.ts +++ b/lib/extensions/__tests__/context-factory.test.ts @@ -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') diff --git a/lib/extensions/context-factory.ts b/lib/extensions/context-factory.ts index 12de1fe6..1edd647e 100644 --- a/lib/extensions/context-factory.ts +++ b/lib/extensions/context-factory.ts @@ -50,7 +50,7 @@ function createSettings( }, async set(key: string, value: T): Promise { - 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 { + 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}`) + } }, } } diff --git a/lib/extensions/types.ts b/lib/extensions/types.ts index c29adefb..40f193f5 100644 --- a/lib/extensions/types.ts +++ b/lib/extensions/types.ts @@ -149,6 +149,12 @@ export interface ExtensionLogger { export interface ExtensionSettings { get(key?: string): Promise set(key: string, value: T): Promise + /** + * 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 } /** Storage accessor wrapping Supabase storage */ diff --git a/lib/reports/__tests__/periodisk-sammanstallning-csv.test.ts b/lib/reports/__tests__/periodisk-sammanstallning-csv.test.ts new file mode 100644 index 00000000..03446d1e --- /dev/null +++ b/lib/reports/__tests__/periodisk-sammanstallning-csv.test.ts @@ -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 { + 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;') + }) +}) diff --git a/lib/reports/__tests__/periodisk-sammanstallning.test.ts b/lib/reports/__tests__/periodisk-sammanstallning.test.ts new file mode 100644 index 00000000..9f0afd6a --- /dev/null +++ b/lib/reports/__tests__/periodisk-sammanstallning.test.ts @@ -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 = {} + 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 + +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() + }) +}) diff --git a/lib/reports/period-dates.ts b/lib/reports/period-dates.ts new file mode 100644 index 00000000..2cb6828a --- /dev/null +++ b/lib/reports/period-dates.ts @@ -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}` + } +} diff --git a/lib/reports/periodisk-sammanstallning-csv.ts b/lib/reports/periodisk-sammanstallning-csv.ts new file mode 100644 index 00000000..2bb47752 --- /dev/null +++ b/lib/reports/periodisk-sammanstallning-csv.ts @@ -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', + } +} diff --git a/lib/reports/periodisk-sammanstallning.ts b/lib/reports/periodisk-sammanstallning.ts new file mode 100644 index 00000000..6b1a3d8d --- /dev/null +++ b/lib/reports/periodisk-sammanstallning.ts @@ -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 = { + '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 { + 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(({ 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() + if (invoiceIds.length > 0) { + const invoices = await fetchAllRows(({ 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() + 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, + 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 { + // 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' diff --git a/supabase/migrations/20260423140000_ai_requests.sql b/supabase/migrations/20260423140000_ai_requests.sql index 33771eb2..25516f1a 100644 --- a/supabase/migrations/20260423140000_ai_requests.sql +++ b/supabase/migrations/20260423140000_ai_requests.sql @@ -62,14 +62,18 @@ CREATE INDEX IF NOT EXISTS idx_ai_requests_subject -- RLS: company-scoped using user_company_ids() ALTER TABLE public.ai_requests ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS "ai_requests_select" ON public.ai_requests; CREATE POLICY "ai_requests_select" ON public.ai_requests FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); +DROP POLICY IF EXISTS "ai_requests_insert" ON public.ai_requests; CREATE POLICY "ai_requests_insert" ON public.ai_requests FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids())); +DROP POLICY IF EXISTS "ai_requests_update" ON public.ai_requests; CREATE POLICY "ai_requests_update" ON public.ai_requests FOR UPDATE USING (company_id IN (SELECT public.user_company_ids())); -- updated_at trigger +DROP TRIGGER IF EXISTS ai_requests_updated_at ON public.ai_requests; CREATE TRIGGER ai_requests_updated_at BEFORE UPDATE ON public.ai_requests FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); diff --git a/supabase/migrations/20260513120001_extension_data_delete_policy.sql b/supabase/migrations/20260513120001_extension_data_delete_policy.sql new file mode 100644 index 00000000..4961608f --- /dev/null +++ b/supabase/migrations/20260513120001_extension_data_delete_policy.sql @@ -0,0 +1,13 @@ +-- The 2026-03-30 multi-tenant refactor (20260330130000) dropped all policies on +-- extension_data and recreated only SELECT / INSERT / UPDATE — leaving DELETE +-- without a policy. Combined with `value jsonb NOT NULL`, this silently broke +-- every extension that tried to clear stored state via upsert with value=null +-- (cloud-backup disconnect, skatteverket OAuth/AGI cleanup, arcim-migration +-- consent reset). The fix is to expose a real DELETE on the row and have +-- ExtensionSettings.clear() use it. + +DROP POLICY IF EXISTS "extension_data_delete" ON public.extension_data; +CREATE POLICY "extension_data_delete" ON public.extension_data + FOR DELETE USING (company_id IN (SELECT public.user_company_ids())); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260513170000_periodisk_sammanstallning.sql b/supabase/migrations/20260513170000_periodisk_sammanstallning.sql new file mode 100644 index 00000000..ac3ca22d --- /dev/null +++ b/supabase/migrations/20260513170000_periodisk_sammanstallning.sql @@ -0,0 +1,42 @@ +-- Add fields to company_settings needed for periodisk sammanställning (SKV 5740). +-- +-- periodisk_sammanstallning_period: +-- Separate from moms_period because: +-- 1. PS does not support 'yearly' (only monthly or quarterly). +-- 2. Goods sellers must report monthly even if VAT is quarterly +-- (35 kap. 2 § SFL). +-- 3. Services-only sellers can apply for quarterly independently. +-- +-- tax_contact_*: +-- The SKV574008 file header requires contact person + phone + email of the +-- declarant. Stored on company_settings so it is reusable for future +-- Skatteverket filings (e.g. AGI, momsdeklaration export). + +ALTER TABLE public.company_settings + ADD COLUMN IF NOT EXISTS periodisk_sammanstallning_period text, + ADD COLUMN IF NOT EXISTS tax_contact_name text, + ADD COLUMN IF NOT EXISTS tax_contact_phone text, + ADD COLUMN IF NOT EXISTS tax_contact_email text; + +-- Backfill: monthly default for everyone except quarterly VAT-payers. +UPDATE public.company_settings + SET periodisk_sammanstallning_period = CASE + WHEN moms_period = 'quarterly' THEN 'quarterly' + ELSE 'monthly' + END + WHERE periodisk_sammanstallning_period IS NULL; + +ALTER TABLE public.company_settings + ALTER COLUMN periodisk_sammanstallning_period SET DEFAULT 'monthly'; + +ALTER TABLE public.company_settings + ALTER COLUMN periodisk_sammanstallning_period SET NOT NULL; + +ALTER TABLE public.company_settings + DROP CONSTRAINT IF EXISTS company_settings_ps_period_check; + +ALTER TABLE public.company_settings + ADD CONSTRAINT company_settings_ps_period_check + CHECK (periodisk_sammanstallning_period IN ('monthly', 'quarterly')); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260513170001_supplier_invoice_paid_with_private_funds.sql b/supabase/migrations/20260513170001_supplier_invoice_paid_with_private_funds.sql new file mode 100644 index 00000000..cb3c7c10 --- /dev/null +++ b/supabase/migrations/20260513170001_supplier_invoice_paid_with_private_funds.sql @@ -0,0 +1,49 @@ +-- Migration: add paid_with_private_funds flag to supplier_invoices +-- +-- Marks a supplier invoice / kvitto that the owner paid out-of-pocket with +-- private funds, so the company owes them back (eget utlägg). +-- +-- When this flag is set at creation time the registration path bypasses the +-- normal AP (2440) leg entirely and books the expense directly against +-- shareholder/owner accounts: +-- AB → credit 2893 (Skulder till närstående personer, kortfristig del) +-- EF → credit 2018 (Övriga egna insättningar) +-- +-- The invoice is created with status='paid' from the start; the regular +-- mark-paid flow stays unreachable for these rows (existing status guard in +-- /api/supplier-invoices/[id]/mark-paid already rejects status='paid'). +-- +-- BFL 5 kap verifikationskrav is unchanged — the invoice row still acts as +-- the underlying business document; only the journal-entry shape differs. +-- Retroactively flipping the flag on existing rows is not supported in v1 +-- (the original AP journal entry is immutable per migration 017 trigger). + +ALTER TABLE public.supplier_invoices + ADD COLUMN IF NOT EXISTS paid_with_private_funds boolean NOT NULL DEFAULT false; + +-- Partial index: most invoices are AP-flow, only a minority are private +-- utlägg. The reports we'll likely build (e.g. "what does the company owe +-- the owner") need to find these fast. +CREATE INDEX IF NOT EXISTS idx_supplier_invoices_private_funds + ON public.supplier_invoices (company_id, invoice_date) + WHERE paid_with_private_funds = true; + +-- Expand journal_entries.source_type CHECK to include the new entry type. +-- See 20260304075837 for the previous expansion pattern. +ALTER TABLE public.journal_entries + DROP CONSTRAINT IF EXISTS journal_entries_source_type_check; + +ALTER TABLE public.journal_entries + ADD CONSTRAINT journal_entries_source_type_check + CHECK (source_type IN ( + 'manual', 'bank_transaction', 'invoice_created', + 'invoice_paid', 'invoice_cash_payment', 'credit_note', 'salary_payment', + 'opening_balance', 'year_end', + 'storno', 'correction', 'import', 'system', + 'supplier_invoice_registered', 'supplier_invoice_paid', + 'supplier_invoice_cash_payment', 'supplier_credit_note', + 'currency_revaluation', + 'supplier_invoice_privately_paid' + )); + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/helpers.ts b/tests/helpers.ts index 8b00caac..3e74174d 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -478,6 +478,7 @@ export function makeSupplierInvoice( payment_journal_entry_id: null, transaction_id: null, document_id: null, + paid_with_private_funds: false, notes: null, created_at: '2024-06-02T00:00:00Z', updated_at: '2024-06-02T00:00:00Z', @@ -508,6 +509,10 @@ export function makeCompanySettings( vat_registered: true, vat_number: null, moms_period: 'quarterly', + periodisk_sammanstallning_period: 'quarterly', + tax_contact_name: null, + tax_contact_phone: null, + tax_contact_email: null, fiscal_year_start_month: 1, preliminary_tax_monthly: null, bank_name: null, diff --git a/types/index.ts b/types/index.ts index 6033ec79..fee08438 100644 --- a/types/index.ts +++ b/types/index.ts @@ -197,6 +197,12 @@ export interface CompanySettings { vat_registered: boolean vat_number: string | null moms_period: MomsPeriod | null + periodisk_sammanstallning_period: 'monthly' | 'quarterly' + + // Tax contact (SKV-filings, periodisk sammanställning, AGI, etc.) + tax_contact_name: string | null + tax_contact_phone: string | null + tax_contact_email: string | null // Fiscal year fiscal_year_start_month: number // 1-12 @@ -515,6 +521,11 @@ export interface SupplierInvoice { transaction_id: string | null document_id: string | null + // Owner paid out-of-pocket; AP step is bypassed and the expense is booked + // directly against 2893 (AB) or 2018 (EF). Status is set to 'paid' at + // creation and mark-paid is rejected by the existing status guard. + paid_with_private_funds: boolean + notes: string | null created_at: string @@ -910,6 +921,7 @@ export type JournalEntrySourceType = | 'supplier_invoice_registered' | 'supplier_invoice_paid' | 'supplier_invoice_cash_payment' + | 'supplier_invoice_privately_paid' | 'supplier_credit_note' | 'currency_revaluation'