feat(reconciliation): manual N:1 matching: two-pane worksheet + group links (#1851)
'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>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
4d6dd68df4
commit
34ad1b1936
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const linkMock = vi.fn()
|
||||
const linkGroupMock = vi.fn()
|
||||
const unlinkMock = vi.fn()
|
||||
const setIgnoredMock = vi.fn()
|
||||
const manualLinkMock = vi.fn()
|
||||
@@ -14,6 +15,7 @@ vi.mock('@/lib/skatteverket/skattekonto-link', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
linkSkattekontoRow: (...args: unknown[]) => linkMock(...args),
|
||||
linkSkattekontoRows: (...args: unknown[]) => linkGroupMock(...args),
|
||||
unlinkSkattekontoRow: (...args: unknown[]) => unlinkMock(...args),
|
||||
setSkattekontoRowIgnored: (...args: unknown[]) => setIgnoredMock(...args),
|
||||
}
|
||||
@@ -42,6 +44,7 @@ describe('matchPairs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
linkMock.mockReset()
|
||||
linkGroupMock.mockReset()
|
||||
manualLinkMock.mockReset()
|
||||
skvStatusMock.mockReset()
|
||||
emitMock.mockResolvedValue(undefined)
|
||||
@@ -53,30 +56,65 @@ describe('matchPairs', () => {
|
||||
expect(await matchPairs(supabase as never, COMPANY, USER, 'manual:1910', { pairs: [] })).toBeNull()
|
||||
})
|
||||
|
||||
it('links skattekonto pairs one by one, reports skips with codes, and emits one event per link', async () => {
|
||||
it('links skattekonto pairs one by one, groups N:1 through the sum-checked helper, and emits one event per row', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
linkMock
|
||||
.mockResolvedValueOnce({ skattekonto_transaction_id: R1, journal_entry_id: E1, via: 'line' })
|
||||
.mockRejectedValueOnce(new SkattekontoLinkError('redan kopplat', 'ENTRY_ALREADY_LINKED'))
|
||||
linkGroupMock.mockResolvedValueOnce({
|
||||
journal_entry_id: E2,
|
||||
via: 'entry_total',
|
||||
skattekonto_transaction_ids: [R1, R2],
|
||||
})
|
||||
|
||||
const result = await matchPairs(supabase as never, COMPANY, USER, 'skattekonto', {
|
||||
pairs: [
|
||||
{ external_ids: [R1], journal_entry_ids: [E1] },
|
||||
{ external_ids: [R2], journal_entry_ids: [E1] },
|
||||
{ external_ids: [R1, R2], journal_entry_ids: [E2] },
|
||||
// 1:M stays refused until the residual link table exists.
|
||||
{ external_ids: [R1], journal_entry_ids: [E1, E2] },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ dry_run: false, considered: 3 })
|
||||
expect(result?.applied).toEqual([{ external_id: R1, journal_entry_id: E1, via: 'line' }])
|
||||
expect(result).toMatchObject({ dry_run: false, considered: 4 })
|
||||
expect(result?.applied).toEqual([
|
||||
{ external_id: R1, journal_entry_id: E1, via: 'line' },
|
||||
{ external_id: R1, journal_entry_id: E2, via: 'entry_total' },
|
||||
{ external_id: R2, journal_entry_id: E2, via: 'entry_total' },
|
||||
])
|
||||
expect(result?.skipped.map((s) => s.code)).toEqual(['ALREADY_LINKED', 'UNSUPPORTED_PAIR_SHAPE'])
|
||||
expect(emitMock).toHaveBeenCalledTimes(1)
|
||||
expect(linkGroupMock).toHaveBeenCalledWith(supabase, COMPANY, [R1, R2], E2)
|
||||
expect(emitMock).toHaveBeenCalledTimes(3)
|
||||
expect(emitMock.mock.calls[0][0]).toMatchObject({
|
||||
type: 'reconciliation.matched',
|
||||
payload: { accountKey: 'skattekonto', externalId: R1, journalEntryId: E1, method: 'manual' },
|
||||
})
|
||||
})
|
||||
|
||||
it('links a bank N:1 group per transaction and reports partial failures per row', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { ledger_account: '1930' } }) // cash_accounts lookup
|
||||
manualLinkMock
|
||||
.mockResolvedValueOnce({ success: true })
|
||||
.mockResolvedValueOnce({ success: false, error: 'Transaktionen är redan kopplad till en verifikation.' })
|
||||
|
||||
const result = await matchPairs(supabase as never, COMPANY, USER, `bank:${CASH}`, {
|
||||
pairs: [{ external_ids: [R1, R2], journal_entry_ids: [E1] }],
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ dry_run: false, considered: 1 })
|
||||
expect(result?.applied).toEqual([{ external_id: R1, journal_entry_id: E1 }])
|
||||
expect(result?.skipped).toEqual([
|
||||
{
|
||||
pair: { external_ids: [R2], journal_entry_ids: [E1] },
|
||||
code: 'PAIR_NOT_CLOSED',
|
||||
message: 'Transaktionen är redan kopplad till en verifikation.',
|
||||
},
|
||||
])
|
||||
expect(emitMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dry run resolves proposals into pairs without writing', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
skvStatusMock.mockResolvedValue({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { eventBus } from '@/lib/events/bus'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import {
|
||||
linkSkattekontoRow,
|
||||
linkSkattekontoRows,
|
||||
setSkattekontoRowIgnored,
|
||||
SkattekontoLinkError,
|
||||
unlinkSkattekontoRow,
|
||||
@@ -115,10 +116,11 @@ async function proposalsAsPairs(
|
||||
}
|
||||
|
||||
/**
|
||||
* Link pairs on one account. Today each pair is one outside row and one
|
||||
* verifikat (the N:M worksheet selection arrives with the manual-match mode);
|
||||
* other shapes are reported as UNSUPPORTED_PAIR_SHAPE, never silently
|
||||
* reduced. Dry run validates shapes and resolves proposals without writing.
|
||||
* Link pairs on one account. A pair is one OR MANY outside rows against
|
||||
* exactly one verifikat (bank: independent links per transaction; skattekonto:
|
||||
* all-or-nothing with the sum settling the verifikat). One row against many
|
||||
* verifikat waits for the residual link table and is reported as
|
||||
* UNSUPPORTED_PAIR_SHAPE, never silently reduced. Dry run validates shapes and resolves proposals without writing.
|
||||
* Partial success is first-class: `applied` and `skipped` together cover
|
||||
* every considered pair.
|
||||
*/
|
||||
@@ -144,27 +146,65 @@ export async function matchPairs(
|
||||
const applied: AppliedLink[] = []
|
||||
const skipped: SkippedPair[] = []
|
||||
|
||||
const emitMatched = async (externalId: string, journalEntryId: string) => {
|
||||
await eventBus.emit({
|
||||
type: 'reconciliation.matched',
|
||||
payload: {
|
||||
accountKey,
|
||||
externalId,
|
||||
journalEntryId,
|
||||
method: input.use_proposals ? 'proposal' : 'manual',
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for (const pair of pairs) {
|
||||
if (pair.external_ids.length !== 1 || pair.journal_entry_ids.length !== 1) {
|
||||
// N outside rows may settle ONE verifikat (the worksheet selection); the
|
||||
// reverse shape (one row over several verifikat) waits for the residual
|
||||
// link table and is refused loudly, never silently reduced.
|
||||
if (pair.journal_entry_ids.length !== 1) {
|
||||
skipped.push({
|
||||
pair,
|
||||
code: 'UNSUPPORTED_PAIR_SHAPE',
|
||||
message: 'Ett par är en händelse och ett verifikat i den här versionen.',
|
||||
message: 'Flera verifikat i samma par stöds inte ännu: ett par är en eller flera händelser mot ett verifikat.',
|
||||
})
|
||||
continue
|
||||
}
|
||||
const externalIds = [...new Set(pair.external_ids)]
|
||||
if (externalIds.length === 0 || externalIds.length > 50) {
|
||||
skipped.push({
|
||||
pair,
|
||||
code: 'UNSUPPORTED_PAIR_SHAPE',
|
||||
message: 'Ett par kopplar mellan 1 och 50 händelser mot ett verifikat.',
|
||||
})
|
||||
continue
|
||||
}
|
||||
const [externalId] = pair.external_ids
|
||||
const [journalEntryId] = pair.journal_entry_ids
|
||||
|
||||
if (dryRun) {
|
||||
applied.push({ external_id: externalId, journal_entry_id: journalEntryId })
|
||||
for (const externalId of externalIds) {
|
||||
applied.push({ external_id: externalId, journal_entry_id: journalEntryId })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
if (parsed.kind === 'skattekonto') {
|
||||
const r = await linkSkattekontoRow(supabase, companyId, externalId, journalEntryId)
|
||||
applied.push({ external_id: externalId, journal_entry_id: journalEntryId, via: r.via })
|
||||
if (externalIds.length === 1) {
|
||||
const r = await linkSkattekontoRow(supabase, companyId, externalIds[0], journalEntryId)
|
||||
applied.push({ external_id: externalIds[0], journal_entry_id: journalEntryId, via: r.via })
|
||||
await emitMatched(externalIds[0], journalEntryId)
|
||||
} else {
|
||||
// All-or-nothing: the group's sum must settle the verifikat, and a
|
||||
// lost race rolls the whole group back inside the link helper.
|
||||
const r = await linkSkattekontoRows(supabase, companyId, externalIds, journalEntryId)
|
||||
for (const externalId of r.skattekonto_transaction_ids) {
|
||||
applied.push({ external_id: externalId, journal_entry_id: journalEntryId, via: r.via })
|
||||
await emitMatched(externalId, journalEntryId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const { data: account } = await supabase
|
||||
.from('cash_accounts')
|
||||
@@ -172,31 +212,30 @@ export async function matchPairs(
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', parsed.cashAccountId)
|
||||
.maybeSingle<{ ledger_account: string }>()
|
||||
const r = await manualLink(
|
||||
supabase,
|
||||
companyId,
|
||||
externalId,
|
||||
journalEntryId,
|
||||
userId,
|
||||
account?.ledger_account ?? '1930',
|
||||
)
|
||||
if (!r.success) {
|
||||
skipped.push({ pair, code: 'PAIR_NOT_CLOSED', message: r.error ?? 'Kunde inte koppla' })
|
||||
continue
|
||||
// Bank N:1 is per-transaction by design (manualLink documents why the
|
||||
// engine allows several transactions on one verifikat): each link is
|
||||
// independent, so partial success is reported per transaction.
|
||||
for (const externalId of externalIds) {
|
||||
const r = await manualLink(
|
||||
supabase,
|
||||
companyId,
|
||||
externalId,
|
||||
journalEntryId,
|
||||
userId,
|
||||
account?.ledger_account ?? '1930',
|
||||
)
|
||||
if (!r.success) {
|
||||
skipped.push({
|
||||
pair: { external_ids: [externalId], journal_entry_ids: [journalEntryId] },
|
||||
code: 'PAIR_NOT_CLOSED',
|
||||
message: r.error ?? 'Kunde inte koppla',
|
||||
})
|
||||
continue
|
||||
}
|
||||
applied.push({ external_id: externalId, journal_entry_id: journalEntryId })
|
||||
await emitMatched(externalId, journalEntryId)
|
||||
}
|
||||
applied.push({ external_id: externalId, journal_entry_id: journalEntryId })
|
||||
}
|
||||
await eventBus.emit({
|
||||
type: 'reconciliation.matched',
|
||||
payload: {
|
||||
accountKey,
|
||||
externalId,
|
||||
journalEntryId,
|
||||
method: input.use_proposals ? 'proposal' : 'manual',
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
const { code, message } = skipCodeFor(err)
|
||||
skipped.push({ pair, code, message })
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import {
|
||||
entrySettlesAmount,
|
||||
linkSkattekontoRow,
|
||||
linkSkattekontoRows,
|
||||
setSkattekontoRowIgnored,
|
||||
SkattekontoLinkError,
|
||||
unlinkSkattekontoRow,
|
||||
@@ -134,3 +135,70 @@ describe('unlinkSkattekontoRow / setSkattekontoRowIgnored', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('linkSkattekontoRows (N:1)', () => {
|
||||
const ROW2 = 'row-2'
|
||||
const { supabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
})
|
||||
|
||||
it('links a group whose sum the verifikat settles, with one guarded update over all rows', async () => {
|
||||
enqueue({ data: [row({ belopp_skatteverket: 3000 }), row({ id: ROW2, belopp_skatteverket: 2000 })] })
|
||||
enqueue({ data: entry([{ account_number: '1630', debit_amount: 5000, credit_amount: 0 }]) })
|
||||
enqueue({ data: [] }) // nothing outside the group linked to the entry
|
||||
enqueue({ data: [{ id: ROW }, { id: ROW2 }] })
|
||||
const result = await linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2, ROW], ENTRY)
|
||||
expect(result).toEqual({ journal_entry_id: ENTRY, via: 'line', skattekonto_transaction_ids: [ROW, ROW2] })
|
||||
const updates = findCalls('skattekonto_transactions', 'update')
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(updates[0][0]).toMatchObject({ journal_entry_id: ENTRY, suggested_journal_entry_id: null })
|
||||
})
|
||||
|
||||
it('refuses a group where a row is already linked, ignored or upcoming', async () => {
|
||||
enqueue({ data: [row(), row({ id: ROW2, journal_entry_id: 'other' })] })
|
||||
await expect(linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2], ENTRY)).rejects.toMatchObject({ code: 'ALREADY_BOOKED' })
|
||||
reset()
|
||||
enqueue({ data: [row(), row({ id: ROW2, is_ignored: true })] })
|
||||
await expect(linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2], ENTRY)).rejects.toMatchObject({ code: 'ROW_IGNORED' })
|
||||
reset()
|
||||
enqueue({ data: [row(), row({ id: ROW2, status: 'upcoming' })] })
|
||||
await expect(linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2], ENTRY)).rejects.toMatchObject({ code: 'INVALID_CANDIDATE' })
|
||||
})
|
||||
|
||||
it('refuses when the sum does not settle the verifikat or nets to zero', async () => {
|
||||
enqueue({ data: [row({ belopp_skatteverket: 3000 }), row({ id: ROW2, belopp_skatteverket: 2000 })] })
|
||||
enqueue({ data: entry([{ account_number: '1630', debit_amount: 4999, credit_amount: 0 }]) })
|
||||
await expect(linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2], ENTRY)).rejects.toMatchObject({ code: 'INVALID_CANDIDATE' })
|
||||
reset()
|
||||
enqueue({ data: [row({ belopp_skatteverket: 3000 }), row({ id: ROW2, belopp_skatteverket: -3000 })] })
|
||||
await expect(linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2], ENTRY)).rejects.toMatchObject({ code: 'INVALID_CANDIDATE' })
|
||||
})
|
||||
|
||||
it('refuses a verifikat already linked to a row outside the group', async () => {
|
||||
enqueue({ data: [row({ belopp_skatteverket: 3000 }), row({ id: ROW2, belopp_skatteverket: 2000 })] })
|
||||
enqueue({ data: entry([{ account_number: '1630', debit_amount: 5000, credit_amount: 0 }]) })
|
||||
enqueue({ data: [{ id: 'row-elsewhere' }] })
|
||||
await expect(linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2], ENTRY)).rejects.toMatchObject({ code: 'ENTRY_ALREADY_LINKED' })
|
||||
})
|
||||
|
||||
it('rolls a partial hit back and reports LINK_RACE', async () => {
|
||||
enqueue({ data: [row({ belopp_skatteverket: 3000 }), row({ id: ROW2, belopp_skatteverket: 2000 })] })
|
||||
enqueue({ data: entry([{ account_number: '1630', debit_amount: 5000, credit_amount: 0 }]) })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [{ id: ROW }] }) // only one of two rows was still free
|
||||
enqueue({ data: null }) // the revert
|
||||
await expect(linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2], ENTRY)).rejects.toMatchObject({ code: 'LINK_RACE' })
|
||||
const updates = findCalls('skattekonto_transactions', 'update')
|
||||
expect(updates).toHaveLength(2)
|
||||
expect(updates[1][0]).toEqual({ journal_entry_id: null })
|
||||
})
|
||||
|
||||
it('refuses a missing row and an empty selection', async () => {
|
||||
enqueue({ data: [row()] })
|
||||
await expect(linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2], ENTRY)).rejects.toMatchObject({ code: 'TRANSACTION_NOT_FOUND' })
|
||||
await expect(linkSkattekontoRows(supabase as never, COMPANY, [], ENTRY)).rejects.toBeInstanceOf(SkattekontoLinkError)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -238,3 +238,117 @@ export async function setSkattekontoRowIgnored(
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user