Files
accounted/lib/skatteverket/bank-counterpart.ts
T
MattssonandClaude Opus 4.7 980f29dae8 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>
2026-05-12 18:04:48 +02:00

77 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { StoredSkattekontoTransaction } from '@/types/skatteverket'
/**
* Heuristic for spotting a 1930↔1630-transfer that has been observed
* from both sides (bank PSD2 + Skatteverket API).
*
* Used to render a passive dublett-varning on the bank-tx card in
* /transactions so the user doesn't book the same transfer twice (once
* via /transactions, once via /skattekonto).
*
* Rule, intentionally conservative:
* - Equal absolute amount (rounded to öre)
* - Opposite signs (a transfer looks like -X on bank, +X on SKV — or
* vice versa for a refund). Same-sign pairs are unrelated cash flows
* that happen to share an amount.
* - Transaktionsdatum within ±DATE_WINDOW_DAYS of bank.date. Real
* settlement is usually 1–3 working days but we widen the window to
* handle weekends and holidays.
*
* The function is non-blocking: false positives just mean an extra
* warning panel the user can ignore. False negatives mean no warning
* (user might double-book — but the SKV row will still have its own
* `match_suggestion` once they book one side, so the dublett-flow has a
* second chance to fire).
*/
export const BANK_SKV_DATE_WINDOW_DAYS = 14
interface BankCounterpartInput {
/** Bank transactions that are still uncategorized (inbox candidates). */
bankRows: ReadonlyArray<{ id: string; date: string; amount: number }>
/** SKV rows that have not been linked to a verifikat yet. */
skvRows: ReadonlyArray<
Pick<StoredSkattekontoTransaction, 'id' | 'transaktionsdatum' | 'belopp_skatteverket'>
>
/** Override for testing — defaults to BANK_SKV_DATE_WINDOW_DAYS. */
dateWindowDays?: number
}
function isoToTime(iso: string): number {
return new Date(iso + 'T00:00:00Z').getTime()
}
function diffDays(a: string, b: string): number {
return Math.abs(isoToTime(a) - isoToTime(b)) / 86_400_000
}
/**
* Map each bank tx that has a plausible SKV counterpart to that SKV row's
* transaktionsdatum. First plausible match wins per bank tx — we don't
* return a ranked list since the UI only renders a single hint per card.
*/
export function findBankSkvCounterparts({
bankRows,
skvRows,
dateWindowDays = BANK_SKV_DATE_WINDOW_DAYS,
}: BankCounterpartInput): Map<string, string> {
const result = new Map<string, string>()
if (skvRows.length === 0 || bankRows.length === 0) return result
for (const tx of bankRows) {
const txAmount = Math.round(Math.abs(tx.amount) * 100) / 100
if (txAmount === 0) continue
const txSign = Math.sign(tx.amount)
for (const r of skvRows) {
const skvAmount = Math.round(Math.abs(Number(r.belopp_skatteverket)) * 100) / 100
if (skvAmount !== txAmount) continue
// Transfer scenario: bank-side and SKV-side have opposite signs.
if (txSign === Math.sign(Number(r.belopp_skatteverket))) continue
if (diffDays(r.transaktionsdatum, tx.date) > dateWindowDays) continue
result.set(tx.id, r.transaktionsdatum)
break
}
}
return result
}