fix: Swedish VAT/SIE compliance, storno hardening, document integrity (#209)

* feat: add INK2 declaration improvements, invoice delivery date, and Swedish compliance skills

Expand INK2 engine with full INK2S/INK2R support and improved SRU generation.
Add delivery_date field to invoices and corresponding PDF/migration support.
Add Claude skills for Swedish asset accounting, invoice compliance, SIE import/export, SRU filing, and tax planning.

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

* fix: address PR review — map BAS 4500–4899, strip CRLF in SRU, document P3

- Map BAS accounts 4500–4599 (legoarbeten), 4700–4899 (diverse
  varuinköpskostnader) to SRU 7512 so they are not silently dropped
  from INK2R declarations
- Strip \r\n in sanitizeString to prevent CRLF injection in SRU fields
- Document P3 period suffix limitation for brutet räkenskapsår

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

* fix: correct BAS 4500-4599, 4700-4899 mapping from 7512 to 7511

Per the official BAS-to-SRU mapping, these account ranges are cost of
goods (legoarbeten, inkurans, svinn) and belong under 7511 (Råvaror
och förnödenheter), not 7512 (Handelsvaror). 7512 remains 4600-4699.

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

* fix: Swedish VAT compliance — representation VAT, domestic RC, full BAS 26xx mapping, SIE encoding

- Representation expenses now default to reduced_12 VAT (ML 13 kap 24-25 §§);
  income tax deduction was abolished 2017 but VAT deduction at 12% remains
- Domestic reverse charge (byggtjänster etc.) uses 2647 instead of 2645,
  with distinct line descriptions for Swedish vs EU/non-EU RC
- VAT declaration maps all BAS 26xx variant accounts (egna uttag 2612/2622/2632,
  uthyrning 2613/2623/2633, VMB 2616/2626/2636, import 2615/2625/2635,
  domestic RC 2647, frivillig skattskyldighet 2642) and revenue variants
  (3108/3105/3004/3100) to correct momsdeklaration rutor
- SIE parser: remove unreliable #FORMAT PC8 encoding detection (most software
  exports UTF-8 with PC8 header), parse #FLAGGA for import-already-done warning,
  default SIE type to 1 when absent, fix RTRANS/BTRANS documentation
- SIE export: add #RAR -1 (previous fiscal year), fix UB = IB + movements
- Error messages: add pattern matching for locked period trigger errors

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

* fix: address Greptile review — update ruta49 JSDoc, use null sentinel in error map

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

* fix: harden storno CAS guard, document integrity, and BFNAR archive compliance

- Storno: defer original→reversed until both entries succeed, add CAS guard
  for concurrent reversals, use cancelEntry() instead of delete
- Document: add document.accessed event, enrich archive manifest with metadata,
  add BFNAR 2013:2 systemdokumentation to full archive export
- Verify cron: run daily, configurable batch size, include company_id in audit
- Migrations: integrity audit actions, document version chain, metadata
  immutability, audit deletions, fix immutability for posted/cancelled

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

* fix: address Greptile review — allow is_current_version in immutability trigger, log cancelEntry errors

- Remove is_current_version from blocked fields in enforce_document_metadata_immutability
  trigger so create_document_version RPC can supersede documents linked to posted entries
