Bug/momsdeklaration skv (#449)

* fix(salary): show birthdate in masked personnummer, hide the 4-digit suffix

Flip the personnummer display format from XXXXXXXX-NNNN to YYYYMMDD-XXXX so
the sensitive 4-digit suffix is hidden while the (public) birthdate stays
visible. Affects the employees list/detail, salary run, payslip PDF, payslip
email, and the MCP server tools (list_employees, get_salary_run). Each call
site now decrypts the stored personnummer before masking.

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

* fix(transactions): allow deleting unbooked transactions from "Alla transaktioner"

The history list only let users delete via the inbox card; once a category or
mall was picked but the verifikation hadn't been created, the row showed
"Ej bokförd" with no way to remove it. The API already permits delete while
journal_entry_id is null, so the gap was purely a missing UI affordance.

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

* fix(vat): populate ruta 20-24 for reverse charge + dishonest "Validera OK"

Three connected issues caused Skatteverket to reject momsdeklarationer
with FK004 even after our local "Validera"-knapp returned OK.

1. supplier-invoice-entries booked fiktiv moms (2614/2624/2634 + 2645/2647)
   on reverse-charge invoices but never the underlying basbelopp on 44xx/45xx.
   Ruta 30-32 filled up at SKV while ruta 20-24 stayed at 0 — SKV's FK004
   ("silent netting prohibited", ML 13 kap kräver båda sidor).

   Fix: generateReverseChargeBasisLines in vat-entries.ts emits parallel
   45xx/44xx debit + 4598 motkonto credit per rate group. Engine calls it
   from registration, cash, and credit-note paths. Skipped when the user
   booked the expense directly on a basis account to avoid double-counting.
   4598 added to BAS reference (no migration needed; account_number is
   plain text on journal_entry_lines).

2. rutorToMomsuppgift rounded each ruta independently but computed
   summaMoms from the unrounded ruta49. SKV recomputes the sum from
   integer rutor on their side, so fractional öres caused ±1 SEK drift
   and SKV rejected with FK009.

   Fix: derive summaMoms from the already-rounded VAT-amount rutor.

3. "Validera"-knappen only confirmed SKV's internal arithmetic — a
   declaration with ruta 30-32 populated and ruta 20-24 empty validated
   fine until /utkast hit FK004. Users got a false green light.

   Fix: vat-declaration-checks.ts runs locally before the SKV call,
   blocks Validera/Spara when ERROR-level findings exist, and surfaces
   them in a separate "Lokala kontroller"-section. Success message
   reworded so SKV's OK is no longer presented as filing-ready.

Tests: 4535/4536/4531/4425 lines + 4598 motkonto on EU/non-EU/byggtjänster
RC, credit-note reversal, fractional-öres summaMoms, all four pre-flight
codes (RC_BASIS_MISSING, RC_OUTPUT_MISSING, RC_INPUT_VAT_MISMATCH,
SUMMA_MOMS_DRIFT).

Backfill for already-posted entries follows in the next commit.

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

* feat: add skattekonto matching functionality

- Enhance TransactionInboxCard to display a warning for potential 1930↔1630 transfers.
- Implement match suggestions for skattekonto transactions in the backend.
- Create SkattekontoMatchDialog component for linking skattekonto rows to existing journal entries.
- Develop SkattekontoInboxCard component to handle skattekonto transactions in the inbox.
- Introduce skattekonto-match utility functions for candidate matching and linking.
- Update types to include match suggestions and enriched transaction responses.

* refactor: reorganize skattekonto types and implement bank counterpart matching logic

* docs: update CLAUDE.md to streamline integrations and clarify architecture details

* refactor: enhance reverse charge logic to handle non-basis accounts and prevent double-counting

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-12 18:04:48 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent eb77ad50b5
commit 980f29dae8
33 changed files with 3337 additions and 493 deletions
+3 -3
View File
@@ -5,7 +5,7 @@ import { validateBody } from '@/lib/api/validate'
import { UpdateEmployeeSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { encryptPersonnummer, extractLast4, validatePersonnummer } from '@/lib/salary/personnummer'
import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
ensureInitialized()
@@ -34,7 +34,7 @@ export async function GET(
return NextResponse.json({
data: {
...employee,
personnummer: `XXXXXXXX-${employee.personnummer_last4}`,
personnummer: maskPersonnummer(decryptPersonnummer(employee.personnummer)),
},
})
}
@@ -117,7 +117,7 @@ export async function PATCH(
return NextResponse.json({
data: {
...updated,
personnummer: `XXXXXXXX-${updated.personnummer_last4}`,
personnummer: maskPersonnummer(decryptPersonnummer(updated.personnummer)),
},
})
}
+4 -4
View File
@@ -5,7 +5,7 @@ import { validateBody } from '@/lib/api/validate'
import { CreateEmployeeSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { encryptPersonnummer, extractLast4, validatePersonnummer } from '@/lib/salary/personnummer'
import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer'
ensureInitialized()
@@ -34,10 +34,10 @@ export async function GET(request: Request) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Mask personnummer — only show last 4
// Mask personnummer — show birthdate, hide the 4-digit suffix
const masked = (data || []).map(emp => ({
...emp,
personnummer: `XXXXXXXX-${emp.personnummer_last4}`,
personnummer: maskPersonnummer(decryptPersonnummer(emp.personnummer)),
}))
return NextResponse.json({ data: masked })
@@ -115,7 +115,7 @@ export async function POST(request: Request) {
return NextResponse.json({
data: {
...employee,
personnummer: `XXXXXXXX-${last4}`,
personnummer: maskPersonnummer(body.personnummer),
},
}, { status: 201 })
}
@@ -5,7 +5,7 @@ import { requireCompanyId } from '@/lib/company/context'
import { renderToBuffer } from '@react-pdf/renderer'
import { PayslipPDF } from '@/lib/salary/pdf/payslip-template'
import type { PayslipData, PayslipLineItem } from '@/lib/salary/pdf/payslip-template'
import { maskPersonnummer } from '@/lib/salary/personnummer'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
ensureInitialized()
@@ -41,7 +41,7 @@ export async function GET(
// Load salary run employee
const { data: sre } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(first_name, last_name, personnummer_last4, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)')
.select('*, employee:employees(first_name, last_name, personnummer, personnummer_last4, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
.eq('employee_id', employeeId)
.single()
@@ -62,7 +62,7 @@ export async function GET(
}
const emp = sre.employee as {
first_name: string; last_name: string; personnummer_last4: string;
first_name: string; last_name: string; personnummer: string; personnummer_last4: string;
employment_type: string; tax_table_number: number | null; tax_column: number;
clearing_number: string | null; bank_account_number: string | null;
}
@@ -104,7 +104,7 @@ export async function GET(
companyName: company.name,
companyOrgNumber: company.org_number || '',
employeeName: `${emp.first_name} ${emp.last_name}`,
personnummerMasked: maskPersonnummer(emp.personnummer_last4),
personnummerMasked: maskPersonnummer(decryptPersonnummer(emp.personnummer)),
employmentType: EMPLOYMENT_LABELS[emp.employment_type] || emp.employment_type,
periodYear: run.period_year,
periodMonth: run.period_month,
@@ -7,7 +7,7 @@ import { getEmailService } from '@/lib/email/service'
import { renderToBuffer } from '@react-pdf/renderer'
import { PayslipPDF } from '@/lib/salary/pdf/payslip-template'
import type { PayslipData, PayslipLineItem } from '@/lib/salary/pdf/payslip-template'
import { maskPersonnummer } from '@/lib/salary/personnummer'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
ensureInitialized()
@@ -58,7 +58,7 @@ export async function POST(
// Load employees with line items
const { data: runEmployees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(first_name, last_name, personnummer_last4, employment_type, email, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)')
.select('*, employee:employees(first_name, last_name, personnummer, personnummer_last4, employment_type, email, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
if (!runEmployees || runEmployees.length === 0) {
@@ -75,7 +75,7 @@ export async function POST(
for (const sre of runEmployees) {
const emp = sre.employee as {
first_name: string; last_name: string; personnummer_last4: string;
first_name: string; last_name: string; personnummer: string; personnummer_last4: string;
employment_type: string; email: string | null; tax_table_number: number | null;
tax_column: number; clearing_number: string | null; bank_account_number: string | null;
} | null
@@ -116,7 +116,7 @@ export async function POST(
companyName: company.name,
companyOrgNumber: company.org_number || '',
employeeName: `${emp.first_name} ${emp.last_name}`,
personnummerMasked: maskPersonnummer(emp.personnummer_last4),
personnummerMasked: maskPersonnummer(decryptPersonnummer(emp.personnummer)),
employmentType: emp.employment_type,
periodYear: run.period_year,
periodMonth: run.period_month,
+3 -2
View File
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { formatRedovisare } from '@/lib/skatteverket/format'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
ensureInitialized()
@@ -32,7 +33,7 @@ export async function GET(
// Load employees with line items
const { data: employees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(id, first_name, last_name, personnummer_last4, employment_type), line_items:salary_line_items(*)')
.select('*, employee:employees(id, first_name, last_name, personnummer, personnummer_last4, employment_type), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
.order('created_at')
@@ -61,7 +62,7 @@ export async function GET(
...emp,
employee: emp.employee ? {
...emp.employee,
personnummer: `XXXXXXXX-${emp.employee.personnummer_last4}`,
personnummer: maskPersonnummer(decryptPersonnummer(emp.employee.personnummer)),
} : null,
})),
},