Bug/accounting ps eu (#474)

* feat(api): implement commit functionality for journal entries

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

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

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

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

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

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

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

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

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

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

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

* fix: correct capitalization in project title

* fix(migrations): resolve duplicate version 20260513120000

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-14 01:10:44 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent ba45b8f661
commit c8461397c8
40 changed files with 3147 additions and 55 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# CLAUDE.md — gnubok
# CLAUDE.md — Gnubok
## Project Overview
+35
View File
@@ -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') && (
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
{entry.status === 'draft' && (
<Button
size="sm"
className="w-full sm:w-auto"
onClick={handleCommit}
disabled={!canWrite || isCommitting}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : isCommitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Bokför
</Button>
)}
{(entry.status === 'draft' || isLastInSeries) && (
<Button
variant="destructive"
+8
View File
@@ -198,6 +198,14 @@ const glossaryTerms: GlossaryTerm[] = [
'Ditt företags unika identitetsnummer. För enskild firma är det ditt personnummer + 100 på århundradesiffran (199001011234 blir 199101011234).',
category: 'företag',
},
{
term: 'Eget utlägg',
simpleTerm: 'Betalat privat för bolagets räkning',
definition:
'När du som ägare lägger ut pengar privat för en kostnad som bolaget ska stå för. Registrera under Leverantörsfakturor → Ny, kryssa i "Jag har betalat detta privat". Verifikatet bokförs då direkt mot skuld till ägare (2893 för AB, 2018 för EF) istället för via leverantörsskuld. När bolaget senare ersätter dig kategoriserar du den utgående banktransaktionen mot samma konto.',
category: 'bokföring',
relatedTerms: ['Aktiebolag', 'Enskild firma'],
},
]
const categoryConfig = {
+2
View File
@@ -14,6 +14,7 @@ import { useCompany } from '@/contexts/CompanyContext'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { ReportsNav } from '@/components/reports/ReportsNav'
import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
import { PeriodiskSammanstallningView } from '@/components/reports/PeriodiskSammanstallningView'
import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView'
import { BankReconciliationView } from '@/components/reports/BankReconciliationView'
import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart'
@@ -186,6 +187,7 @@ export default function ReportsPage() {
<BalanceSheetView periodId={selectedPeriod} onNavigateToAccount={navigateToAccount} />
)}
{activeTab === 'vat-declaration' && <VatDeclarationView />}
{activeTab === 'periodisk-sammanstallning' && <PeriodiskSammanstallningView />}
{isEnskildFirma && activeTab === 'ne-declaration' && (
<NEDeclarationView periodId={selectedPeriod} />
)}
+5
View File
@@ -19,6 +19,11 @@ export default function TaxSettingsPage() {
vat_registered: vatRegistered,
vat_number: vatRegistered ? ((formData.get('vat_number') as string) || null) : null,
moms_period: vatRegistered ? ((formData.get('moms_period') as string) || null) : null,
periodisk_sammanstallning_period:
(formData.get('periodisk_sammanstallning_period') as string) || 'monthly',
tax_contact_name: (formData.get('tax_contact_name') as string) || null,
tax_contact_phone: (formData.get('tax_contact_phone') as string) || null,
tax_contact_email: (formData.get('tax_contact_email') as string) || null,
fiscal_year_start_month: parseInt(formData.get('fiscal_year_start_month') as string) || 1,
pays_salaries: formData.get('pays_salaries') === 'true',
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
+85 -26
View File
@@ -42,6 +42,7 @@ interface FormData {
reverse_charge: boolean
payment_reference: string
notes: string
paid_with_private_funds: boolean
items: LineItem[]
}
@@ -155,6 +156,7 @@ export default function NewSupplierInvoicePage() {
reverse_charge: false,
payment_reference: '',
notes: '',
paid_with_private_funds: false,
items: [{ description: '', amount: 0, account_number: '5010', vat_rate: 0.25 }],
},
})
@@ -165,6 +167,7 @@ export default function NewSupplierInvoicePage() {
const watchedItems = watch('items')
const watchedSupplierId = watch('supplier_id')
const watchedCurrency = watch('currency')
const watchedPaidPrivately = watch('paid_with_private_funds')
const isEF = entityType === 'enskild_firma'
@@ -468,11 +471,16 @@ export default function NewSupplierInvoicePage() {
function buildPayload(data: FormData) {
const vatTreatment = inferVatTreatment(data.items, data.reverse_charge)
// When paid privately, due_date is irrelevant — but the API still requires
// a YYYY-MM-DD value. Default to invoice_date so the field passes validation.
const dueDate = data.paid_with_private_funds && !data.due_date
? data.invoice_date
: data.due_date
return {
supplier_id: data.supplier_id,
supplier_invoice_number: data.supplier_invoice_number,
invoice_date: data.invoice_date,
due_date: data.due_date,
due_date: dueDate,
delivery_date: data.delivery_date || undefined,
currency: data.currency,
exchange_rate: data.exchange_rate ? parseFloat(data.exchange_rate) : undefined,
@@ -480,6 +488,7 @@ export default function NewSupplierInvoicePage() {
reverse_charge: data.reverse_charge,
payment_reference: data.payment_reference || undefined,
notes: data.notes || undefined,
paid_with_private_funds: data.paid_with_private_funds,
items: data.items.map((item) => ({
description: item.description,
amount: item.amount,
@@ -572,7 +581,10 @@ export default function NewSupplierInvoicePage() {
return
}
if (isEF) {
// Privately-paid skips the AB review dialog — the toggle itself is the
// explicit user intent, and the resulting verifikat is just expense + VAT
// against the owner account (2893/2018). Same path for EF.
if (isEF || data.paid_with_private_funds) {
setPendingData(data)
handleDirectSubmit(data)
} else {
@@ -581,7 +593,8 @@ export default function NewSupplierInvoicePage() {
}
}
// EF: create + auto-approve, no review dialog
// EF: create + auto-approve, no review dialog. Privately-paid invoices land
// here too and skip auto-approve since they're already in status='paid'.
async function handleDirectSubmit(data: FormData) {
setIsSubmitting(true)
await patchInboxFieldsIfChanged(data)
@@ -597,11 +610,22 @@ export default function NewSupplierInvoicePage() {
return
}
// Auto-approve for EF
const approveRes = await fetch(`/api/supplier-invoices/${result.data.id}/approve`, { method: 'POST' })
// Clear dirty state so useUnsavedChanges doesn't fire the
// beforeunload prompt while we navigate away on a successful submit.
reset(data)
if (data.paid_with_private_funds) {
toast({
title: 'Utlägg registrerat',
description: `Ankomstnummer: ${result.data.arrival_number}`,
})
router.push('/supplier-invoices')
setIsSubmitting(false)
return
}
// Auto-approve for EF
const approveRes = await fetch(`/api/supplier-invoices/${result.data.id}/approve`, { method: 'POST' })
if (!approveRes.ok) {
toast({
title: 'Varning',
@@ -856,6 +880,30 @@ export default function NewSupplierInvoicePage() {
<CardTitle className="text-lg">Faktura</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Eget utlägg-toggle. När den är på bokas verifikatet direkt mot
skuld till ägare (2893/2018) istället för leverantörsskuld (2440),
och fakturan får status "Betalad" direkt. */}
<div className="flex items-start gap-3 p-3 rounded-md border bg-muted/30">
<Controller
name="paid_with_private_funds"
control={control}
render={({ field }) => (
<Checkbox
id="paid_with_private_funds"
checked={field.value}
onCheckedChange={field.onChange}
className="mt-0.5"
/>
)}
/>
<Label htmlFor="paid_with_private_funds" className="cursor-pointer flex-1">
<span className="text-sm font-medium">Jag har betalat detta privat</span>
<span className="block text-[11px] text-muted-foreground font-normal mt-0.5">
Bokförs som skuld från bolaget till dig ({isEF ? '2018 Egen insättning' : '2893 Skuld till ägare'}). Återbetalas senare manuellt från företagskontot.
</span>
</Label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Leverantör<RequiredMark /></Label>
@@ -905,19 +953,26 @@ export default function NewSupplierInvoicePage() {
})()}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className={cn(
'grid grid-cols-1 gap-4',
watchedPaidPrivately ? 'sm:grid-cols-1' : 'sm:grid-cols-3',
)}>
<div className="space-y-2">
<Label>Fakturadatum<RequiredMark /></Label>
<Input type="date" {...register('invoice_date')} />
</div>
<div className="space-y-2">
<Label>Förfallodatum<RequiredMark /></Label>
<Input type="date" {...register('due_date')} />
</div>
<div className="space-y-2">
<Label>OCR / Betalningsreferens</Label>
<Input placeholder="OCR-nummer" {...register('payment_reference')} />
</div>
{!watchedPaidPrivately && (
<>
<div className="space-y-2">
<Label>Förfallodatum<RequiredMark /></Label>
<Input type="date" {...register('due_date')} />
</div>
<div className="space-y-2">
<Label>OCR / Betalningsreferens</Label>
<Input placeholder="OCR-nummer" {...register('payment_reference')} />
</div>
</>
)}
</div>
</CardContent>
</Card>
@@ -1237,17 +1292,19 @@ export default function NewSupplierInvoicePage() {
<Button type="button" variant="outline" className="w-full sm:w-auto" onClick={() => router.push('/supplier-invoices')}>
Avbryt
</Button>
<Button
type="submit"
variant="outline"
className="w-full sm:w-auto"
disabled={isSubmitting || !canWrite}
onClick={() => { submitModeRef.current = 'register_and_match' }}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
<Link2 className="mr-2 h-4 w-4" />
Registrera &amp; markera som betald
</Button>
{!watchedPaidPrivately && (
<Button
type="submit"
variant="outline"
className="w-full sm:w-auto"
disabled={isSubmitting || !canWrite}
onClick={() => { submitModeRef.current = 'register_and_match' }}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
<Link2 className="mr-2 h-4 w-4" />
Registrera &amp; markera som betald
</Button>
)}
<Button
type="submit"
disabled={isSubmitting || !canWrite}
@@ -1263,8 +1320,10 @@ export default function NewSupplierInvoicePage() {
) : !canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" />
{isEF ? 'Registrera faktura' : 'Granska & registrera'}
{watchedPaidPrivately ? 'Registrera utlägg' : isEF ? 'Registrera faktura' : 'Granska & registrera'}
</>
) : watchedPaidPrivately ? (
'Registrera utlägg'
) : isEF ? (
'Registrera faktura'
) : (
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
makeJournalEntry,
} from '@/tests/helpers'
const mockCreateClient = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => mockCreateClient(),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockCommitEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
commitEntry: (...args: unknown[]) => mockCommitEntry(...args),
}))
import { POST } from '../route'
describe('POST /api/bookkeeping/journal-entries/[id]/commit', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
mockCreateClient.mockResolvedValue({
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) },
})
})
it('returns 401 when not authenticated', async () => {
mockCreateClient.mockResolvedValue({
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) },
})
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/commit', {
method: 'POST',
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns posted entry on success', async () => {
const postedEntry = makeJournalEntry({
id: 'entry-1',
status: 'posted',
voucher_series: 'A',
voucher_number: 42,
})
mockCommitEntry.mockResolvedValue(postedEntry)
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/commit', {
method: 'POST',
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
expect(status).toBe(200)
expect(body.data).toEqual(postedEntry)
expect(mockCommitEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'entry-1',
'manual'
)
})
it('returns 400 when engine throws', async () => {
mockCommitEntry.mockRejectedValue(new Error('Entry not balanced'))
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/commit', {
method: 'POST',
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Entry not balanced')
})
})
@@ -0,0 +1,39 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { commitEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
try {
const posted = await commitEntry(supabase, companyId, user.id, id, 'manual')
return NextResponse.json({ data: posted })
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to commit entry' },
{ status: 400 }
)
}
}
+7 -2
View File
@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { createDraftEntry, createJournalEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
@@ -141,8 +141,13 @@ export async function POST(request: Request) {
if (!validation.success) return validation.response
const body = validation.data
const { searchParams } = new URL(request.url)
const asDraft = searchParams.get('as_draft') === 'true'
try {
const entry = await createJournalEntry(supabase, companyId, user.id, body)
const entry = asDraft
? await createDraftEntry(supabase, companyId, user.id, body)
: await createJournalEntry(supabase, companyId, user.id, body)
return NextResponse.json({ data: entry })
} catch (err) {
const typed = bookkeepingErrorResponse(err)
@@ -0,0 +1,106 @@
import {
generatePeriodiskSammanstallning,
type PsPeriodType,
} from '@/lib/reports/periodisk-sammanstallning'
import {
buildPeriodiskSammanstallningCsv,
PsCsvBuildError,
} from '@/lib/reports/periodisk-sammanstallning-csv'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
/**
* GET /api/reports/periodisk-sammanstallning/csv
*
* Returns the SKV574008-formatted CSV file for upload to Skatteverket.
* Refuses (400) if the report has any blocking warnings.
*/
export const GET = withRouteContext(
'report.periodisk_sammanstallning.csv',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const { searchParams } = new URL(request.url)
const periodType = searchParams.get('periodType') as PsPeriodType | null
const yearStr = searchParams.get('year')
const periodStr = searchParams.get('period')
if (!periodType || !yearStr || !periodStr) {
return errorResponseFromCode('PS_REPORT_MISSING_PARAMS', log, { requestId })
}
if (periodType !== 'monthly' && periodType !== 'quarterly') {
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD_TYPE', log, {
requestId, details: { received: periodType },
})
}
const year = parseInt(yearStr, 10)
const period = parseInt(periodStr, 10)
if (isNaN(year) || year < 2000 || year > 2100) {
return errorResponseFromCode('PS_REPORT_INVALID_YEAR', log, { requestId })
}
if (isNaN(period)) {
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, { requestId })
}
if (periodType === 'monthly' && (period < 1 || period > 12)) {
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, { requestId })
}
if (periodType === 'quarterly' && (period < 1 || period > 4)) {
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, { requestId })
}
const { data: settings } = await supabase
.from('company_settings')
.select('org_number, tax_contact_name, tax_contact_phone, tax_contact_email')
.eq('company_id', companyId)
.single()
if (!settings?.org_number
|| !settings?.tax_contact_name
|| !settings?.tax_contact_phone
|| !settings?.tax_contact_email) {
return errorResponseFromCode('PS_REPORT_MISSING_FILER_INFO', log, { requestId })
}
try {
const report = await generatePeriodiskSammanstallning(
supabase, companyId, periodType, year, period,
)
const csv = buildPeriodiskSammanstallningCsv(report, {
organizationNumber: settings.org_number,
contactName: settings.tax_contact_name,
contactPhone: settings.tax_contact_phone,
contactEmail: settings.tax_contact_email,
})
return new Response(csv.content as unknown as BodyInit, {
status: 200,
headers: {
'Content-Type': csv.mimeType,
'Content-Disposition': `attachment; filename="${csv.filename}"`,
'X-Request-Id': requestId,
},
})
} catch (err) {
if (err instanceof PsCsvBuildError) {
if (err.reason === 'BLOCKING_WARNINGS') {
return errorResponseFromCode('PS_REPORT_CSV_BLOCKED_BY_ERRORS', log, {
requestId, details: { message: err.message },
})
}
if (err.reason === 'MISSING_FILER_INFO') {
return errorResponseFromCode('PS_REPORT_MISSING_FILER_INFO', log, {
requestId, details: { message: err.message },
})
}
}
log.error('periodisk sammanställning CSV failed', err as Error, {
periodType, year, period,
})
return errorResponseFromCode('PS_REPORT_GENERATION_FAILED', log, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
},
)
@@ -0,0 +1,95 @@
import { NextResponse } from 'next/server'
import {
generatePeriodiskSammanstallning,
reconcilePsAgainstVatDeclaration,
type PsPeriodType,
} from '@/lib/reports/periodisk-sammanstallning'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { MomsPeriod } from '@/types'
/**
* GET /api/reports/periodisk-sammanstallning
*
* Query parameters:
* periodType: 'monthly' | 'quarterly'
* year: number (e.g., 2025)
* period: number (1-12 for monthly, 1-4 for quarterly)
*/
export const GET = withRouteContext(
'report.periodisk_sammanstallning',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const { searchParams } = new URL(request.url)
const periodType = searchParams.get('periodType') as PsPeriodType | null
const yearStr = searchParams.get('year')
const periodStr = searchParams.get('period')
if (!periodType || !yearStr || !periodStr) {
return errorResponseFromCode('PS_REPORT_MISSING_PARAMS', log, { requestId })
}
if (periodType !== 'monthly' && periodType !== 'quarterly') {
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD_TYPE', log, {
requestId,
details: { received: periodType },
})
}
const year = parseInt(yearStr, 10)
const period = parseInt(periodStr, 10)
if (isNaN(year) || year < 2000 || year > 2100) {
return errorResponseFromCode('PS_REPORT_INVALID_YEAR', log, {
requestId,
details: { received: yearStr },
})
}
if (isNaN(period)) {
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, {
requestId,
details: { received: periodStr },
})
}
if (periodType === 'monthly' && (period < 1 || period > 12)) {
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, {
requestId,
details: { periodType, received: period, allowed: '1-12' },
})
}
if (periodType === 'quarterly' && (period < 1 || period > 4)) {
return errorResponseFromCode('PS_REPORT_INVALID_PERIOD', log, {
requestId,
details: { periodType, received: period, allowed: '1-4' },
})
}
try {
const report = await generatePeriodiskSammanstallning(
supabase, companyId, periodType, year, period,
)
// Best-effort reconciliation against momsdeklaration when periods coincide.
const { data: settings } = await supabase
.from('company_settings')
.select('moms_period')
.eq('company_id', companyId)
.single()
const momsPeriod = (settings?.moms_period ?? null) as MomsPeriod | null
const reconciled = await reconcilePsAgainstVatDeclaration(
supabase, companyId, report, momsPeriod,
)
return NextResponse.json({ data: reconciled })
} catch (err) {
log.error('periodisk sammanställning calculation failed', err as Error, {
periodType, year, period,
})
return errorResponseFromCode('PS_REPORT_GENERATION_FAILED', log, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
},
)
@@ -31,9 +31,12 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
}))
const mockCreateSupplierInvoiceRegistrationEntry = vi.fn()
const mockCreateSupplierInvoicePrivatelyPaidEntry = vi.fn()
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
createSupplierInvoiceRegistrationEntry: (...args: unknown[]) =>
mockCreateSupplierInvoiceRegistrationEntry(...args),
createSupplierInvoicePrivatelyPaidEntry: (...args: unknown[]) =>
mockCreateSupplierInvoicePrivatelyPaidEntry(...args),
}))
import { eventBus } from '@/lib/events'
@@ -457,4 +460,126 @@ describe('POST /api/supplier-invoices', () => {
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('SI_CREATE_FAILED')
})
it('books privately-paid invoice via 2893 path for aktiebolag', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-1', status: 'paid' })
// Fetch supplier
enqueue({ data: supplier, error: null })
// Fetch company.entity_type (paidPrivately branch)
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
// RPC get_next_arrival_number
enqueue({ data: 12 })
// Insert invoice
enqueue({ data: createdInvoice, error: null })
// Insert items
enqueue({ data: null, error: null })
// Fetch company settings
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockCreateSupplierInvoicePrivatelyPaidEntry.mockResolvedValue({ id: 'je-priv-1' })
// Update invoice with payment_journal_entry_id
enqueue({ data: null, error: null })
// Insert supplier_invoice_payments row
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'KVITTO-001',
invoice_date: '2024-06-01',
due_date: '2024-06-01',
paid_with_private_funds: true,
items: [
{
description: 'Kontorsmaterial',
quantity: 1,
unit_price: 400,
account_number: '6110',
vat_rate: 0.25,
},
],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: { payment_journal_entry_id: string; registration_journal_entry_id: null }
}>(response)
expect(status).toBe(200)
expect(body.data.payment_journal_entry_id).toBe('je-priv-1')
expect(body.data.registration_journal_entry_id).toBeNull()
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).toHaveBeenCalled()
// The classic registration path must NOT be touched.
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
const call = mockCreateSupplierInvoicePrivatelyPaidEntry.mock.calls[0]
expect(call[5]).toBe('aktiebolag')
})
it('passes entity_type=enskild_firma so engine credits 2018', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-2', status: 'paid' })
enqueue({ data: supplier, error: null })
enqueue({ data: { entity_type: 'enskild_firma' }, error: null })
enqueue({ data: 13 })
enqueue({ data: createdInvoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'cash' }, error: null })
mockCreateSupplierInvoicePrivatelyPaidEntry.mockResolvedValue({ id: 'je-priv-2' })
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'KVITTO-002',
invoice_date: '2024-06-01',
due_date: '2024-06-01',
paid_with_private_funds: true,
items: [
{
description: 'Lunch klient',
quantity: 1,
unit_price: 200,
account_number: '5810',
vat_rate: 0.12,
},
],
},
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
const call = mockCreateSupplierInvoicePrivatelyPaidEntry.mock.calls[0]
expect(call[5]).toBe('enskild_firma')
})
it('rejects paid_with_private_funds combined with reverse_charge', async () => {
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-RC',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
paid_with_private_funds: true,
reverse_charge: true,
items: [{ description: 'Service', quantity: 1, unit_price: 5000, account_number: '6540', vat_rate: 0.25 }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT')
// Make sure we never touched the engine paths.
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).not.toHaveBeenCalled()
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
})
})
+132 -5
View File
@@ -1,6 +1,9 @@
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import {
createSupplierInvoiceRegistrationEntry,
createSupplierInvoicePrivatelyPaidEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
@@ -54,6 +57,17 @@ export const POST = withRouteContext(
})
if (!validation.success) return validation.response
const body = validation.data
const paidPrivately = body.paid_with_private_funds === true
if (paidPrivately && body.reverse_charge) {
// RC invoices come from registered businesses with formal invoices and
// go through normal AP. "Privately paid" only makes sense for
// out-of-pocket kvitton — combining the two is a UI bug. 400, not 500.
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
requestId,
details: { reason: 'paid_with_private_funds is not supported with reverse_charge' },
})
}
const { data: supplier, error: supplierError } = await supabase
.from('suppliers')
@@ -66,6 +80,25 @@ export const POST = withRouteContext(
return errorResponseFromCode('SUPPLIER_NOT_FOUND', log, { requestId })
}
// Entity type drives the credit account for privately-paid invoices:
// AB → 2893 (skuld till aktieägare), EF → 2018 (egen insättning). Loaded
// up front so we can fail early if the company row is missing.
let entityType: 'aktiebolag' | 'enskild_firma' | null = null
if (paidPrivately) {
const { data: company } = await supabase
.from('companies')
.select('entity_type')
.eq('id', companyId)
.single()
if (!company?.entity_type) {
return errorResponseFromCode('SI_CREATE_FAILED', log, {
requestId,
details: { reason: 'company entity_type missing — cannot pick owner account' },
})
}
entityType = company.entity_type as 'aktiebolag' | 'enskild_firma'
}
const { data: arrivalNum, error: arrivalError } = await supabase
.rpc('get_next_arrival_number', { p_company_id: companyId })
@@ -101,11 +134,33 @@ export const POST = withRouteContext(
const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0)
const total = Math.round((subtotal + vatAmount) * 100) / 100
// Representation (BAS 6070–6079): ingående moms is only deductible up to
// 300 SEK base/person per ML 8 kap. 1 §, and the income-tax deduction was
// abolished in 2017 (IL 16 kap. 2 §). The engine debits 2641 for the full
// VAT; we surface a non-blocking warning so the user can adjust manually.
// Only emit on the new private-funds path for now — other AP paths share
// the flaw and are tracked separately.
const warnings: Array<{ code: string; message: string }> = []
if (paidPrivately) {
const repItems = items.filter(i => /^607\d$/.test(i.account_number))
if (repItems.length > 0) {
warnings.push({
code: 'REPRESENTATION_VAT_CAP',
message:
'Representation (konto 6070–6079): ingående moms är endast avdragsgill ' +
'upp till 300 kr/person (ML 8 kap. 1 §) och kostnaden är inte ' +
'inkomstskattemässigt avdragsgill (IL 16 kap. 2 §). Justera bokföringen ' +
'manuellt om beloppet överstiger gränsen.',
})
}
}
const exchangeRate = body.exchange_rate || null
const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null
const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null
const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null
const totalRounded = Math.round(total * 100) / 100
const { data: invoice, error: invoiceError } = await supabase
.from('supplier_invoices')
.insert({
@@ -117,19 +172,22 @@ export const POST = withRouteContext(
invoice_date: body.invoice_date,
due_date: body.due_date,
delivery_date: body.delivery_date || null,
status: 'registered',
status: paidPrivately ? 'paid' : 'registered',
currency: body.currency || 'SEK',
exchange_rate: exchangeRate,
vat_treatment: body.vat_treatment || 'standard_25',
reverse_charge: body.reverse_charge || false,
payment_reference: body.payment_reference || null,
paid_with_private_funds: paidPrivately,
subtotal: Math.round(subtotal * 100) / 100,
subtotal_sek: subtotalSek,
vat_amount: Math.round(vatAmount * 100) / 100,
vat_amount_sek: vatAmountSek,
total: Math.round(total * 100) / 100,
total: totalRounded,
total_sek: totalSek,
remaining_amount: Math.round(total * 100) / 100,
paid_amount: paidPrivately ? totalRounded : 0,
remaining_amount: paidPrivately ? 0 : totalRounded,
paid_at: paidPrivately ? new Date().toISOString() : null,
notes: body.notes || null,
})
.select()
@@ -214,6 +272,11 @@ export const POST = withRouteContext(
// is fatal — an orphan supplier_invoices row without a registration JE
// silently understates leverantörsskuld (2440) and ingående moms (2641)
// for the momsdeklaration. Roll back instead.
//
// Privately-paid path bypasses both accrual and cash flows: a single
// verifikat books the expense + VAT against 2893 (AB) or 2018 (EF) at
// registration time, regardless of accounting_method. mark-paid is never
// invoked for these (status='paid' from the start).
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
@@ -222,8 +285,59 @@ export const POST = withRouteContext(
const accountingMethod = settings?.accounting_method || 'accrual'
let registrationJournalEntryId: string | null = null
let paymentJournalEntryId: string | null = null
if (accountingMethod === 'accrual') {
if (paidPrivately && entityType) {
try {
const journalEntry = await createSupplierInvoicePrivatelyPaidEntry(
supabase,
companyId!,
user.id,
invoice as SupplierInvoice,
items as SupplierInvoiceItem[],
entityType,
supplier.name,
)
if (journalEntry) {
paymentJournalEntryId = journalEntry.id
await supabase
.from('supplier_invoices')
.update({ payment_journal_entry_id: journalEntry.id })
.eq('id', invoice.id)
// Mirror the payment in supplier_invoice_payments so AR/AP and
// payment-history queries stay consistent with the mark-paid path.
await supabase.from('supplier_invoice_payments').insert({
user_id: user.id,
company_id: companyId,
supplier_invoice_id: invoice.id,
// For an eget utlägg the actual out-of-pocket date may differ from
// the invoice/receipt date — accept an explicit payment_date and
// fall back to invoice_date for the common kvitto case.
payment_date: body.payment_date ?? invoice.invoice_date,
amount: totalRounded,
currency: invoice.currency,
exchange_rate_difference: 0,
journal_entry_id: journalEntry.id,
notes: 'Eget utlägg — betalat privat',
})
}
} catch (err) {
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
if (isBookkeepingError(err)) {
return errorResponse(err, log, { requestId })
}
log.error('failed to create privately-paid journal entry', err as Error, {
invoiceId: invoice.id,
})
return errorResponseFromCode('SI_CREATE_FAILED', log, {
requestId,
details: {
reason: err instanceof Error ? err.message : 'unknown',
step: 'privately_paid_journal_entry',
},
})
}
} else if (accountingMethod === 'accrual') {
try {
const journalEntry = await createSupplierInvoiceRegistrationEntry(
supabase,
@@ -264,6 +378,17 @@ export const POST = withRouteContext(
type: 'supplier_invoice.registered',
payload: { supplierInvoice: invoice as SupplierInvoice, companyId: companyId!, userId: user.id },
})
if (paidPrivately) {
await eventBus.emit({
type: 'supplier_invoice.paid',
payload: {
supplierInvoice: invoice as SupplierInvoice,
paymentAmount: totalRounded,
companyId: companyId!,
userId: user.id,
},
})
}
} catch (err) {
log.warn('supplier_invoice.registered event emission failed', err as Error)
}
@@ -273,7 +398,9 @@ export const POST = withRouteContext(
...invoice,
items: itemInserts,
registration_journal_entry_id: registrationJournalEntryId,
payment_journal_entry_id: paymentJournalEntryId,
},
...(warnings.length > 0 ? { warnings } : {}),
})
},
{ requireWrite: true },
+80 -10
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -89,6 +89,8 @@ export default function JournalEntryForm({
const [nextVoucherNumber, setNextVoucherNumber] = useState<number | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [showReview, setShowReview] = useState(false)
const [isSavingDraft, setIsSavingDraft] = useState(false)
const saveAsDraftRef = useRef(false)
const [showNoDocWarning, setShowNoDocWarning] = useState(false)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
@@ -332,7 +334,8 @@ export default function JournalEntryForm({
return base
})
const url = submitUrl ?? '/api/bookkeeping/journal-entries'
const baseUrl = submitUrl ?? '/api/bookkeeping/journal-entries'
const url = saveAsDraftRef.current ? `${baseUrl}?as_draft=true` : baseUrl
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -355,6 +358,7 @@ export default function JournalEntryForm({
const handleConfirm = async () => {
setIsSubmitting(true)
saveAsDraftRef.current = false
try {
const result = await runSubmit()
@@ -414,6 +418,61 @@ export default function JournalEntryForm({
}
}
const handleSaveDraft = async () => {
if (!selectedPeriod || !description || !isBalanced || periodMismatch) return
setIsSavingDraft(true)
saveAsDraftRef.current = true
try {
const result = await runSubmit()
const journalEntryId = result.data?.id ?? result.journal_entry_id
if (journalEntryId && uploadedFiles.length > 0) {
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
for (const file of filesToLink) {
try {
await fetch(`/api/documents/${file.id}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ journal_entry_id: journalEntryId }),
})
} catch {
// Non-blocking: user can attach underlag from the draft view
}
}
}
toast({
title: 'Utkast sparat',
description: 'Utkastet kan bokföras från bokföringssidan.',
})
setDescription('')
setNotes('')
setUploadedFiles([])
setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }])
setEntryCurrency('SEK')
setExchangeRate('')
setForeignAmount('')
onCreated?.()
if (journalEntryId) {
onEntryCreated?.(journalEntryId)
}
} catch (err) {
if (err instanceof Error && err.message === 'cancelled') {
// Activation dialog dismissed — silent
} else {
const anyErr = err as { body?: unknown; status?: number }
toast({
title: 'Kunde inte spara utkast',
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
variant: 'destructive',
})
}
} finally {
saveAsDraftRef.current = false
setIsSavingDraft(false)
}
}
const formContent = (
<div className="space-y-4">
<div className={`grid gap-4 grid-cols-1 ${
@@ -789,14 +848,25 @@ export default function JournalEntryForm({
)}
<div className="flex flex-col items-end gap-1">
<Button
onClick={handleReview}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isUploading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
Granska & skapa
</Button>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleSaveDraft}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : 'Sparar som utkast utan att tilldela verifikationsnummer'}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : isSavingDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara som utkast
</Button>
<Button
onClick={handleReview}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
Granska & skapa
</Button>
</div>
{(!description || !selectedPeriod || isUploading || periodMismatch || incompleteLineCount > 0 || (!isBalanced && submittableLines.length < 2)) && (
<div className="text-xs text-muted-foreground space-y-0.5 text-right">
{!description && <p>Ange en beskrivning</p>}
+41 -1
View File
@@ -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<JournalEntry[]>([])
const [committingId, setCommittingId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<string | null>(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 (
<Card>
@@ -443,6 +471,18 @@ export default function JournalEntryList({ periodId }: Props) {
/>
<div className="mt-4 pt-3 border-t flex flex-col sm:flex-row gap-2">
{entry.status === 'draft' && (
<Button
size="sm"
className="w-full sm:w-auto"
onClick={() => handleCommit(entry.id)}
disabled={!canWrite || committingId === entry.id}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : committingId === entry.id && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Bokför
</Button>
)}
<Button variant="outline" size="sm" className="w-full sm:w-auto" asChild>
<Link href={`/bookkeeping/${entry.id}`}>Visa detaljer</Link>
</Button>
@@ -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<PsPeriodType>('quarterly')
const [year, setYear] = useState(currentYear)
const [period, setPeriod] = useState(currentQuarter)
const [data, setData] = useState<PeriodiskSammanstallningReport | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="space-y-6">
{/* Period selection */}
<Card>
<CardHeader>
<CardTitle className="text-base">Välj period</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-end gap-4">
<div>
<Label>Periodicitet</Label>
<select
value={periodType}
onChange={e => setPeriodType(e.target.value as PsPeriodType)}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="monthly">Månadsvis</option>
<option value="quarterly">Kvartalsvis</option>
</select>
</div>
<div>
<Label>År</Label>
<select
value={year}
onChange={e => setYear(parseInt(e.target.value))}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{yearOptions.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div>
<Label>Period</Label>
<select
value={period}
onChange={e => setPeriod(parseInt(e.target.value))}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{periodOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<Button onClick={fetchReport} disabled={loading}>
{loading ? 'Laddar…' : 'Hämta'}
</Button>
</div>
</CardContent>
</Card>
{error && (
<Card>
<CardContent className="p-6 flex items-start gap-3 text-destructive">
<AlertCircle className="h-5 w-5 mt-0.5 shrink-0" />
<div>{error}</div>
</CardContent>
</Card>
)}
{loading && (
<Card>
<CardContent className="p-6 space-y-3">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-32" />
</CardContent>
</Card>
)}
{data && !loading && (
<>
{/* Summary */}
<Card>
<CardHeader>
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle>
Periodisk sammanställning — {data.period.label}
</CardTitle>
{data.totals.rowCount > 0 && (
<Badge variant={hasBlockingErrors ? 'destructive' : 'secondary'}>
{data.totals.rowCount} {data.totals.rowCount === 1 ? 'rad' : 'rader'}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<div className="text-xs text-muted-foreground uppercase tracking-wider">Tjänster (typ 3)</div>
<div className="font-display text-xl font-medium tabular-nums">
{formatAmount(data.totals.services)} kr
</div>
</div>
<div>
<div className="text-xs text-muted-foreground uppercase tracking-wider">Varor (typ 1)</div>
<div className="font-display text-xl font-medium tabular-nums">
{formatAmount(data.totals.goods)} kr
</div>
</div>
<div>
<div className="text-xs text-muted-foreground uppercase tracking-wider">Trepart (typ 2)</div>
<div className="font-display text-xl font-medium tabular-nums">
{formatAmount(data.totals.triangulation)} kr
</div>
</div>
</div>
{/* Reconciliation */}
{data.reconciliation.matches !== null && data.totals.rowCount > 0 && (
<div className="text-sm text-muted-foreground border-t pt-3">
{data.reconciliation.matches ? (
<span className="text-foreground">
✓ 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)
</span>
) : (
<span className="text-destructive">
⚠ 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.
</span>
)}
</div>
)}
{data.reconciliation.matches === null && data.totals.rowCount > 0 && (
<div className="text-xs text-muted-foreground border-t pt-3">
Avstämning mot momsdeklarationen tillgänglig endast när PS-perioden sammanfaller med momsperioden.
</div>
)}
{/* Deadline banner */}
{data.totals.rowCount > 0 && (
<div className="text-sm border-t pt-3">
<strong>Inlämnas digitalt senast den {deadlineLabel(data.period.end)}</strong>
{' '}via e-tjänsten hos Skatteverket. Förseningsavgift: 1 250 kr.
</div>
)}
</CardContent>
</Card>
{/* Errors */}
{errors.length > 0 && (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-base text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
{errors.length} {errors.length === 1 ? 'fel måste' : 'fel måste'} åtgärdas
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{errors.map((w, i) => <WarningItem key={i} warning={w} />)}
<div className="text-xs text-muted-foreground pt-2 border-t">
CSV-nedladdning är blockerad tills alla fel åtgärdats.
</div>
</CardContent>
</Card>
)}
{/* Warnings */}
{cautions.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2 text-foreground">
<AlertTriangle className="h-4 w-4" />
Varningar ({cautions.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{cautions.map((w, i) => <WarningItem key={i} warning={w} />)}
</CardContent>
</Card>
)}
{/* Table */}
{data.rows.length > 0 ? (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Rader</CardTitle>
<Button
variant="outline"
size="sm"
onClick={downloadCsv}
disabled={hasBlockingErrors}
title={hasBlockingErrors ? 'Åtgärda blockerande fel innan CSV kan laddas ner' : undefined}
>
<Download className="h-4 w-4 mr-2" />
Ladda ner CSV (SKV 5740)
</Button>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Land</TableHead>
<TableHead>VAT-nummer</TableHead>
<TableHead className="text-right">Tjänster</TableHead>
<TableHead className="text-right">Varor</TableHead>
<TableHead className="text-right">Trepart</TableHead>
<TableHead>Typ</TableHead>
<TableHead>Kund</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.rows.map((row, i) => (
<TableRow key={i}>
<TableCell className="tabular-nums font-mono text-xs">{row.country}</TableCell>
<TableCell className="tabular-nums font-mono text-xs">{row.vatNumber}</TableCell>
<TableCell className="text-right tabular-nums">
{row.services !== 0 ? formatAmount(row.services) : '—'}
</TableCell>
<TableCell className="text-right tabular-nums">
{row.goods !== 0 ? formatAmount(row.goods) : '—'}
</TableCell>
<TableCell className="text-right tabular-nums">
{row.triangulation !== 0 ? formatAmount(row.triangulation) : '—'}
</TableCell>
<TableCell className="text-xs text-muted-foreground">{typeBadge(row)}</TableCell>
<TableCell className="text-xs">
{row.customerId
? <Link href={`/customers/${row.customerId}`} className="underline-offset-4 hover:underline">{row.customerName ?? '—'}</Link>
: <span className="text-muted-foreground">—</span>
}
</TableCell>
</TableRow>
))}
<TableRow className="bg-muted/30 font-medium">
<TableCell colSpan={2}>Totalt</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(data.totals.services)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(data.totals.goods)}</TableCell>
<TableCell className="text-right tabular-nums">{formatAmount(data.totals.triangulation)}</TableCell>
<TableCell colSpan={2} />
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<EmptyState
icon={FileText}
title="Inga EU-försäljningar i perioden"
description="Inga EU-försäljningar med omvänd skattskyldighet under denna period. Periodisk sammanställning behöver inte lämnas in."
>
<a
href="https://www.skatteverket.se/foretag/skatterochavdrag/moms/periodisksammanstallning.html"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 underline underline-offset-4"
>
Läs mer hos Skatteverket
<ExternalLink className="h-3 w-3" />
</a>
</EmptyState>
)}
</>
)}
</div>
)
}
function WarningItem({ warning }: { warning: PsWarning }) {
return (
<div className="flex items-start gap-2">
<span className="text-muted-foreground shrink-0">•</span>
<div className="flex-1">
<div>{warning.message}</div>
{(warning.customerId || warning.invoiceId) && (
<div className="text-xs text-muted-foreground mt-0.5 flex gap-3">
{warning.customerId && (
<Link href={`/customers/${warning.customerId}`} className="underline-offset-4 hover:underline">
Öppna kund
</Link>
)}
{warning.invoiceId && (
<Link href={`/invoices/${warning.invoiceId}`} className="underline-offset-4 hover:underline">
Öppna faktura
</Link>
)}
{warning.amount !== undefined && (
<span className="tabular-nums">{formatCurrency(warning.amount)}</span>
)}
</div>
)}
</div>
</div>
)
}
+1
View File
@@ -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' },
],
@@ -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')
}
+61
View File
@@ -107,11 +107,72 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
Enligt beslut från Skatteverket.
</p>
</div>
<div className="max-w-xs space-y-2">
<Label>Period för periodisk sammanställning</Label>
<Select
name="periodisk_sammanstallning_period"
defaultValue={settings.periodisk_sammanstallning_period || 'monthly'}
>
<SelectTrigger>
<SelectValue placeholder="Välj period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månad</SelectItem>
<SelectItem value="quarterly">Kvartal</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
</div>
)}
</div>
</section>
{/* Tax contact — required for SKV-filings */}
<section className="border-t border-border/8 pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Kontaktperson för skatteärenden
</h2>
<p className="text-xs text-muted-foreground -mt-2">
Används som avsändare på filer till Skatteverket (periodisk sammanställning m.m.).
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl">
<div className="space-y-2">
<Label htmlFor="tax_contact_name">Namn</Label>
<Input
id="tax_contact_name"
name="tax_contact_name"
defaultValue={settings.tax_contact_name || ''}
placeholder="Anna Andersson"
/>
</div>
<div className="space-y-2">
<Label htmlFor="tax_contact_phone">Telefon</Label>
<Input
id="tax_contact_phone"
name="tax_contact_phone"
defaultValue={settings.tax_contact_phone || ''}
placeholder="08-123 45 67"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="tax_contact_email">E-post</Label>
<Input
id="tax_contact_email"
name="tax_contact_email"
type="email"
defaultValue={settings.tax_contact_email || ''}
placeholder="anna@foretaget.se"
/>
</div>
</div>
</section>
{/* Fiscal year & salaries */}
<section className="border-t border-border/8 pt-8 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
+2 -2
View File
@@ -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 })
+3 -3
View File
@@ -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)
+4 -4
View File
@@ -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)
+11
View File
@@ -98,6 +98,7 @@ export const JournalEntrySourceTypeSchema = z.enum([
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'supplier_invoice_privately_paid',
'supplier_credit_note',
'currency_revaluation',
])
@@ -137,6 +138,8 @@ export const DeadlineSourceSchema = z.enum(['system', 'user'])
export const MomsPeriodSchema = z.enum(['monthly', 'quarterly', 'yearly'])
export const PsPeriodTypeSchema = z.enum(['monthly', 'quarterly'])
export const DocumentUploadSourceSchema = z.enum([
'camera', 'file_upload', 'email', 'e_invoice', 'scan', 'api', 'system',
])
@@ -263,6 +266,10 @@ export const CreateSupplierInvoiceSchema = z.object({
reverse_charge: z.boolean().optional(),
payment_reference: z.string().optional(),
notes: z.string().optional(),
paid_with_private_funds: z.boolean().optional(),
// For paid_with_private_funds: the date the owner paid out-of-pocket.
// Defaults to invoice_date (common for kvitto where the two coincide).
payment_date: isoDate.optional(),
items: z.array(CreateSupplierInvoiceItemSchema).min(1, 'At least one item is required'),
})
@@ -371,6 +378,10 @@ export const UpdateSettingsSchema = z.object({
vat_registered: z.boolean().optional(),
vat_number: z.string().regex(/^SE\d{12}$/, 'Momsregistreringsnummer måste vara SE följt av 12 siffror').nullable().optional(),
moms_period: MomsPeriodSchema.nullable().optional(),
periodisk_sammanstallning_period: PsPeriodTypeSchema.optional(),
tax_contact_name: z.string().max(200).nullable().optional(),
tax_contact_phone: z.string().max(40).nullable().optional(),
tax_contact_email: z.string().email().nullable().optional().or(z.literal('')),
fiscal_year_start_month: z.number().int().min(1).max(12).optional(),
preliminary_tax_monthly: z.number().nullable().optional(),
bank_name: z.string().max(100, 'Banknamn får vara max 100 tecken').optional(),
@@ -83,6 +83,7 @@ const {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
createSupplierCreditNoteEntry,
createSupplierInvoicePrivatelyPaidEntry,
} = await import('../supplier-invoice-entries')
function makeItem(overrides: Partial<SupplierInvoiceItem> = {}): SupplierInvoiceItem {
@@ -1140,3 +1141,164 @@ describe('createSupplierCreditNoteEntry', () => {
assertBalanced(input)
})
})
// ============================================================
// createSupplierInvoicePrivatelyPaidEntry — eget utlägg
// ============================================================
describe('createSupplierInvoicePrivatelyPaidEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('returns null when no fiscal period found', async () => {
mockedFindFiscalPeriod.mockResolvedValue(null)
const invoice = makeSupplierInvoice()
const items = [makeItem()]
const result = await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
expect(result).toBeNull()
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('AB: credits 2893 (D expense + D 2641 + C 2893)', async () => {
const invoice = makeSupplierInvoice({
subtotal: 400,
vat_amount: 100,
total: 500,
})
const items = [makeItem({ line_total: 400, account_number: '6110', vat_rate: 0.25 })]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag', 'Pressbyrån'
)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.source_type).toBe('supplier_invoice_privately_paid')
const debit6110 = findByAccount(input.lines, '6110')
expect(debit6110).toHaveLength(1)
expect(debit6110[0].debit_amount).toBe(400)
const debit2641 = findByAccount(input.lines, '2641')
expect(debit2641).toHaveLength(1)
expect(debit2641[0].debit_amount).toBe(100)
const credit2893 = findByAccount(input.lines, '2893')
expect(credit2893).toHaveLength(1)
expect(credit2893[0].credit_amount).toBe(500)
// AP account 2440 must NOT appear — privately-paid bypasses AP entirely.
expect(findByAccount(input.lines, '2440')).toHaveLength(0)
// Bank account 1930 must NOT appear — the owner paid, not the company.
expect(findByAccount(input.lines, '1930')).toHaveLength(0)
// EF owner account 2018 must NOT appear for AB.
expect(findByAccount(input.lines, '2018')).toHaveLength(0)
assertBalanced(input)
})
it('EF: credits 2018 instead of 2893', async () => {
const invoice = makeSupplierInvoice({
subtotal: 400,
vat_amount: 100,
total: 500,
})
const items = [makeItem({ line_total: 400, account_number: '6110', vat_rate: 0.25 })]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'enskild_firma', 'Pressbyrån'
)
const input = mockedCreateEntry.mock.calls[0][3]
const credit2018 = findByAccount(input.lines, '2018')
expect(credit2018).toHaveLength(1)
expect(credit2018[0].credit_amount).toBe(500)
expect(findByAccount(input.lines, '2893')).toHaveLength(0)
expect(findByAccount(input.lines, '2440')).toHaveLength(0)
assertBalanced(input)
})
it('skips 2641 line when invoice has zero VAT', async () => {
const invoice = makeSupplierInvoice({
subtotal: 500,
vat_amount: 0,
total: 500,
})
const items = [makeItem({ line_total: 500, account_number: '5460', vat_rate: 0 })]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '5460')[0].debit_amount).toBe(500)
expect(findByAccount(input.lines, '2641')).toHaveLength(0)
expect(findByAccount(input.lines, '2893')[0].credit_amount).toBe(500)
assertBalanced(input)
})
it('handles mixed-rate kvitto with separate 2641 lines per rate', async () => {
// Lunch (12%) + parking (25%) on the same kvitto
const invoice = makeSupplierInvoice({
subtotal: 200,
vat_amount: 36, // 100*0.12 + 100*0.25 = 12 + 25 = 37; off-by-one from rounding
total: 237,
})
const items = [
makeItem({ line_total: 100, account_number: '5810', vat_rate: 0.12 }),
makeItem({ line_total: 100, account_number: '5611', vat_rate: 0.25 }),
]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
const input = mockedCreateEntry.mock.calls[0][3]
// One 2641 line per rate
const vat2641 = findByAccount(input.lines, '2641')
expect(vat2641).toHaveLength(2)
// Credit 2893 = sum of all debits
const totalDebits = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const credit2893 = findByAccount(input.lines, '2893')[0]
expect(Math.round(credit2893.credit_amount * 100)).toBe(Math.round(totalDebits * 100))
assertBalanced(input)
})
it('aggregates expense lines per account number', async () => {
// Two items on the same expense account should collapse to one debit line
const invoice = makeSupplierInvoice({
subtotal: 600,
vat_amount: 150,
total: 750,
})
const items = [
makeItem({ line_total: 300, account_number: '6110', vat_rate: 0.25 }),
makeItem({ line_total: 300, account_number: '6110', vat_rate: 0.25 }),
]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
const input = mockedCreateEntry.mock.calls[0][3]
const debit6110 = findByAccount(input.lines, '6110')
expect(debit6110).toHaveLength(1)
expect(debit6110[0].debit_amount).toBe(600)
})
})
@@ -388,6 +388,92 @@ export async function createSupplierInvoiceCashEntry(
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* Create journal entry for an invoice paid with the owner's private funds
* (eget utlägg). The AP leg is bypassed entirely — instead of crediting 2440
* and later debiting it on mark-paid, the expense lines book straight against
* the owner's payable/equity account:
*
* Debit 5xxx/6xxx (per item) [line_total in SEK]
* Debit 2641 Ingående moms [VAT per rate]
* Credit 2893 / 2018 [total incl VAT]
*
* Reverse charge is intentionally not supported here. RC invoices are
* never "I paid this cash at a kiosk" cases — they're EU/byggtjänster from
* registered businesses with formal invoices, which always go through AP.
* The API route guards against this combo before calling us.
*/
export async function createSupplierInvoicePrivatelyPaidEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
entityType: 'aktiebolag' | 'enskild_firma',
supplierName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, invoice.invoice_date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for invoice date:', invoice.invoice_date)
return null
}
const ownerAccount = entityType === 'aktiebolag' ? '2893' : '2018'
const desc = buildSupplierDescription('Eget utlägg', invoice.supplier_invoice_number, supplierName, `(ankomst ${invoice.arrival_number})`)
const lines: CreateJournalEntryLineInput[] = []
// Debit: Expense accounts (in SEK), aggregated per account
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
expenseByAccount.set(item.account_number, current + itemSek)
}
for (const [accountNumber, amount] of expenseByAccount) {
lines.push({
account_number: accountNumber,
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: desc,
})
}
// Debit: Ingående moms per rate group (mixed-rate kvitto support)
if (invoice.vat_amount > 0) {
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
for (const [rate, amount] of vatByRate) {
if (amount > 0) {
lines.push({
account_number: '2641',
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
})
}
}
}
// Credit: Owner payable/equity — balance guarantee
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
lines.push({
account_number: ownerAccount,
debit_amount: 0,
credit_amount: Math.round(totalDebits * 100) / 100,
line_description: desc,
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: invoice.invoice_date,
description: desc,
source_type: 'supplier_invoice_privately_paid',
source_id: invoice.id,
lines,
}
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* Create journal entry for a supplier credit note (reversal of registration)
*
+44
View File
@@ -696,6 +696,44 @@ const VAT_REPORT: Record<string, StructuredErrorEntry> = {
},
}
const PS_REPORT: Record<string, StructuredErrorEntry> = {
PS_REPORT_MISSING_PARAMS: {
httpStatus: 400,
message_sv: 'periodType, year och period krävs.',
message_en: 'periodType, year and period query parameters are required.',
},
PS_REPORT_INVALID_PERIOD_TYPE: {
httpStatus: 400,
message_sv: 'periodType måste vara monthly eller quarterly.',
message_en: 'periodType must be monthly or quarterly.',
},
PS_REPORT_INVALID_YEAR: {
httpStatus: 400,
message_sv: 'year måste vara ett giltigt årtal mellan 2000 och 2100.',
message_en: 'year must be a number between 2000 and 2100.',
},
PS_REPORT_INVALID_PERIOD: {
httpStatus: 400,
message_sv: 'period är ogiltig för vald periodtyp.',
message_en: 'period is invalid for the chosen period type.',
},
PS_REPORT_GENERATION_FAILED: {
httpStatus: 500,
message_sv: 'Periodisk sammanställning kunde inte beräknas.',
message_en: 'Failed to generate periodisk sammanställning.',
},
PS_REPORT_CSV_BLOCKED_BY_ERRORS: {
httpStatus: 400,
message_sv: 'CSV kan inte laddas ner. Åtgärda blockerande fel först.',
message_en: 'CSV download blocked by validation errors. Fix them first.',
},
PS_REPORT_MISSING_FILER_INFO: {
httpStatus: 400,
message_sv: 'Kontaktuppgifter saknas. Fyll i namn, telefon och e-post under Inställningar.',
message_en: 'Tax contact information is missing on company_settings.',
},
}
const SIE_EXPORT: Record<string, StructuredErrorEntry> = {
SIE_EXPORT_COMPANY_NOT_FOUND: {
httpStatus: 404,
@@ -1167,6 +1205,11 @@ const SUPPLIER_INVOICE_WAVE4: Record<string, StructuredErrorEntry> = {
message_sv: 'Leverantörsfakturan kunde inte skapas.',
message_en: 'Failed to create supplier invoice.',
},
SI_CREATE_INVALID_INPUT: {
httpStatus: 400,
message_sv: 'Ogiltig kombination av fakturafält. Kontrollera formuläret och försök igen.',
message_en: 'Invalid combination of supplier invoice fields.',
},
SI_PAID_ALREADY: {
httpStatus: 409,
message_sv: 'Leverantörsfakturan är redan betald eller krediterad.',
@@ -1365,6 +1408,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
...FX,
...REPORT,
...VAT_REPORT,
...PS_REPORT,
...SIE_EXPORT,
...TAX_DECL,
...SIE_IMPORT,
@@ -120,6 +120,34 @@ describe('createExtensionContext', () => {
expect(supabase.from).toHaveBeenCalledWith('extension_data')
})
it('settings.set() throws when supabase returns an error', async () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: null, error: { message: 'null value in column "value" violates not-null constraint' } })
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'test-ext')
await expect(ctx.settings.set('my-key', null)).rejects.toThrow(/extension_data set failed/)
})
it('settings.clear() deletes from extension_data table', async () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: null, error: null })
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'test-ext')
await ctx.settings.clear('my-key')
expect(supabase.from).toHaveBeenCalledWith('extension_data')
})
it('settings.clear() throws when supabase returns an error', async () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: null, error: { message: 'permission denied' } })
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'test-ext')
await expect(ctx.settings.clear('my-key')).rejects.toThrow(/extension_data clear failed/)
})
it('storage.getPublicUrl() returns URL string', () => {
const { supabase } = createMockSupabase()
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'test-ext')
+16 -1
View File
@@ -50,7 +50,7 @@ function createSettings(
},
async set<T>(key: string, value: T): Promise<void> {
await supabase
const { error } = await supabase
.from('extension_data')
.upsert(
{
@@ -62,6 +62,21 @@ function createSettings(
},
{ onConflict: 'company_id,extension_id,key' }
)
if (error) {
throw new Error(`extension_data set failed for ${extensionId}/${key}: ${error.message}`)
}
},
async clear(key: string): Promise<void> {
const { error } = await supabase
.from('extension_data')
.delete()
.eq('company_id', companyId)
.eq('extension_id', extensionId)
.eq('key', key)
if (error) {
throw new Error(`extension_data clear failed for ${extensionId}/${key}: ${error.message}`)
}
},
}
}
+6
View File
@@ -149,6 +149,12 @@ export interface ExtensionLogger {
export interface ExtensionSettings {
get<T>(key?: string): Promise<T | null>
set<T>(key: string, value: T): Promise<void>
/**
* Remove a stored key. Use this to clear state instead of `set(key, null)`,
* which fails against the `value jsonb NOT NULL` constraint on extension_data.
* No-op when the key does not exist.
*/
clear(key: string): Promise<void>
}
/** Storage accessor wrapping Supabase storage */
@@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest'
import {
buildPeriodiskSammanstallningCsv,
formatPeriodCode,
PsCsvBuildError,
} from '../periodisk-sammanstallning-csv'
import type { PeriodiskSammanstallningReport } from '../periodisk-sammanstallning'
function mkReport(partial: Partial<PeriodiskSammanstallningReport> = {}): PeriodiskSammanstallningReport {
return {
period: { type: 'monthly', year: 2025, period: 5, start: '2025-05-01', end: '2025-05-31', label: 'Maj 2025' },
rows: [],
warnings: [],
totals: { services: 0, goods: 0, triangulation: 0, grand: 0, rowCount: 0 },
reconciliation: { ruta39: null, ruta35: null, ruta38: null, matches: null, tolerance: 1 },
...partial,
}
}
const FILER = {
organizationNumber: '5560000167',
contactName: 'Per Persson',
contactPhone: '0123-45690',
contactEmail: 'post@filmkopia.se',
}
describe('formatPeriodCode', () => {
it('monthly: YYMM zero-padded', () => {
expect(formatPeriodCode('monthly', 2025, 5)).toBe('2505')
expect(formatPeriodCode('monthly', 2020, 1)).toBe('2001')
expect(formatPeriodCode('monthly', 2025, 12)).toBe('2512')
})
it('quarterly: YY-Q with hyphen', () => {
expect(formatPeriodCode('quarterly', 2025, 2)).toBe('25-2')
expect(formatPeriodCode('quarterly', 2022, 4)).toBe('22-4')
})
it('throws on invalid month', () => {
expect(() => formatPeriodCode('monthly', 2025, 13)).toThrow(PsCsvBuildError)
expect(() => formatPeriodCode('monthly', 2025, 0)).toThrow()
})
it('throws on invalid quarter', () => {
expect(() => formatPeriodCode('quarterly', 2025, 5)).toThrow()
})
})
describe('buildPeriodiskSammanstallningCsv', () => {
it('emits SKV574008 header + filer line + rows', () => {
const report = mkReport({
rows: [
{ country: 'DE', vatNumber: '123456789', services: 10000, goods: 0, triangulation: 0, customerId: null, customerName: null, hasBlockingIssue: false },
{ country: 'FI', vatNumber: '01409351', services: 0, goods: 5000, triangulation: 0, customerId: null, customerName: null, hasBlockingIssue: false },
],
totals: { services: 10000, goods: 5000, triangulation: 0, grand: 15000, rowCount: 2 },
})
const { content } = buildPeriodiskSammanstallningCsv(report, FILER)
const text = content.toString('latin1')
const expected =
'SKV574008;\r\n' +
'5560000167;2505;Per Persson;0123-45690;post@filmkopia.se\r\n' +
'DE123456789;10000;;;\r\n' +
'FI01409351;;5000;;\r\n'
expect(text).toBe(expected)
})
it('emits empty fields rather than zeros', () => {
const report = mkReport({
rows: [{ country: 'DE', vatNumber: '123', services: 1000, goods: 0, triangulation: 0, customerId: null, customerName: null, hasBlockingIssue: false }],
totals: { services: 1000, goods: 0, triangulation: 0, grand: 1000, rowCount: 1 },
})
const text = buildPeriodiskSammanstallningCsv(report, FILER).content.toString('latin1')
expect(text).toContain('DE123;1000;;;')
expect(text).not.toContain(';0;')
})
it('renders negative amounts as signed integers', () => {
const report = mkReport({
rows: [{ country: 'DE', vatNumber: '123', services: -1234, goods: 0, triangulation: 0, customerId: null, customerName: null, hasBlockingIssue: false }],
totals: { services: -1234, goods: 0, triangulation: 0, grand: -1234, rowCount: 1 },
})
const text = buildPeriodiskSammanstallningCsv(report, FILER).content.toString('latin1')
expect(text).toContain('DE123;-1234;;;')
})
it('latin1 encoding preserves Swedish characters', () => {
const report = mkReport()
const filer = { ...FILER, contactName: 'Åsa Östberg' }
const { content } = buildPeriodiskSammanstallningCsv(report, filer)
// Round-trip via latin1.
const text = content.toString('latin1')
expect(text).toContain('Åsa Östberg')
// Confirm bytes are single-byte latin1, not utf-8 (Å in utf-8 is 0xC3 0x85; in latin1 it's 0xC5).
const aRingByte = content.indexOf(0xC5)
expect(aRingByte).toBeGreaterThan(0)
})
it('refuses to build with blocking errors', () => {
const report = mkReport({
warnings: [{ level: 'error', code: 'MISSING_COUNTRY', message: 'oops' }],
})
expect(() => buildPeriodiskSammanstallningCsv(report, FILER)).toThrow(PsCsvBuildError)
})
it('refuses to build when filer info missing', () => {
const report = mkReport()
expect(() =>
buildPeriodiskSammanstallningCsv(report, { ...FILER, contactEmail: '' }),
).toThrow(PsCsvBuildError)
})
it('skips rows that round to zero in all buckets', () => {
const report = mkReport({
rows: [
{ country: 'DE', vatNumber: '111', services: 100, goods: 0, triangulation: 0, customerId: null, customerName: null, hasBlockingIssue: false },
{ country: 'FR', vatNumber: '222', services: 0, goods: 0, triangulation: 0, customerId: null, customerName: null, hasBlockingIssue: false },
],
totals: { services: 100, goods: 0, triangulation: 0, grand: 100, rowCount: 2 },
})
const text = buildPeriodiskSammanstallningCsv(report, FILER).content.toString('latin1')
expect(text).toContain('DE111')
expect(text).not.toContain('FR222')
})
it('filename uses orgnr + YYMM for monthly', () => {
const report = mkReport()
const { filename } = buildPeriodiskSammanstallningCsv(report, FILER)
expect(filename).toBe('Periodisk_sammanstallning_5560000167_2505.csv')
})
it('filename uses orgnr + YYQQ for quarterly', () => {
const report = mkReport({
period: { type: 'quarterly', year: 2025, period: 2, start: '2025-04-01', end: '2025-06-30', label: 'Kvartal 2 2025' },
})
const { filename } = buildPeriodiskSammanstallningCsv(report, FILER)
expect(filename).toBe('Periodisk_sammanstallning_5560000167_25Q2.csv')
})
it('strips formatting from organization number', () => {
const { filename, content } = buildPeriodiskSammanstallningCsv(mkReport(), {
...FILER,
organizationNumber: '556000-0167',
})
expect(filename).toContain('5560000167')
expect(content.toString('latin1')).toContain('5560000167;')
})
})
@@ -0,0 +1,412 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue (mirrors vat-declaration.test.ts)
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
import {
generatePeriodiskSammanstallning,
normalizeVatNumber,
reconcilePsAgainstVatDeclaration,
} from '../periodisk-sammanstallning'
import { calculatePeriodDates, formatPeriodLabel } from '../period-dates'
let supabase: ReturnType<typeof makeClient>
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
supabase = makeClient()
})
// ============================================================
// Pure helpers
// ============================================================
describe('calculatePeriodDates', () => {
it('monthly January', () => {
expect(calculatePeriodDates('monthly', 2025, 1)).toEqual({ start: '2025-01-01', end: '2025-01-31' })
})
it('monthly December', () => {
expect(calculatePeriodDates('monthly', 2025, 12)).toEqual({ start: '2025-12-01', end: '2025-12-31' })
})
it('quarterly Q2', () => {
expect(calculatePeriodDates('quarterly', 2025, 2)).toEqual({ start: '2025-04-01', end: '2025-06-30' })
})
it('quarterly Q4', () => {
expect(calculatePeriodDates('quarterly', 2025, 4)).toEqual({ start: '2025-10-01', end: '2025-12-31' })
})
})
describe('formatPeriodLabel', () => {
it('monthly', () => expect(formatPeriodLabel('monthly', 2025, 5)).toBe('Maj 2025'))
it('quarterly', () => expect(formatPeriodLabel('quarterly', 2025, 2)).toBe('Kvartal 2 2025'))
})
describe('normalizeVatNumber', () => {
it('strips Swedish country prefix', () => {
expect(normalizeVatNumber('SE556677889901')).toBe('556677889901')
})
it('strips whitespace and uppercases', () => {
expect(normalizeVatNumber(' de 123456789 ')).toBe('123456789')
})
it('handles EL prefix', () => {
expect(normalizeVatNumber('EL123456789')).toBe('123456789')
})
it('handles already-stripped numbers', () => {
expect(normalizeVatNumber('556677889901')).toBe('556677889901')
})
it('handles null/empty', () => {
expect(normalizeVatNumber(null)).toBe('')
expect(normalizeVatNumber('')).toBe('')
})
})
// ============================================================
// Generator
// ============================================================
interface InvoiceFx {
id: string
customer: {
id: string
name: string
country: string | null
vat_number: string | null
vat_number_validated?: boolean
vat_number_validated_at?: string | null
} | null
}
// Recent validation so VIES_UNVALIDATED warnings don't fire by default.
const RECENT = new Date().toISOString()
function lineEU(account: string, credit: number, sourceId: string) {
return {
account_number: account,
debit_amount: 0,
credit_amount: credit,
journal_entries: {
company_id: 'c1',
entry_date: '2025-05-15',
status: 'posted',
source_type: 'invoice_created',
source_id: sourceId,
},
}
}
function lineCredit(account: string, debit: number, sourceId: string) {
return {
account_number: account,
debit_amount: debit,
credit_amount: 0,
journal_entries: {
company_id: 'c1',
entry_date: '2025-05-20',
status: 'posted',
source_type: 'credit_note',
source_id: sourceId,
},
}
}
function invDE(id = 'inv-de', customer = 'cust-de', name = 'DE Customer', vat = 'DE123456789'): InvoiceFx {
return {
id,
customer: {
id: customer,
name,
country: 'DE',
vat_number: vat,
vat_number_validated: true,
vat_number_validated_at: RECENT,
},
}
}
describe('generatePeriodiskSammanstallning', () => {
it('empty period returns zero rows and zero warnings', async () => {
results = [{ data: [], error: null }]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toEqual([])
expect(report.warnings).toEqual([])
expect(report.totals.rowCount).toBe(0)
expect(report.totals.grand).toBe(0)
expect(report.period.label).toBe('Maj 2025')
})
it('single EU service sale → 1 row, type 3 only', async () => {
results = [
{ data: [lineEU('3308', 10000, 'inv-de')], error: null },
{ data: [invDE()], error: null },
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toHaveLength(1)
expect(report.rows[0]).toMatchObject({
country: 'DE',
vatNumber: '123456789',
services: 10000,
goods: 0,
triangulation: 0,
})
expect(report.totals).toMatchObject({ services: 10000, goods: 0, triangulation: 0, grand: 10000, rowCount: 1 })
expect(report.warnings).toEqual([])
})
it('aggregates multiple invoices to same customer', async () => {
results = [
{
data: [
lineEU('3308', 4000, 'inv1'),
lineEU('3308', 3500, 'inv2'),
lineEU('3308', 2500, 'inv3'),
],
error: null,
},
{
data: [
{ ...invDE('inv1') },
{ ...invDE('inv2') },
{ ...invDE('inv3') },
],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toHaveLength(1)
expect(report.rows[0].services).toBe(10000)
})
it('one customer with both services and goods → 1 row with both filled', async () => {
results = [
{
data: [
lineEU('3308', 7000, 'inv1'),
lineEU('3108', 5000, 'inv2'),
],
error: null,
},
{
data: [invDE('inv1'), invDE('inv2')],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toHaveLength(1)
expect(report.rows[0]).toMatchObject({ services: 7000, goods: 5000, triangulation: 0 })
})
it('credit invoice nets against original in same period', async () => {
results = [
{
data: [
lineEU('3308', 10000, 'inv1'),
lineCredit('3308', 3000, 'cn1'),
],
error: null,
},
{
data: [invDE('inv1'), invDE('cn1')],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toHaveLength(1)
expect(report.rows[0].services).toBe(7000)
})
it('credit fully cancels → row excluded with ZERO_NET_EXCLUDED warning', async () => {
results = [
{
data: [
lineEU('3308', 10000, 'inv1'),
lineCredit('3308', 10000, 'cn1'),
],
error: null,
},
{ data: [invDE('inv1'), invDE('cn1')], error: null },
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toHaveLength(0)
expect(report.warnings.some(w => w.code === 'ZERO_NET_EXCLUDED')).toBe(true)
})
it('customer missing country → MISSING_COUNTRY error and row blocked', async () => {
results = [
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
{
data: [{
id: 'inv1',
customer: { id: 'c1', name: 'No Country', country: null, vat_number: 'DE123', vat_number_validated: true, vat_number_validated_at: RECENT },
}],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.warnings.some(w => w.code === 'MISSING_COUNTRY' && w.level === 'error')).toBe(true)
expect(report.rows[0]?.hasBlockingIssue).toBe(true)
})
it('customer missing vat_number → MISSING_VAT_NUMBER error', async () => {
results = [
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
{
data: [{
id: 'inv1',
customer: { id: 'c1', name: 'No VAT', country: 'DE', vat_number: null, vat_number_validated: false, vat_number_validated_at: null },
}],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.warnings.some(w => w.code === 'MISSING_VAT_NUMBER' && w.level === 'error')).toBe(true)
expect(report.rows[0]?.hasBlockingIssue).toBe(true)
})
it('VAT prefix mismatch surfaces COUNTRY_PREFIX_MISMATCH warning', async () => {
results = [
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
{
data: [{
id: 'inv1',
customer: { id: 'c1', name: 'Mixed', country: 'DE', vat_number: 'FR123456', vat_number_validated: true, vat_number_validated_at: RECENT },
}],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.warnings.some(w => w.code === 'COUNTRY_PREFIX_MISMATCH')).toBe(true)
expect(report.rows).toHaveLength(1)
})
it('non-EU country on EU account → NON_EU_COUNTRY_ON_EU_ACCOUNT and excluded from CSV', async () => {
results = [
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
{
data: [{
id: 'inv1',
customer: { id: 'c1', name: 'US Co', country: 'US', vat_number: 'US123', vat_number_validated: true, vat_number_validated_at: RECENT },
}],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.warnings.some(w => w.code === 'NON_EU_COUNTRY_ON_EU_ACCOUNT')).toBe(true)
expect(report.rows[0].hasBlockingIssue).toBe(true)
})
it('Greek customer → country code emitted as EL', async () => {
results = [
{ data: [lineEU('3308', 4200, 'inv1')], error: null },
{
data: [{
id: 'inv1',
customer: { id: 'c1', name: 'Hellas', country: 'GR', vat_number: 'EL123456', vat_number_validated: true, vat_number_validated_at: RECENT },
}],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows[0].country).toBe('EL')
})
it('goods sold in quarterly period → GOODS_SOLD_WITH_QUARTERLY_PERIOD warning', async () => {
results = [
{ data: [lineEU('3108', 9000, 'inv1')], error: null },
{ data: [{ ...invDE('inv1') }], error: null },
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'quarterly', 2025, 2)
expect(report.warnings.some(w => w.code === 'GOODS_SOLD_WITH_QUARTERLY_PERIOD')).toBe(true)
})
it('sorts rows by country then vat_number', async () => {
results = [
{
data: [
lineEU('3308', 1000, 'inv-fr'),
lineEU('3308', 2000, 'inv-de'),
lineEU('3308', 3000, 'inv-at'),
],
error: null,
},
{
data: [
{ id: 'inv-fr', customer: { id: 'fr', name: 'FR', country: 'FR', vat_number: 'FR999', vat_number_validated: true, vat_number_validated_at: RECENT } },
{ id: 'inv-de', customer: { id: 'de', name: 'DE', country: 'DE', vat_number: 'DE888', vat_number_validated: true, vat_number_validated_at: RECENT } },
{ id: 'inv-at', customer: { id: 'at', name: 'AT', country: 'AT', vat_number: 'ATU111', vat_number_validated: true, vat_number_validated_at: RECENT } },
],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows.map(r => r.country)).toEqual(['AT', 'DE', 'FR'])
})
it('rejects yearly period type', async () => {
await expect(
generatePeriodiskSammanstallning(supabase, 'c1', 'yearly' as 'monthly', 2025, 1),
).rejects.toThrow()
})
})
// ============================================================
// Reconciliation
// ============================================================
describe('reconcilePsAgainstVatDeclaration', () => {
it('returns null matches when periods do not coincide', async () => {
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'quarterly', 2025, 2)
// No data calls expected — function bails before invoking calculateVatDeclaration.
results = []
const reconciled = await reconcilePsAgainstVatDeclaration(supabase, 'c1', report, 'monthly')
expect(reconciled.reconciliation.matches).toBeNull()
})
})
+78
View File
@@ -0,0 +1,78 @@
/**
* Period date helpers shared between report generators (momsdeklaration,
* periodisk sammanställning, etc.). Kept tiny on purpose — anything domain-
* specific belongs in the calling module.
*/
export type PeriodType = 'monthly' | 'quarterly' | 'yearly'
/**
* Calculate inclusive start and end dates for a fiscal-calendar period.
*
* monthly: period 1-12, one calendar month
* quarterly: period 1-4, three calendar months
* yearly: period 1, full calendar year
*/
export function calculatePeriodDates(
periodType: PeriodType,
year: number,
period: number,
): { start: string; end: string } {
let startMonth: number
let endMonth: number
switch (periodType) {
case 'monthly':
startMonth = period
endMonth = period
break
case 'quarterly':
startMonth = (period - 1) * 3 + 1
endMonth = period * 3
break
case 'yearly':
startMonth = 1
endMonth = 12
break
default:
startMonth = 1
endMonth = 12
}
const startDate = new Date(year, startMonth - 1, 1)
const endDate = new Date(year, endMonth, 0)
return {
start: formatDate(startDate),
end: formatDate(endDate),
}
}
export function formatDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
const SWEDISH_MONTHS = [
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
] as const
export function formatPeriodLabel(
periodType: PeriodType,
year: number,
period: number,
): string {
switch (periodType) {
case 'monthly':
return `${SWEDISH_MONTHS[period - 1]} ${year}`
case 'quarterly':
return `Kvartal ${period} ${year}`
case 'yearly':
return `Helår ${year}`
default:
return `${year}`
}
}
@@ -0,0 +1,159 @@
/**
* SKV574008 CSV serializer for periodisk sammanställning.
*
* Format (Skatteverket):
*
* SKV574008;
* {orgnr};{period};{kontaktnamn};{telefon};{email}
* {landskod+vatnr};{tjänster};{varor};{trepartshandel};
* ...
*
* - Semicolon-separated, ISO-8859-1 (Latin-1) encoded, CRLF terminated.
* - Period codes: YYMM for monthly (e.g. "2505"), YY-Q for quarterly
* (e.g. "25-2"). Verified against Skatteverket's current spec.
* - Amounts are whole kronor, signed integers (negative allowed).
* - Empty buckets are blank fields (`;;`), never `0;0`.
* - Avropslager codes X/Y/Z are NOT emitted in v1 — only numeric amounts.
*
* The serializer refuses to build if any row has hasBlockingIssue=true or any
* warning is error-level. Caller (API route) returns 400 to the client.
*/
import type { PeriodiskSammanstallningReport } from './periodisk-sammanstallning'
export interface CsvFilerInfo {
organizationNumber: string // 10 or 12 digits, dashes/spaces stripped
contactName: string
contactPhone: string
contactEmail: string
}
export class PsCsvBuildError extends Error {
constructor(
public readonly reason:
| 'BLOCKING_WARNINGS'
| 'MISSING_FILER_INFO'
| 'INVALID_PERIOD_CODE',
message: string,
) {
super(message)
this.name = 'PsCsvBuildError'
}
}
const PERIOD_MONTHLY_RE = /^\d{2}(0[1-9]|1[0-2])$/
const PERIOD_QUARTERLY_RE = /^\d{2}-[1-4]$/
/**
* Encode the period for the file header.
* monthly: YYMM (e.g. 2505 for May 2025)
* quarterly: YY-Q (e.g. 25-2 for Q2 2025)
*/
export function formatPeriodCode(
type: 'monthly' | 'quarterly',
year: number,
period: number,
): string {
const yy = String(year % 100).padStart(2, '0')
if (type === 'monthly') {
if (period < 1 || period > 12) {
throw new PsCsvBuildError('INVALID_PERIOD_CODE', `Ogiltig månad: ${period}`)
}
return `${yy}${String(period).padStart(2, '0')}`
}
if (period < 1 || period > 4) {
throw new PsCsvBuildError('INVALID_PERIOD_CODE', `Ogiltigt kvartal: ${period}`)
}
return `${yy}-${period}`
}
function normalizeOrgNumber(raw: string): string {
return (raw ?? '').replace(/[^\d]/g, '')
}
function csvField(value: string): string {
// Skatteverket's CSV uses semicolon as separator. Any embedded semicolon or
// newline would corrupt the line; strip them rather than quote (Skatteverket
// does not document a quoting mechanism for SKV574008).
return value.replace(/[;\r\n]/g, ' ').trim()
}
function emptyOrAmount(n: number): string {
if (!Number.isFinite(n) || n === 0) return ''
return String(Math.trunc(n))
}
export function buildPeriodiskSammanstallningCsv(
report: PeriodiskSammanstallningReport,
filer: CsvFilerInfo,
): { filename: string; content: Buffer; mimeType: string } {
// 1. Filer info present?
const orgnr = normalizeOrgNumber(filer.organizationNumber)
if (!orgnr || !filer.contactName?.trim() || !filer.contactPhone?.trim() || !filer.contactEmail?.trim()) {
throw new PsCsvBuildError(
'MISSING_FILER_INFO',
'Kontaktuppgifter för skatterapportering saknas. Fyll i namn, telefon och e-post under Inställningar → Företag.',
)
}
// 2. No blocking warnings?
const blockingErrors = report.warnings.filter(w => w.level === 'error')
if (blockingErrors.length > 0) {
throw new PsCsvBuildError(
'BLOCKING_WARNINGS',
`${blockingErrors.length} blockerande fel måste åtgärdas innan CSV kan laddas ner.`,
)
}
// 3. Period code.
const periodCode = formatPeriodCode(report.period.type, report.period.year, report.period.period)
// Defensive — formatPeriodCode already validates, but double-check via regex.
const re = report.period.type === 'monthly' ? PERIOD_MONTHLY_RE : PERIOD_QUARTERLY_RE
if (!re.test(periodCode)) {
throw new PsCsvBuildError('INVALID_PERIOD_CODE', `Periodkod ${periodCode} matchar inte SKV-format.`)
}
// 4. Assemble lines.
const lines: string[] = []
lines.push('SKV574008;')
lines.push(
[
csvField(orgnr),
csvField(periodCode),
csvField(filer.contactName),
csvField(filer.contactPhone),
csvField(filer.contactEmail),
].join(';'),
)
for (const row of report.rows) {
// Skip rows where all three buckets round to zero (defensive — the
// generator already drops them, but a row could be passed in manually).
if (row.services === 0 && row.goods === 0 && row.triangulation === 0) continue
const vatField = `${row.country}${row.vatNumber}`
lines.push(
[
csvField(vatField),
emptyOrAmount(row.services),
emptyOrAmount(row.goods),
emptyOrAmount(row.triangulation),
'',
].join(';'),
)
}
// CRLF line endings, ISO-8859-1 encoded.
const text = lines.join('\r\n') + '\r\n'
const content = Buffer.from(text, 'latin1')
const periodLabel = report.period.type === 'monthly'
? periodCode // 2505
: periodCode.replace('-', 'Q') // 25Q2 for filename clarity
return {
filename: `Periodisk_sammanstallning_${orgnr}_${periodLabel}.csv`,
content,
mimeType: 'text/csv; charset=iso-8859-1',
}
}
+528
View File
@@ -0,0 +1,528 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { calculatePeriodDates, formatPeriodLabel } from './period-dates'
import { calculateVatDeclaration } from './vat-declaration'
/**
* Periodisk sammanställning (EC Sales List / SKV 5740).
*
* Pure projection from the general ledger: posted journal entry lines on the
* three EU revenue accounts (3308 services, 3108 goods, 3107 triangulation)
* are joined back to the originating invoice and customer to produce one row
* per (country, vat_number) with three amount buckets.
*
* Shares its source data with vat-declaration.ts so the PS totals and the
* momsdeklaration Ruta 35/38/39 can never drift. See §1.2 of the plan.
*
* Notes:
* - Account 3305/3105 (non-EU export) are NOT in this report — they go to
* Ruta 36/40 only.
* - Trepartshandel (3107) is included so the report works if someone posts
* there manually; the invoicing UI doesn't post there today (v2).
* - Avropslager codes X/Y/Z are deferred to v2 — the CSV serializer asserts
* only numeric amounts in v1.
*/
export type PsPeriodType = 'monthly' | 'quarterly'
export interface PsRow {
country: string // 2-char, EL for Grekland
vatNumber: string // normalized, no country prefix
services: number // typ 3 (account 3308), hela kronor
goods: number // typ 1 (account 3108)
triangulation: number // typ 2 (account 3107)
customerId: string | null
customerName: string | null
hasBlockingIssue: boolean
}
export type PsWarningCode =
| 'MISSING_COUNTRY'
| 'MISSING_VAT_NUMBER'
| 'VIES_UNVALIDATED'
| 'COUNTRY_PREFIX_MISMATCH'
| 'NON_EU_COUNTRY_ON_EU_ACCOUNT'
| 'CUSTOMER_NOT_FOUND'
| 'ZERO_NET_EXCLUDED'
| 'GOODS_SOLD_WITH_QUARTERLY_PERIOD'
export interface PsWarning {
level: 'error' | 'warning'
code: PsWarningCode
message: string
customerId?: string
customerName?: string
invoiceId?: string
amount?: number
}
export interface PeriodiskSammanstallningReport {
period: {
type: PsPeriodType
year: number
period: number
start: string
end: string
label: string
}
rows: PsRow[]
warnings: PsWarning[]
totals: {
services: number
goods: number
triangulation: number
grand: number
rowCount: number
}
reconciliation: {
ruta39: number | null
ruta35: number | null
ruta38: number | null
matches: boolean | null
tolerance: number
}
}
/** ISO 3166-1 alpha-2 codes for EU member states (post-Brexit, excl. UK). */
const EU_COUNTRIES = new Set([
'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI',
'FR', 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT',
'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK',
])
/** Skatteverket uses EL for Greece; ISO uses GR. */
function toVatCountryCode(iso: string): string {
return iso === 'GR' ? 'EL' : iso.toUpperCase()
}
const ACCOUNT_TO_BUCKET: Record<string, 'services' | 'goods' | 'triangulation'> = {
'3308': 'services',
'3108': 'goods',
'3107': 'triangulation',
}
const PS_ACCOUNTS = Object.keys(ACCOUNT_TO_BUCKET)
interface RawLine {
account_number: string
debit_amount: number | string
credit_amount: number | string
journal_entries: {
company_id: string
entry_date: string
status: string
source_type: string
source_id: string | null
} | null
}
interface RawInvoice {
id: string
customer_id: string | null
customer: {
id: string
name: string
country: string | null
vat_number: string | null
vat_number_validated: boolean | null
vat_number_validated_at: string | null
} | null
}
/**
* Strip optional leading country prefix and whitespace; uppercase the rest.
*
* Examples:
* "SE556677889901" → "556677889901"
* " de 123456789 " → "123456789"
* "el123" → "123"
*/
export function normalizeVatNumber(raw: string | null | undefined): string {
if (!raw) return ''
const stripped = raw.replace(/\s+/g, '').toUpperCase()
// Skatteverket prefixes are two letters; EL is intentionally treated the same.
if (/^[A-Z]{2}/.test(stripped)) return stripped.slice(2)
return stripped
}
function round(value: number): number {
return Math.round(value)
}
interface Accumulator {
country: string
vatNumber: string
customerId: string | null
customerName: string | null
services: number
goods: number
triangulation: number
blocking: boolean
/** True once we've seen any non-zero posting, even if it later nets to zero. */
sawActivity: boolean
}
export async function generatePeriodiskSammanstallning(
supabase: SupabaseClient,
companyId: string,
periodType: PsPeriodType,
year: number,
period: number,
): Promise<PeriodiskSammanstallningReport> {
if (periodType !== 'monthly' && periodType !== 'quarterly') {
throw new Error(`Invalid PS periodType: ${periodType}`)
}
if (periodType === 'monthly' && (period < 1 || period > 12)) {
throw new Error(`Invalid monthly period: ${period}`)
}
if (periodType === 'quarterly' && (period < 1 || period > 4)) {
throw new Error(`Invalid quarterly period: ${period}`)
}
const { start, end } = calculatePeriodDates(periodType, year, period)
const lines = await fetchAllRows<RawLine>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select(`
account_number,
debit_amount,
credit_amount,
journal_entries!inner (
company_id, entry_date, status, source_type, source_id
)
`)
.in('account_number', PS_ACCOUNTS)
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
// Cash sales on 3308/3108 are not a real flow (EU reverse-charge sales
// always go through AR); excluded to avoid phantom rows.
.in('journal_entries.source_type', ['invoice_created', 'credit_note'])
.gte('journal_entries.entry_date', start)
.lte('journal_entries.entry_date', end)
.range(from, to) as unknown as PromiseLike<{ data: RawLine[] | null; error: { message: string } | null }>,
)
const invoiceIds = Array.from(
new Set(
lines
.map(l => l.journal_entries?.source_id)
.filter((id): id is string => typeof id === 'string'),
),
)
const invoiceMap = new Map<string, RawInvoice>()
if (invoiceIds.length > 0) {
const invoices = await fetchAllRows<RawInvoice>(({ from, to }) =>
supabase
.from('invoices')
.select(`
id,
customer_id,
customer:customers (
id,
name,
country,
vat_number,
vat_number_validated,
vat_number_validated_at
)
`)
.in('id', invoiceIds)
.range(from, to) as unknown as PromiseLike<{ data: RawInvoice[] | null; error: { message: string } | null }>,
)
for (const inv of invoices) invoiceMap.set(inv.id, inv)
}
const accumulators = new Map<string, Accumulator>()
const warnings: PsWarning[] = []
let goodsLineSeen = false
for (const line of lines) {
const je = line.journal_entries
if (!je) continue
const sourceId = je.source_id
const invoice = sourceId ? invoiceMap.get(sourceId) : null
const bucket = ACCOUNT_TO_BUCKET[line.account_number]
if (!bucket) continue
if (bucket === 'goods' || bucket === 'triangulation') goodsLineSeen = true
const debit = Number(line.debit_amount) || 0
const credit = Number(line.credit_amount) || 0
const net = credit - debit
const customer = invoice?.customer ?? null
if (!invoice || !customer) {
warnings.push({
level: 'error',
code: 'CUSTOMER_NOT_FOUND',
message: 'Kund saknas på faktura. Kontakta support innan inlämning.',
invoiceId: invoice?.id,
amount: net,
})
bucketRow(accumulators, '??', '??', null, null, bucket, net, true)
continue
}
const isoCountry = (customer.country ?? '').trim().toUpperCase()
const vatCountry = isoCountry ? toVatCountryCode(isoCountry) : ''
const rawVat = customer.vat_number ?? ''
const normalizedVat = normalizeVatNumber(rawVat)
let blocking = false
if (!isoCountry) {
warnings.push({
level: 'error',
code: 'MISSING_COUNTRY',
message: `Kund "${customer.name}" saknar land. Uppdatera kunden innan CSV laddas ner.`,
customerId: customer.id,
customerName: customer.name,
invoiceId: invoice.id,
amount: net,
})
blocking = true
} else if (!EU_COUNTRIES.has(isoCountry)) {
warnings.push({
level: 'warning',
code: 'NON_EU_COUNTRY_ON_EU_ACCOUNT',
message:
`Konto ${line.account_number} men kund "${customer.name}" i ${isoCountry} ` +
'är inte EU-land. Kontrollera bokföringen.',
customerId: customer.id,
customerName: customer.name,
invoiceId: invoice.id,
amount: net,
})
blocking = true
}
if (!normalizedVat) {
warnings.push({
level: 'error',
code: 'MISSING_VAT_NUMBER',
message: `Kund "${customer.name}" saknar VAT-nummer.`,
customerId: customer.id,
customerName: customer.name,
invoiceId: invoice.id,
amount: net,
})
blocking = true
} else {
// VAT prefix check — if the raw VAT-number starts with a country code,
// it must match the customer.country. Skatteverket uses EL for Greece.
const rawUpper = rawVat.replace(/\s+/g, '').toUpperCase()
const prefixMatch = rawUpper.match(/^([A-Z]{2})/)
if (prefixMatch && isoCountry) {
const expected = toVatCountryCode(isoCountry)
if (prefixMatch[1] !== expected) {
warnings.push({
level: 'warning',
code: 'COUNTRY_PREFIX_MISMATCH',
message:
`VAT-nr för "${customer.name}" har prefix ${prefixMatch[1]} men ` +
`kunden är registrerad i ${isoCountry}.`,
customerId: customer.id,
customerName: customer.name,
invoiceId: invoice.id,
})
}
}
const validatedAt = customer.vat_number_validated_at
const stale = validatedAt
? (Date.now() - new Date(validatedAt).getTime()) / (1000 * 60 * 60 * 24) > 30
: true
if (!customer.vat_number_validated || stale) {
warnings.push({
level: 'warning',
code: 'VIES_UNVALIDATED',
message:
`Kund "${customer.name}" är inte VIES-validerad (eller validering äldre än 30 dagar). ` +
'Verifiera mot Skatteverkets VIES-tjänst.',
customerId: customer.id,
customerName: customer.name,
})
}
}
bucketRow(
accumulators,
vatCountry || isoCountry || '??',
normalizedVat || '??',
customer.id,
customer.name,
bucket,
net,
blocking,
)
}
// Goods-sold-with-quarterly-period — blocking under SFL 35 kap. 2 §.
// Companies selling goods intra-EU must file PS monthly; a quarterly filing
// is structurally non-compliant and must not be exportable as CSV.
if (goodsLineSeen && periodType === 'quarterly') {
warnings.push({
level: 'error',
code: 'GOODS_SOLD_WITH_QUARTERLY_PERIOD',
message:
'Du har varuförsäljning i perioden. Periodisk sammanställning för varor ska ' +
'rapporteras månadsvis (35 kap. 2 § SFL). Byt period eller kontakta Skatteverket.',
})
}
// Round, drop zero rows, sort.
const rows: PsRow[] = []
for (const acc of accumulators.values()) {
const services = round(acc.services)
const goods = round(acc.goods)
const triangulation = round(acc.triangulation)
if (services === 0 && goods === 0 && triangulation === 0) {
// Emit a warning only if there was actual rörelse (a credit note nets
// services back to zero — final values are 0 but we saw activity).
if (acc.sawActivity) {
warnings.push({
level: 'warning',
code: 'ZERO_NET_EXCLUDED',
message:
`Kund "${acc.customerName ?? acc.vatNumber}" nettar till 0 kr för perioden ` +
'(kreditfaktura tar ut original). Exkluderad från filen.',
customerId: acc.customerId ?? undefined,
customerName: acc.customerName ?? undefined,
})
}
continue
}
rows.push({
country: acc.country,
vatNumber: acc.vatNumber,
services,
goods,
triangulation,
customerId: acc.customerId,
customerName: acc.customerName,
hasBlockingIssue: acc.blocking,
})
}
rows.sort((a, b) => {
if (a.country !== b.country) return a.country.localeCompare(b.country)
return a.vatNumber.localeCompare(b.vatNumber)
})
const totals = {
services: rows.reduce((s, r) => s + r.services, 0),
goods: rows.reduce((s, r) => s + r.goods, 0),
triangulation: rows.reduce((s, r) => s + r.triangulation, 0),
grand: 0,
rowCount: rows.length,
}
totals.grand = totals.services + totals.goods + totals.triangulation
return {
period: {
type: periodType,
year,
period,
start,
end,
label: formatPeriodLabel(periodType, year, period),
},
rows,
warnings,
totals,
reconciliation: {
ruta39: null,
ruta35: null,
ruta38: null,
matches: null,
tolerance: Math.max(1, Math.ceil(rows.length / 2)),
},
}
}
function bucketRow(
map: Map<string, Accumulator>,
country: string,
vatNumber: string,
customerId: string | null,
customerName: string | null,
bucket: 'services' | 'goods' | 'triangulation',
amount: number,
blocking: boolean,
): void {
const key = `${country}|${vatNumber}|${customerId ?? ''}`
let acc = map.get(key)
if (!acc) {
acc = {
country,
vatNumber,
customerId,
customerName,
services: 0,
goods: 0,
triangulation: 0,
blocking: false,
sawActivity: false,
}
map.set(key, acc)
}
acc[bucket] += amount
if (amount !== 0) acc.sawActivity = true
if (blocking) acc.blocking = true
}
/**
* Cross-check PS totals against momsdeklaration Ruta 35/38/39.
*
* Only meaningful when the PS period coincides with the momsdeklaration period.
* Returns the report with reconciliation populated; matches=null indicates the
* caller asked for a check that doesn't make sense (different periods).
*/
export async function reconcilePsAgainstVatDeclaration(
supabase: SupabaseClient,
companyId: string,
report: PeriodiskSammanstallningReport,
momsPeriod: 'monthly' | 'quarterly' | 'yearly' | null,
): Promise<PeriodiskSammanstallningReport> {
// Reconciliation only valid when periods coincide. Yearly is never valid for PS.
const periodsCoincide =
(report.period.type === 'monthly' && momsPeriod === 'monthly') ||
(report.period.type === 'quarterly' && momsPeriod === 'quarterly')
if (!periodsCoincide) {
return report
}
const vat = await calculateVatDeclaration(
supabase,
companyId,
report.period.type,
report.period.year,
report.period.period,
)
const ruta35 = Math.round(vat.rutor.ruta35)
const ruta38 = Math.round(vat.rutor.ruta38 ?? 0)
const ruta39 = Math.round(vat.rutor.ruta39)
const tolerance = report.reconciliation.tolerance
const matches =
Math.abs(report.totals.services - ruta39) <= tolerance &&
Math.abs(report.totals.goods - ruta35) <= tolerance &&
Math.abs(report.totals.triangulation - ruta38) <= tolerance
return {
...report,
reconciliation: {
ruta39,
ruta35,
ruta38,
matches,
tolerance,
},
}
}
export { formatPeriodLabel } from './period-dates'
@@ -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();
@@ -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';
@@ -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';
@@ -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';
+5
View File
@@ -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,
+12
View File
@@ -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'