- Add error logging to cancelEntry for observability on cleanup failures

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-09 16:12:03 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 6ccd4f429c
commit b484e9a7b4
17 changed files with 1214 additions and 47 deletions
@@ -70,27 +70,28 @@ describe('correctEntry', () => {
const correctedEntry = makeJournalEntry({ id: 'corrected-1', correction_of_id: 'orig-1' })
results = [
// 0: fetch original
// 0: fetch original (.single())
{ data: originalEntry, error: null },
// 1: insert reversal entry
// 1: insert reversal entry (.single())
{ data: reversalEntry, error: null },
// 2: insert reversal lines (thenable, no .single())
// 2: insert reversal lines (thenable)
{ data: null, error: null },
// 3: update reversal to posted (thenable)
{ data: null, error: null },
// 4: mark original as reversed (thenable)
{ data: null, error: null },
// 5: fetch accounts for corrected lines
// -- getNextVoucherNumber increments resultIdx --
// 4: fetch accounts for corrected lines (thenable)
{ data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null },
// 6: insert corrected entry
// 5: insert corrected entry (.single())
{ data: correctedEntry, error: null },
// 7: insert corrected lines (thenable)
// 6: insert corrected lines (thenable)
{ data: null, error: null },
// 8: update corrected to posted (thenable)
// 7: update corrected to posted (thenable)
{ data: null, error: null },
// 9: fetch final reversal
// 8: CAS update original to reversed (thenable, needs array for .length check)
{ data: [{ id: 'orig-1' }], error: null },
// 9: fetch final reversal (.single())
{ data: { ...reversalEntry, lines: [] }, error: null },
// 10: fetch final corrected
// 10: fetch final corrected (.single())
{ data: { ...correctedEntry, lines: correctedLines }, error: null },
]
}
@@ -128,6 +129,69 @@ describe('correctEntry', () => {
).rejects.toThrow('not balanced')
})
it('cancels both entries on concurrent reversal (CAS guard)', async () => {
const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'orig-1' })
const correctedEntry = makeJournalEntry({ id: 'corrected-1', correction_of_id: 'orig-1' })
results = [
{ data: originalEntry, error: null }, // 0: fetch original
{ data: reversalEntry, error: null }, // 1: insert reversal
{ data: null, error: null }, // 2: insert reversal lines
{ data: null, error: null }, // 3: post reversal
{ data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 4: accounts
{ data: correctedEntry, error: null }, // 5: insert corrected
{ data: null, error: null }, // 6: insert corrected lines
{ data: null, error: null }, // 7: post corrected
{ data: [], error: null }, // 8: CAS fails — empty array
{ data: null, error: null }, // 9: cancelEntry reversal update
{ data: null, error: null }, // 10: cancelEntry reversal lines delete
{ data: null, error: null }, // 11: cancelEntry corrected update
{ data: null, error: null }, // 12: cancelEntry corrected lines delete
]
const supabase = makeClient()
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines)
).rejects.toThrow('already reversed')
})
it('cancels reversal when corrected entry creation fails', async () => {
const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'orig-1' })
results = [
{ data: originalEntry, error: null }, // 0: fetch original
{ data: reversalEntry, error: null }, // 1: insert reversal
{ data: null, error: null }, // 2: insert reversal lines
{ data: null, error: null }, // 3: post reversal
{ data: [], error: null }, // 4: accounts
{ data: null, error: { message: 'DB error' } }, // 5: insert corrected FAILS
{ data: null, error: null }, // 6: cancelEntry reversal update
{ data: null, error: null }, // 7: cancelEntry reversal lines delete
]
const supabase = makeClient()
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines)
).rejects.toThrow('Failed to create corrected entry')
})
it('cancels reversal entry when reversal lines fail', async () => {
const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'orig-1' })
results = [
{ data: originalEntry, error: null }, // 0: fetch original
{ data: reversalEntry, error: null }, // 1: insert reversal
{ data: null, error: { message: 'line error' } }, // 2: insert reversal lines FAILS
{ data: null, error: null }, // 3: cancelEntry update
{ data: null, error: null }, // 4: cancelEntry lines delete
]
const supabase = makeClient()
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines)
).rejects.toThrow('Failed to create reversal lines')
})
it('emits journal_entry.corrected event', async () => {
setupResults()
+52 -25
View File
@@ -17,6 +17,28 @@ import { validateBalance, getNextVoucherNumber, getSwedishLocalDate } from '@/li
* 3. Link all three via reverses_id, reversed_by_id, correction_of_id
*/
/**
* Cancel a journal entry and delete its lines.
* Uses status='cancelled' instead of DELETE (DB trigger blocks all DELETEs).
* Works for both draft→cancelled and posted→cancelled transitions.
*/
async function cancelEntry(supabase: SupabaseClient, entryId: string): Promise<void> {
const { error: statusErr } = await supabase
.from('journal_entries')
.update({ status: 'cancelled' })
.eq('id', entryId)
if (statusErr) {
console.error(`[storno] cancelEntry: failed to cancel ${entryId}:`, statusErr.message)
}
const { error: linesErr } = await supabase
.from('journal_entry_lines')
.delete()
.eq('journal_entry_id', entryId)
if (linesErr) {
console.error(`[storno] cancelEntry: failed to delete lines for ${entryId}:`, linesErr.message)
}
}
/**
* Correct an existing posted journal entry using the storno method.
*
@@ -106,7 +128,7 @@ export async function correctEntry(
.insert(reversalLineInserts)
if (reversalLinesError) {
await supabase.from('journal_entries').delete().eq('id', reversalEntry.id)
await cancelEntry(supabase, reversalEntry.id)
throw new Error(`Failed to create reversal lines: ${reversalLinesError.message}`)
}
@@ -117,32 +139,17 @@ export async function correctEntry(
.eq('id', reversalEntry.id)
if (postReversalError) {
await cancelEntry(supabase, reversalEntry.id)
throw new Error(`Failed to post reversal entry: ${postReversalError.message}`)
}
// Mark original as reversed
await supabase
.from('journal_entries')
.update({
status: 'reversed',
reversed_by_id: reversalEntry.id,
})
.eq('id', originalEntryId)
// NOTE: Original entry is NOT marked as 'reversed' here. We defer that
// until both the reversal and corrected entries are successfully posted.
// This avoids the impossible reversed→posted rollback if step 2 fails.
// ===== Step 2: Create corrected entry =====
// If anything in this step fails, we must roll back the reversal from step 1
// to avoid leaving the ledger in an inconsistent state.
async function rollbackReversal() {
// Restore original entry to 'posted' status
await supabase
.from('journal_entries')
.update({ status: 'posted', reversed_by_id: null })
.eq('id', originalEntryId)
// Delete the reversal entry (it was just created, safe to remove since
// the DB trigger allows deleting draft entries and we need to clean up)
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
await supabase.from('journal_entries').delete().eq('id', reversalEntry.id)
}
// If anything in this step fails, cancel the reversal entry.
// The original entry was never modified, so no rollback needed.
let correctedEntry: typeof reversalEntry
@@ -214,7 +221,7 @@ export async function correctEntry(
.insert(correctedLineInserts)
if (correctedLinesError) {
await supabase.from('journal_entries').delete().eq('id', correctedEntry.id)
await cancelEntry(supabase, correctedEntry.id)
throw new Error(`Failed to create corrected lines: ${correctedLinesError.message}`)
}
@@ -225,14 +232,34 @@ export async function correctEntry(
.eq('id', correctedEntry.id)
if (postCorrectedError) {
await cancelEntry(supabase, correctedEntry.id)
throw new Error(`Failed to post corrected entry: ${postCorrectedError.message}`)
}
} catch (err) {
// Roll back the reversal to restore ledger consistency
await rollbackReversal()
// Cancel the reversal entry (posted → cancelled). Original was never
// modified so no rollback needed — it's still 'posted'.
await cancelEntry(supabase, reversalEntry.id)
throw err
}
// ===== Mark original as reversed (CAS guard: only if still 'posted') =====
const { data: updatedOriginal, error: casError } = await supabase
.from('journal_entries')
.update({
status: 'reversed',
reversed_by_id: reversalEntry.id,
})
.eq('id', originalEntryId)
.eq('status', 'posted')
.select('id')
if (casError || !updatedOriginal || updatedOriginal.length === 0) {
// Concurrent reversal beat us — cancel both our entries
await cancelEntry(supabase, reversalEntry.id)
await cancelEntry(supabase, correctedEntry!.id)
throw new Error('Entry was already reversed by a concurrent operation')
}
// ===== Step 3: Fetch complete entries =====
const { data: finalReversal } = await supabase
.from('journal_entries')