'Matcha manuellt' as designed: outside rows on the left (multi-select), verifikat without an outside row on the right (single-select), the selection's arithmetic in the footer, and one Koppla that is enabled only when the difference is 0. Mode lives in the URL (?mode=match). Engine: a pair is now one OR MANY outside rows against one verifikat. Bank groups link per transaction (manualLink allows N:1 by design, so partial success is reported per row). Skattekonto groups go through the new linkSkattekontoRows: the verifikat's 1630 side must settle the sum, one guarded UPDATE links the whole group, and a partial hit is rolled back as LINK_RACE. 1:M stays UNSUPPORTED_PAIR_SHAPE until the residual link table (6c). v1 pitfalls + API skill regenerated. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
355 lines
14 KiB
TypeScript
355 lines
14 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { roundOre } from '@/lib/money'
|
|
import { SKATTEKONTO_ACCOUNT } from './manual-verifikat-prefill'
|
|
|
|
/**
|
|
* Link semantics for a skattekonto row (core).
|
|
*
|
|
* A link pairs ONE SKV-posted row with ONE verifikat whose 1630 movement
|
|
* equals the row's amount on the expected side (positive belopp = money
|
|
* into the skattekonto = debit 1630). It writes nothing to the verifikat:
|
|
* `skattekonto_transactions.journal_entry_id` is the only thing that changes,
|
|
* so linking and unlinking are allowed in locked periods and need no storno.
|
|
*
|
|
* Lives in core so the reconciliation engine (lib/reconciliation), the
|
|
* dashboard routes, the v1 API and the MCP executors share one implementation;
|
|
* the skatteverket extension's matchSkattekontoToEntry delegates here.
|
|
*/
|
|
|
|
export type SkattekontoLinkErrorCode =
|
|
| 'TRANSACTION_NOT_FOUND'
|
|
| 'ALREADY_BOOKED'
|
|
| 'ROW_IGNORED'
|
|
| 'ENTRY_NOT_FOUND'
|
|
| 'ENTRY_ALREADY_LINKED'
|
|
| 'INVALID_CANDIDATE'
|
|
| 'NOT_LINKED'
|
|
| 'LINK_RACE'
|
|
|
|
export class SkattekontoLinkError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly code: SkattekontoLinkErrorCode,
|
|
) {
|
|
super(message)
|
|
this.name = 'SkattekontoLinkError'
|
|
}
|
|
}
|
|
|
|
interface RowForLink {
|
|
id: string
|
|
belopp_skatteverket: number | string
|
|
journal_entry_id: string | null
|
|
is_ignored: boolean | null
|
|
status: 'booked' | 'upcoming'
|
|
}
|
|
|
|
interface EntryForLink {
|
|
id: string
|
|
status: 'draft' | 'posted' | 'reversed'
|
|
lines: Array<{ account_number: string; debit_amount: number | string; credit_amount: number | string }> | null
|
|
}
|
|
|
|
function expectedSide(belopp: number): 'debit' | 'credit' {
|
|
return belopp > 0 ? 'debit' : 'credit'
|
|
}
|
|
|
|
/**
|
|
* Does this entry settle the row? True when a single 1630 line equals the
|
|
* amount on the expected side, or when the entry's 1630 lines net to it (a
|
|
* manual voucher that split the movement over two lines). Exported for the
|
|
* engine's pair validation.
|
|
*/
|
|
export function entrySettlesAmount(
|
|
lines: EntryForLink['lines'],
|
|
belopp: number,
|
|
): { ok: boolean; via: 'line' | 'entry_total' | null } {
|
|
const amount = roundOre(Math.abs(belopp))
|
|
const side = expectedSide(belopp)
|
|
const onAccount = (lines ?? []).filter((l) => l.account_number === SKATTEKONTO_ACCOUNT)
|
|
if (onAccount.length === 0) return { ok: false, via: null }
|
|
const single = onAccount.some((l) => {
|
|
const debit = roundOre(Number(l.debit_amount))
|
|
const credit = roundOre(Number(l.credit_amount))
|
|
return side === 'debit' ? debit === amount && credit === 0 : credit === amount && debit === 0
|
|
})
|
|
if (single) return { ok: true, via: 'line' }
|
|
const net = roundOre(
|
|
onAccount.reduce((s, l) => s + Number(l.debit_amount || 0) - Number(l.credit_amount || 0), 0),
|
|
)
|
|
const signed = side === 'debit' ? amount : -amount
|
|
if (onAccount.length > 1 && net === signed) return { ok: true, via: 'entry_total' }
|
|
return { ok: false, via: null }
|
|
}
|
|
|
|
export interface LinkSkattekontoRowResult {
|
|
skattekonto_transaction_id: string
|
|
journal_entry_id: string
|
|
via: 'line' | 'entry_total'
|
|
}
|
|
|
|
/**
|
|
* Link one open SKV row to one verifikat. Throws SkattekontoLinkError with a
|
|
* stable code on every refusal; the write is guarded on journal_entry_id IS
|
|
* NULL so a concurrent link loses cleanly (LINK_RACE).
|
|
*/
|
|
export async function linkSkattekontoRow(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
transactionId: string,
|
|
journalEntryId: string,
|
|
): Promise<LinkSkattekontoRowResult> {
|
|
const { data: row, error: rowError } = await supabase
|
|
.from('skattekonto_transactions')
|
|
.select('id, belopp_skatteverket, journal_entry_id, is_ignored, status')
|
|
.eq('id', transactionId)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle<RowForLink>()
|
|
if (rowError || !row) {
|
|
throw new SkattekontoLinkError('Skattekonto-transaktionen hittades inte.', 'TRANSACTION_NOT_FOUND')
|
|
}
|
|
if (row.journal_entry_id) {
|
|
throw new SkattekontoLinkError('Transaktionen är redan kopplad till ett verifikat.', 'ALREADY_BOOKED')
|
|
}
|
|
if (row.is_ignored) {
|
|
throw new SkattekontoLinkError('Transaktionen är ignorerad. Återställ den innan du kopplar.', 'ROW_IGNORED')
|
|
}
|
|
if (row.status !== 'booked') {
|
|
throw new SkattekontoLinkError('En kommande händelse kan inte kopplas ännu.', 'INVALID_CANDIDATE')
|
|
}
|
|
|
|
const { data: entry, error: entryError } = await supabase
|
|
.from('journal_entries')
|
|
.select('id, status, lines:journal_entry_lines ( account_number, debit_amount, credit_amount )')
|
|
.eq('id', journalEntryId)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle<EntryForLink>()
|
|
if (entryError || !entry) {
|
|
throw new SkattekontoLinkError('Verifikatet hittades inte.', 'ENTRY_NOT_FOUND')
|
|
}
|
|
if (entry.status === 'reversed') {
|
|
throw new SkattekontoLinkError('Verifikatet är makulerat och kan inte kopplas.', 'INVALID_CANDIDATE')
|
|
}
|
|
const settles = entrySettlesAmount(entry.lines, Number(row.belopp_skatteverket))
|
|
if (!settles.ok || !settles.via) {
|
|
throw new SkattekontoLinkError('Verifikatet saknar en matchande rad på 1630.', 'INVALID_CANDIDATE')
|
|
}
|
|
|
|
const { data: alreadyLinked } = await supabase
|
|
.from('skattekonto_transactions')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.eq('journal_entry_id', journalEntryId)
|
|
.maybeSingle()
|
|
if (alreadyLinked) {
|
|
throw new SkattekontoLinkError(
|
|
'Verifikatet är redan kopplat till en annan skattekonto-transaktion.',
|
|
'ENTRY_ALREADY_LINKED',
|
|
)
|
|
}
|
|
|
|
const { data: updated, error: updateError } = await supabase
|
|
.from('skattekonto_transactions')
|
|
.update({ journal_entry_id: journalEntryId, suggested_journal_entry_id: null, suggested_at: null })
|
|
.eq('id', transactionId)
|
|
.eq('company_id', companyId)
|
|
.is('journal_entry_id', null)
|
|
.select('id')
|
|
if (updateError) {
|
|
throw new SkattekontoLinkError(`Kunde inte koppla: ${updateError.message}`, 'LINK_RACE')
|
|
}
|
|
if (!updated || (Array.isArray(updated) && updated.length === 0)) {
|
|
throw new SkattekontoLinkError('Transaktionen kopplades av någon annan samtidigt.', 'LINK_RACE')
|
|
}
|
|
|
|
return { skattekonto_transaction_id: transactionId, journal_entry_id: journalEntryId, via: settles.via }
|
|
}
|
|
|
|
/**
|
|
* Remove the link. The verifikat is untouched (BFL: nothing is deleted or
|
|
* edited in the ledger); only the row's pointer is cleared. Proposals are
|
|
* recomputed on the next sync.
|
|
*/
|
|
export async function unlinkSkattekontoRow(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
transactionId: string,
|
|
): Promise<{ skattekonto_transaction_id: string; previous_journal_entry_id: string }> {
|
|
const { data: row, error } = await supabase
|
|
.from('skattekonto_transactions')
|
|
.select('id, journal_entry_id')
|
|
.eq('id', transactionId)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle<{ id: string; journal_entry_id: string | null }>()
|
|
if (error || !row) {
|
|
throw new SkattekontoLinkError('Skattekonto-transaktionen hittades inte.', 'TRANSACTION_NOT_FOUND')
|
|
}
|
|
if (!row.journal_entry_id) {
|
|
throw new SkattekontoLinkError('Transaktionen är inte kopplad till något verifikat.', 'NOT_LINKED')
|
|
}
|
|
const { error: updateError } = await supabase
|
|
.from('skattekonto_transactions')
|
|
.update({ journal_entry_id: null })
|
|
.eq('id', transactionId)
|
|
.eq('company_id', companyId)
|
|
.eq('journal_entry_id', row.journal_entry_id)
|
|
if (updateError) {
|
|
throw new SkattekontoLinkError(`Kunde inte koppla bort: ${updateError.message}`, 'LINK_RACE')
|
|
}
|
|
return { skattekonto_transaction_id: transactionId, previous_journal_entry_id: row.journal_entry_id }
|
|
}
|
|
|
|
/**
|
|
* Ignore / restore a row. An ignored row never carries a link (DB CHECK,
|
|
* migration 20260819200000), so ignoring a linked row is refused here with a
|
|
* clean code instead of a constraint error.
|
|
*/
|
|
export async function setSkattekontoRowIgnored(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
transactionId: string,
|
|
ignored: boolean,
|
|
): Promise<{ skattekonto_transaction_id: string; is_ignored: boolean }> {
|
|
const { data: row, error } = await supabase
|
|
.from('skattekonto_transactions')
|
|
.select('id, journal_entry_id, is_ignored')
|
|
.eq('id', transactionId)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle<{ id: string; journal_entry_id: string | null; is_ignored: boolean | null }>()
|
|
if (error || !row) {
|
|
throw new SkattekontoLinkError('Skattekonto-transaktionen hittades inte.', 'TRANSACTION_NOT_FOUND')
|
|
}
|
|
if (ignored && row.journal_entry_id) {
|
|
throw new SkattekontoLinkError('En kopplad händelse kan inte ignoreras. Koppla bort den först.', 'ALREADY_BOOKED')
|
|
}
|
|
if (Boolean(row.is_ignored) === ignored) {
|
|
return { skattekonto_transaction_id: transactionId, is_ignored: ignored }
|
|
}
|
|
// Two literal payloads (not one conditional expression) so the phantom-column
|
|
// guard can read the column set; ignoring also drops a standing proposal.
|
|
const update = ignored
|
|
? supabase
|
|
.from('skattekonto_transactions')
|
|
.update({ is_ignored: true, suggested_journal_entry_id: null, suggested_at: null })
|
|
: supabase.from('skattekonto_transactions').update({ is_ignored: false })
|
|
const { error: updateError } = await update.eq('id', transactionId).eq('company_id', companyId)
|
|
if (updateError) {
|
|
throw new SkattekontoLinkError(`Kunde inte uppdatera: ${updateError.message}`, 'LINK_RACE')
|
|
}
|
|
return { skattekonto_transaction_id: transactionId, is_ignored: ignored }
|
|
}
|
|
|
|
export interface LinkSkattekontoRowsResult {
|
|
journal_entry_id: string
|
|
via: 'line' | 'entry_total'
|
|
skattekonto_transaction_ids: string[]
|
|
}
|
|
|
|
/**
|
|
* Link SEVERAL open SKV rows to ONE verifikat: the N:1 worksheet selection
|
|
* (one AGI verifikat settling the avdragen skatt + arbetsgivaravgift rows,
|
|
* one payment verifikat covering a row pair). The verifikat's 1630 side must
|
|
* settle the SUM of the rows; each row then gets the same guarded pointer as
|
|
* the single link. The write is ONE guarded UPDATE over the whole group: a
|
|
* concurrent link shrinks the hit set, and a partial hit is rolled back and
|
|
* reported as LINK_RACE, so a group is never left half-linked.
|
|
*/
|
|
export async function linkSkattekontoRows(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
transactionIds: string[],
|
|
journalEntryId: string,
|
|
): Promise<LinkSkattekontoRowsResult> {
|
|
const ids = [...new Set(transactionIds)]
|
|
if (ids.length === 0 || ids.length > 50) {
|
|
throw new SkattekontoLinkError('Välj mellan 1 och 50 händelser.', 'INVALID_CANDIDATE')
|
|
}
|
|
|
|
const { data: rows, error: rowsError } = await supabase
|
|
.from('skattekonto_transactions')
|
|
.select('id, belopp_skatteverket, journal_entry_id, is_ignored, status')
|
|
.eq('company_id', companyId)
|
|
.in('id', ids)
|
|
if (rowsError || !rows || rows.length !== ids.length) {
|
|
throw new SkattekontoLinkError('Någon av skattekonto-transaktionerna hittades inte.', 'TRANSACTION_NOT_FOUND')
|
|
}
|
|
const typed = rows as RowForLink[]
|
|
if (typed.some((r) => r.journal_entry_id)) {
|
|
throw new SkattekontoLinkError('En av transaktionerna är redan kopplad till ett verifikat.', 'ALREADY_BOOKED')
|
|
}
|
|
if (typed.some((r) => r.is_ignored)) {
|
|
throw new SkattekontoLinkError('En av transaktionerna är ignorerad. Återställ den innan du kopplar.', 'ROW_IGNORED')
|
|
}
|
|
if (typed.some((r) => r.status !== 'booked')) {
|
|
throw new SkattekontoLinkError('En kommande händelse kan inte kopplas ännu.', 'INVALID_CANDIDATE')
|
|
}
|
|
|
|
const sum = roundOre(typed.reduce((s, r) => s + Number(r.belopp_skatteverket), 0))
|
|
if (sum === 0) {
|
|
throw new SkattekontoLinkError(
|
|
'De valda händelserna nettar till 0 och kan inte kopplas mot ett verifikat.',
|
|
'INVALID_CANDIDATE',
|
|
)
|
|
}
|
|
|
|
const { data: entry, error: entryError } = await supabase
|
|
.from('journal_entries')
|
|
.select('id, status, lines:journal_entry_lines ( account_number, debit_amount, credit_amount )')
|
|
.eq('id', journalEntryId)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle<EntryForLink>()
|
|
if (entryError || !entry) {
|
|
throw new SkattekontoLinkError('Verifikatet hittades inte.', 'ENTRY_NOT_FOUND')
|
|
}
|
|
if (entry.status === 'reversed') {
|
|
throw new SkattekontoLinkError('Verifikatet är makulerat och kan inte kopplas.', 'INVALID_CANDIDATE')
|
|
}
|
|
const settles = entrySettlesAmount(entry.lines, sum)
|
|
if (!settles.ok || !settles.via) {
|
|
throw new SkattekontoLinkError(
|
|
'Verifikatets rader på 1630 motsvarar inte summan av de valda händelserna.',
|
|
'INVALID_CANDIDATE',
|
|
)
|
|
}
|
|
|
|
const groupSet = new Set(ids)
|
|
const { data: linkedRows } = await supabase
|
|
.from('skattekonto_transactions')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.eq('journal_entry_id', journalEntryId)
|
|
if ((linkedRows ?? []).some((r) => !groupSet.has((r as { id: string }).id))) {
|
|
throw new SkattekontoLinkError(
|
|
'Verifikatet är redan kopplat till en annan skattekonto-transaktion.',
|
|
'ENTRY_ALREADY_LINKED',
|
|
)
|
|
}
|
|
|
|
const { data: updated, error: updateError } = await supabase
|
|
.from('skattekonto_transactions')
|
|
.update({ journal_entry_id: journalEntryId, suggested_journal_entry_id: null, suggested_at: null })
|
|
.eq('company_id', companyId)
|
|
.in('id', ids)
|
|
.is('journal_entry_id', null)
|
|
.eq('is_ignored', false)
|
|
.select('id')
|
|
if (updateError) {
|
|
throw new SkattekontoLinkError(`Kunde inte koppla: ${updateError.message}`, 'LINK_RACE')
|
|
}
|
|
const updatedIds = ((updated ?? []) as Array<{ id: string }>).map((r) => r.id)
|
|
if (updatedIds.length !== ids.length) {
|
|
// Roll the partial hit back: the group's sum no longer settles the entry.
|
|
if (updatedIds.length > 0) {
|
|
await supabase
|
|
.from('skattekonto_transactions')
|
|
.update({ journal_entry_id: null })
|
|
.eq('company_id', companyId)
|
|
.in('id', updatedIds)
|
|
.eq('journal_entry_id', journalEntryId)
|
|
}
|
|
throw new SkattekontoLinkError('En av transaktionerna kopplades av någon annan samtidigt.', 'LINK_RACE')
|
|
}
|
|
|
|
return { journal_entry_id: journalEntryId, via: settles.via, skattekonto_transaction_ids: ids }
|
|
}
|