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:
co-authored by
Claude Opus 4.6
parent
6ccd4f429c
commit
b484e9a7b4
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user