feat(import): keep the source system's #BTRANS/#RTRANS correction history at SIE import (#2458)
* feat(import): keep the source system's #BTRANS/#RTRANS correction history at SIE import A verifikat migrated from Fortnox/Visma lost the trail of what had been corrected in the source system: the parser skipped #BTRANS (struck lines) and #RTRANS (lines added by a rättelse) and nothing else read them. The final state is still built from #TRANS only, exactly as SIE 4B prescribes (#RTRANS is always twinned by an identical #TRANS, so summing all three double-counts, #63). The two history record types now ride along the voucher as `corrections` and land, inside the same atomic import transaction, as one journal_entry_rattelse_log row per corrected voucher with source='sie_import', the file's sie_import_id and the SIE `sign` (who corrected in the source system; SIE carries who, never when). Why the problem occurred: the March fix for double-counting chose "skip" over "keep aside" because nowhere existed to keep the history. The inline rättelse log (July) created that place, and every reader of it (verifikat page, "Rättad" marker, behandlingshistorik, full archive) already renders struck/added snapshots, so the history now flows through one table. What was removed or simplified instead: no new table, no per-import toggle, no fifth RPC parameter (sie_import_id travels inside each payload entry so the (uuid,uuid,uuid,jsonb) signature, grants and statement_timeout stay put and PostgREST sees no overload). The parser's three identical TRANS/RTRANS/BTRANS field parsers collapsed into one helper; the TRANS-only ledger path is byte-for-byte the same. Why this over the proposed shape: the reporter suggested an own table or column. A separate store would need its own readers, RLS, archive classification and behandlingshistorik wiring; the rättelselogg already has all four. Storing history in sie_imports.migration_documentation was rejected as aggregate JSON that no per-verifikat surface reads. Import-sourced log rows survive undo/replace like every other log row (no FK on purpose); a re-import writes fresh rows against fresh entry ids. Parser also warns when an #RTRANS is not followed by its identical #TRANS twin (a spec violation that would silently drop a line from the final state) and the record-type comments now match the spec wording. Migration 20260909132618: three nullable/defaulted columns + CHECKs on journal_entry_rattelse_log, sie_correction_snapshots() helper, import_sie_journal_entries body verbatim plus the history insert. No backfill; existing imports and log rows untouched. Fixes #2427 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W2FcXNv8qRp4GaXCtzEdyn * fix(import): verify the SIE import id before it becomes provenance, keep per-line signatures Review findings on PR #2458, one pass: - Superagent P2: import_sie_journal_entries stored the caller-supplied sieImportId as WORM audit provenance without checking it. The RPC now requires the id to be one of the importing company's own sie_imports rows and fails closed (42501, whole import rolled back) on a foreign or fabricated id. pg-real test added. - Compliance review: the voucher-level external_signature collapsed distinct correctors per line. Each struck/added snapshot now carries its own SIE sign (importer + sie_correction_snapshots), the summary column stays as the first one. - Compliance review: created_at on imported rows is the import moment. Behandlingshistoriken now says so in the event details instead of leaving it implicit (the verifikat page already avoided a date). Migration file is unshipped (not on main); staging re-applied under the same version. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W2FcXNv8qRp4GaXCtzEdyn --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1693,5 +1693,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-08] Zettle orders cron pages candidates (range) and caps entitled syncs at 50 instead of limit(50) before hasCapability: entitlement skips must not consume the batch or advance last_order_synced_at (purchase recovery cursor). Declined a separate cron_checked_at column for now; revisit if scanned non-entitled volume becomes a time-budget problem.
|
||||
[2026-09-09] PR #2416 (Zettle, community-authored) adopted on the contributor's branch instead of re-implemented: kept the Shopify-shaped feed-only design, added migration 20260909100400 for the sites that enumerate platforms/connection tables (platform CHECKs on webshop_orders and webshop_store_settings, writer-role gate trigger, migration-reset snapshot and lock via the 20260826150000 wrapper pattern rather than re-issuing the 400-line reset body), renamed the four PR migrations past prod's 20260908143051, froze the validated connect origin on zettle_connections.return_origin so white-label users return to their brand domain (Zettle has one registered callback URL), and derive per-rate VAT net from Zettle's own product rows (tax / rate drifted from what was charged) with a one-öre-per-row line-sum tolerance instead of 0.005 kr (silently dropped multi-row 12%/6% underlag). Per-purchase rows kept for v1; daily kassarapport aggregation and Finance API fees/payouts are the follow-up.
|
||||
[2026-09-09] Zettle v1 imports split-tender, gift-card (sale or tender) and tip-carrying purchases as is_paid = false rows titled 'bokför manuellt' and skips their refunds, instead of booking them through the one-account / revenue-per-rate model: the skeptic showed a card+cash split would put the whole gross on 1686, a card+invoice split would count as paid, a 0 % gift-card row would land on 3004 / ruta 42 (it is a 2421 liability), and tips would book as momsfri sale. Proper support needs per-payment amounts and a voucher liability on webshop_orders (follow-up). Sync runs claim the connection (sync_lock_until) before refreshing the rotating token: a concurrent cron + manual sync otherwise reuses a refresh token, Zettle answers 400, and the connection flips to revoked. The cron snapshots its candidate list before syncing because each sync moves the row to the tail of the last_order_synced_at ordering, so live offset paging re-fetched synced rows and skipped unseen ones.
|
||||
[2026-09-09] SIE #BTRANS/#RTRANS history (#2427) lands in journal_entry_rattelse_log with source='sie_import' instead of a new table or sie_imports.migration_documentation: every reader (verifikat page, Rättad marker, behandlingshistorik, full archive) already renders that table, and the ledger stays #TRANS-only (SIE 4B rule: #RTRANS is twinned by an identical #TRANS, so summing all three double-counts). sie_import_id travels inside each payload entry rather than as a fifth RPC parameter, so the (uuid,uuid,uuid,jsonb) signature, its grants and its statement_timeout stay put and PostgREST sees no overload. Import history rows survive undo/replace like every other log row (no FK on purpose); a re-import writes fresh rows against fresh entry ids, the old ones only appear in behandlingshistoriken as history of the undone import.
|
||||
[2026-09-09] Resultatrapport and dimension-pnl list account 8999 as a normal class-8 row instead of filtering it out (#2455): our own bokslut verifikat never posts 8999 (it zeroes each P&L account straight against 2099), so the only 8999 balances are manual or SIE-imported omföringar, and hiding those made an account-level report disagree with huvudboken. "Beräknat resultat" now reads zero after such an omföring, the Fortnox/Visma convention. Resultaträkning keeps excluding 8999 because ÅRL's uppställningsform has no such line. This supersedes the 2026-07-29 line that kept Resultatrapport on the same profit as Resultaträkning: they still share 'exclude-all-year-end', and now differ by exactly a booked 8999, pinned in cross-surface-agreement.test.ts. No MCP or v1 surface is built on generateResultatrapport (the income-statement tool and v1 route carry the word "resultatrapport" but serve Resultaträkning), so an agent asked for resultatrapporten still answers the computed figure: follow-up issue filed. Rejected the bigger version (Stage 2 of #1051, 'exclude-final'): with our closing entry shape it would zero every row of a closed year, and it is a separate decision.
|
||||
[2026-09-09] SIE account creation writes each chunk as an ignore-duplicates upsert (ON CONFLICT (company_id, account_number) DO NOTHING, returning the landed rows) instead of a plain INSERT whose duplicate-key error was swallowed: under PostgREST one request is one transaction, so a single concurrent duplicate rolled back the whole statement while the caller counted it as created and moved on with accounts missing. The conflict clause makes the race a skipped row, `created` counts exactly what landed, and the "duplicate" string special-case is gone. Raised by CodeRabbit and the compliance review on PR #2451 (count accuracy under BFNAR 2013:2 p. 9.16); the audit_log trigger stays the per-row record.
|
||||
|
||||
@@ -86,6 +86,10 @@ type RattelseLogRow = {
|
||||
// actor is unknown or the lookup failed.
|
||||
actor_label: string | null
|
||||
created_at: string
|
||||
// 'sie_import': correction history carried by the imported SIE file
|
||||
// (#BTRANS/#RTRANS); the source system's signature stands in for the actor.
|
||||
source?: 'user' | 'sie_import' | null
|
||||
external_signature?: string | null
|
||||
}
|
||||
|
||||
type PeriodStatus = 'open' | 'locked' | 'closed'
|
||||
@@ -432,20 +436,32 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
const struckDisplayLines = rattelseLog
|
||||
.filter((r) => r.rattelse_type === 'lines')
|
||||
.flatMap((r) =>
|
||||
(r.struck_lines ?? []).map((s) => ({ ...s, struck_at: r.created_at, struck_by: r.actor_label }))
|
||||
(r.struck_lines ?? []).map((s) => ({
|
||||
...s,
|
||||
struck_at: r.created_at,
|
||||
struck_by: r.source === 'sie_import' ? (r.external_signature ?? null) : r.actor_label,
|
||||
imported: r.source === 'sie_import',
|
||||
}))
|
||||
)
|
||||
|
||||
// The struck marker beside a struck row: who and when at a glance, the
|
||||
// date alone when the actor could not be resolved.
|
||||
const struckMarker = (s: { struck_at: string; struck_by: string | null }) =>
|
||||
s.struck_by
|
||||
// date alone when the actor could not be resolved. Imported history has no
|
||||
// "when" (SIE carries only the signature), so it says where instead.
|
||||
const struckMarker = (s: { struck_at: string; struck_by: string | null; imported?: boolean }) => {
|
||||
if (s.imported) {
|
||||
return s.struck_by
|
||||
? t('struck_marker_imported_by', { actor: s.struck_by })
|
||||
: t('struck_marker_imported')
|
||||
}
|
||||
return s.struck_by
|
||||
? t('struck_marker_by', { date: formatDate(s.struck_at), actor: s.struck_by })
|
||||
: t('struck_marker', { date: formatDate(s.struck_at) })
|
||||
}
|
||||
|
||||
// Live and struck lines interleaved by original position.
|
||||
const displayRows: Array<
|
||||
| { kind: 'live'; line: JournalEntryLine }
|
||||
| { kind: 'struck'; line: StruckLineSnapshot & { struck_at: string; struck_by: string | null } }
|
||||
| { kind: 'struck'; line: StruckLineSnapshot & { struck_at: string; struck_by: string | null; imported?: boolean } }
|
||||
> = [
|
||||
...lines.map((l) => ({ kind: 'live' as const, line: l })),
|
||||
...struckDisplayLines.map((s) => ({ kind: 'struck' as const, line: s })),
|
||||
@@ -1027,10 +1043,20 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
{/* data-ph-mask: the actor label is a person's e-mail or name */}
|
||||
<span data-ph-mask="" className="text-muted-foreground">
|
||||
<span className="tabular-nums">{formatDate(row.created_at)}</span>
|
||||
{row.actor_label ? ` · ${row.actor_label}` : ''}
|
||||
{row.source === 'sie_import'
|
||||
? row.external_signature
|
||||
? ` · ${t('rattelse_imported_signature', { actor: row.external_signature })}`
|
||||
: ''
|
||||
: row.actor_label
|
||||
? ` · ${row.actor_label}`
|
||||
: ''}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.rattelse_type === 'metadata' ? t('rattelse_kind_metadata') : t('rattelse_kind_lines')}
|
||||
{row.source === 'sie_import'
|
||||
? t('rattelse_kind_imported')
|
||||
: row.rattelse_type === 'metadata'
|
||||
? t('rattelse_kind_metadata')
|
||||
: t('rattelse_kind_lines')}
|
||||
</span>
|
||||
</div>
|
||||
{row.rattelse_type === 'metadata' ? (
|
||||
|
||||
@@ -12,6 +12,9 @@ import { resolveUserLabelsFromProfiles } from '@/lib/reports/behandlingshistorik
|
||||
* the struck_lines snapshots here. Each row also carries `actor_label`, the
|
||||
* actor's profile label, so the page can say who struck a line without the
|
||||
* reader opening a log panel; the raw `actor` uuid is kept unchanged.
|
||||
* Rows with source='sie_import' are correction history carried by the
|
||||
* imported SIE file (#BTRANS/#RTRANS, #2427): no actor, `external_signature`
|
||||
* names who corrected in the source system.
|
||||
*/
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'bookkeeping.journal_entry.rattelse_log',
|
||||
@@ -36,7 +39,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entry_rattelse_log')
|
||||
.select('id, rattelse_type, old_description, new_description, old_entry_date, new_entry_date, struck_lines, added_lines, actor, created_at')
|
||||
.select('id, rattelse_type, old_description, new_description, old_entry_date, new_entry_date, struck_lines, added_lines, actor, created_at, source, external_signature')
|
||||
.eq('company_id', companyId)
|
||||
.eq('journal_entry_id', id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
@@ -2,6 +2,17 @@ import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
|
||||
// A completed sie_imports row the correction history can point at.
|
||||
async function insertSieImport(companyId: string, userId: string): Promise<string> {
|
||||
const { rows } = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.sie_imports (company_id, user_id, filename, file_hash, sie_type, status)
|
||||
VALUES ($1, $2, 'fixture.se', md5(gen_random_uuid()::text), 4, 'completed')
|
||||
RETURNING id`,
|
||||
[companyId, userId],
|
||||
)
|
||||
return rows[0]!.id
|
||||
}
|
||||
|
||||
describe('import_sie_journal_entries RPC', () => {
|
||||
it('rolls back the journal entry header when a line insert fails', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
@@ -188,4 +199,176 @@ describe('import_sie_journal_entries RPC', () => {
|
||||
),
|
||||
).rejects.toThrow(/does not belong to company/i)
|
||||
})
|
||||
// Migration 20260909132618 (#2427): #BTRANS/#RTRANS history rides on the
|
||||
// payload as `corrections` and lands as ONE rättelselogg row per voucher,
|
||||
// source='sie_import'. The ledger insert is unchanged.
|
||||
it('writes source-system correction history to the rättelselogg without touching the lines', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
const sieImportId = await insertSieImport(companyId, userId)
|
||||
|
||||
const payload = [
|
||||
{
|
||||
sourceId: 'A7',
|
||||
series: 'A',
|
||||
date: '2026-03-01',
|
||||
description: 'Corrected in source',
|
||||
sourceSeries: 'A',
|
||||
sourceNumber: 7,
|
||||
sourceType: 'import',
|
||||
sieImportId,
|
||||
corrections: {
|
||||
struck: [
|
||||
{ account_number: '5010', debit_amount: 1200, credit_amount: 0, line_description: 'Lokalhyra', sort_order: 0, signature: 'EL' },
|
||||
],
|
||||
added: [
|
||||
{ account_number: '6540', debit_amount: 1200, credit_amount: 0, line_description: 'IT', sort_order: 0, signature: 'AB' },
|
||||
],
|
||||
signature: 'EL',
|
||||
},
|
||||
lines: [
|
||||
{ account_number: '6540', debit_amount: 1200, credit_amount: 0, currency: 'SEK', line_description: 'IT', sort_order: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 1200, currency: 'SEK', line_description: 'Bank', sort_order: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
sourceId: 'A8',
|
||||
series: 'A',
|
||||
date: '2026-03-02',
|
||||
description: 'Plain voucher',
|
||||
sourceSeries: 'A',
|
||||
sourceNumber: 8,
|
||||
sourceType: 'import',
|
||||
lines: [
|
||||
{ account_number: '5010', debit_amount: 100, credit_amount: 0, currency: 'SEK', line_description: null, sort_order: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 100, currency: 'SEK', line_description: null, sort_order: 1 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const res = await getPool().query<{ import_sie_journal_entries: { inserted_entries: Array<{ id: string; sourceId: string }> } }>(
|
||||
`SELECT public.import_sie_journal_entries($1::uuid, $2::uuid, $3::uuid, $4::jsonb)`,
|
||||
[companyId, userId, fiscalPeriodId, JSON.stringify(payload)],
|
||||
)
|
||||
const inserted = res.rows[0]!.import_sie_journal_entries.inserted_entries
|
||||
expect(inserted).toHaveLength(2)
|
||||
const correctedId = inserted.find((e) => e.sourceId === 'A7')!.id
|
||||
|
||||
// Ledger: exactly the #TRANS rows, posted and balanced. History never
|
||||
// becomes a line.
|
||||
const lines = await getPool().query<{ account_number: string; debit_amount: string; credit_amount: string }>(
|
||||
`SELECT account_number, debit_amount::text, credit_amount::text
|
||||
FROM public.journal_entry_lines
|
||||
WHERE journal_entry_id = $1
|
||||
ORDER BY sort_order`,
|
||||
[correctedId],
|
||||
)
|
||||
expect(lines.rows.map((r) => r.account_number)).toEqual(['6540', '1930'])
|
||||
|
||||
// One log row for the corrected voucher, none for the plain one.
|
||||
const logs = await getPool().query<{
|
||||
journal_entry_id: string
|
||||
rattelse_type: string
|
||||
source: string
|
||||
sie_import_id: string | null
|
||||
external_signature: string | null
|
||||
actor: string | null
|
||||
struck_lines: Array<Record<string, unknown>>
|
||||
added_lines: Array<Record<string, unknown>>
|
||||
}>(
|
||||
`SELECT journal_entry_id, rattelse_type, source, sie_import_id, external_signature, actor, struck_lines, added_lines
|
||||
FROM public.journal_entry_rattelse_log
|
||||
WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
expect(logs.rows).toHaveLength(1)
|
||||
const log = logs.rows[0]!
|
||||
expect(log).toMatchObject({
|
||||
journal_entry_id: correctedId,
|
||||
rattelse_type: 'lines',
|
||||
source: 'sie_import',
|
||||
sie_import_id: sieImportId,
|
||||
external_signature: 'EL',
|
||||
actor: null,
|
||||
})
|
||||
// Snapshot shape matches what correct_entry_lines_inline stores, so the
|
||||
// verifikat page renders both the same way.
|
||||
expect(log.struck_lines).toHaveLength(1)
|
||||
expect(log.struck_lines[0]).toMatchObject({
|
||||
journal_entry_id: correctedId,
|
||||
account_number: '5010',
|
||||
debit_amount: 1200,
|
||||
credit_amount: 0,
|
||||
line_description: 'Lokalhyra',
|
||||
sort_order: 0,
|
||||
currency: 'SEK',
|
||||
signature: 'EL',
|
||||
})
|
||||
expect(typeof log.struck_lines[0]!.id).toBe('string')
|
||||
// Per-line signatures survive: the added row names a different corrector.
|
||||
expect(log.added_lines[0]).toMatchObject({ account_number: '6540', debit_amount: 1200, signature: 'AB' })
|
||||
|
||||
// The log stays WORM for imported rows too.
|
||||
await expect(
|
||||
getPool().query(`DELETE FROM public.journal_entry_rattelse_log WHERE company_id = $1`, [companyId]),
|
||||
).rejects.toThrow(/oföränderlig/)
|
||||
})
|
||||
|
||||
it('rejects correction history whose sie_import_id belongs to another company', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
const other = await seedCompany()
|
||||
const foreignImportId = await insertSieImport(other.companyId, other.userId)
|
||||
|
||||
const payload = [
|
||||
{
|
||||
sourceId: 'A1',
|
||||
series: 'A',
|
||||
date: '2026-03-01',
|
||||
description: 'Foreign provenance',
|
||||
sourceSeries: 'A',
|
||||
sourceNumber: 1,
|
||||
sourceType: 'import',
|
||||
sieImportId: foreignImportId,
|
||||
corrections: {
|
||||
struck: [{ account_number: '5010', debit_amount: 100, credit_amount: 0, line_description: null, sort_order: 0 }],
|
||||
added: [],
|
||||
signature: null,
|
||||
},
|
||||
lines: [
|
||||
{ account_number: '6540', debit_amount: 100, credit_amount: 0, currency: 'SEK', line_description: null, sort_order: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 100, currency: 'SEK', line_description: null, sort_order: 1 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`SELECT public.import_sie_journal_entries($1::uuid, $2::uuid, $3::uuid, $4::jsonb)`,
|
||||
[companyId, userId, fiscalPeriodId, JSON.stringify(payload)],
|
||||
),
|
||||
).rejects.toThrow(/does not belong to company/)
|
||||
|
||||
// Fail closed: the whole import rolled back, nothing posted, no log row.
|
||||
const posted = await getPool().query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count FROM public.journal_entries WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
expect(posted.rows[0]!.count).toBe('0')
|
||||
const logs = await getPool().query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count FROM public.journal_entry_rattelse_log WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
expect(logs.rows[0]!.count).toBe('0')
|
||||
})
|
||||
|
||||
it('rejects an imported history row that claims an actor (provenance check)', async () => {
|
||||
const { companyId } = await seedCompany()
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.journal_entry_rattelse_log
|
||||
(company_id, journal_entry_id, rattelse_type, struck_lines, added_lines, actor, source)
|
||||
VALUES ($1, gen_random_uuid(), 'lines', '[]', '[]', gen_random_uuid(), 'sie_import')`,
|
||||
[companyId],
|
||||
),
|
||||
).rejects.toThrow(/journal_entry_rattelse_log_import_provenance_check/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1220,6 +1220,78 @@ describe('importVouchers: per-voucher series preservation', () => {
|
||||
expect((importCalls[0].args.p_entries as Array<{ series: string }>).map((e) => e.series)).toEqual(['B', 'B', 'C', 'V'])
|
||||
})
|
||||
|
||||
it('sends #BTRANS/#RTRANS history as corrections on the RPC payload, never as lines (#2427)', async () => {
|
||||
const { supabase, rpcCalls } = buildCapturingSupabase()
|
||||
const corrected = {
|
||||
...makeVoucher('A', 7, [
|
||||
{ account: '3001', amount: 1200 },
|
||||
{ account: '1510', amount: -1200 },
|
||||
]),
|
||||
corrections: {
|
||||
struck: [{ account: '1510', amount: 1200, description: 'Fel konto', signature: 'EL' }],
|
||||
added: [{ account: '3001', amount: 1200, signature: 'EL' }],
|
||||
},
|
||||
}
|
||||
const parsed = makeParsedFile({ vouchers: [makeVoucher('A', 6), corrected] })
|
||||
|
||||
const result = await importVouchers(
|
||||
supabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
'period-1',
|
||||
parsed,
|
||||
baseMap,
|
||||
'A',
|
||||
'import-42',
|
||||
)
|
||||
|
||||
expect(result.created).toBe(2)
|
||||
const importCalls = rpcCalls.filter((c) => c.name === 'import_sie_journal_entries')
|
||||
expect(importCalls).toHaveLength(1)
|
||||
const entries = importCalls[0].args.p_entries as Array<Record<string, unknown>>
|
||||
|
||||
// The plain voucher carries no history keys at all.
|
||||
expect(entries[0]).not.toHaveProperty('corrections')
|
||||
expect(entries[0]).not.toHaveProperty('sieImportId')
|
||||
|
||||
// The corrected voucher: lines are the #TRANS rows only ...
|
||||
expect(entries[1].lines).toHaveLength(2)
|
||||
// ... and the history rides alongside, debit/credit split like lines,
|
||||
// stamped with the import id and the source-system signature.
|
||||
expect(entries[1].sieImportId).toBe('import-42')
|
||||
expect(entries[1].corrections).toEqual({
|
||||
struck: [{ account_number: '1510', debit_amount: 1200, credit_amount: 0, line_description: 'Fel konto', sort_order: 0, signature: 'EL' }],
|
||||
added: [{ account_number: '3001', debit_amount: 1200, credit_amount: 0, line_description: null, sort_order: 0, signature: 'EL' }],
|
||||
signature: 'EL',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the source account on history rows whose account is unmapped', async () => {
|
||||
const { supabase, rpcCalls } = buildCapturingSupabase()
|
||||
const corrected = {
|
||||
...makeVoucher('A', 1),
|
||||
corrections: {
|
||||
struck: [{ account: '9999', amount: -1000 }],
|
||||
added: [],
|
||||
},
|
||||
}
|
||||
const parsed = makeParsedFile({ vouchers: [corrected] })
|
||||
|
||||
const result = await importVouchers(supabase, 'company-1', 'user-1', 'period-1', parsed, baseMap, 'A')
|
||||
|
||||
// The voucher itself (mapped #TRANS rows) still imports; only history
|
||||
// references the unmapped account, verbatim, as the source showed it.
|
||||
expect(result.created).toBe(1)
|
||||
const entries = rpcCalls.find((c) => c.name === 'import_sie_journal_entries')!.args.p_entries as Array<Record<string, unknown>>
|
||||
expect(entries[0].corrections).toEqual({
|
||||
struck: [{ account_number: '9999', debit_amount: 0, credit_amount: 1000, line_description: null, sort_order: 0, signature: null }],
|
||||
added: [],
|
||||
signature: null,
|
||||
})
|
||||
// No import record in this call: the id is null, not absent.
|
||||
expect(entries[0].sieImportId).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to defaultSeries when source voucher has empty series (SIE4I)', async () => {
|
||||
const { supabase, journalEntryInserts } = buildCapturingSupabase()
|
||||
const parsed = makeParsedFile({
|
||||
|
||||
@@ -145,6 +145,41 @@ const SIE_EMPTY_SERIES = [
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
// SIE 4B corrected voucher (Fortnox-style): the original 5010 line was
|
||||
// struck (#BTRANS) and replaced by 6540 (#RTRANS, twinned by an identical
|
||||
// #TRANS). Final state = the #TRANS rows only, and it balances.
|
||||
const SIE_WITH_CORRECTIONS = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#FNAMN "Rättat AB"',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 1930 "Företagskonto"',
|
||||
'#KONTO 5010 "Lokalhyra"',
|
||||
'#KONTO 6540 "IT-tjänster"',
|
||||
'#VER A 7 20240301 "Faktura IT"',
|
||||
'{',
|
||||
'#BTRANS 5010 {} 1200.00 20240301 "Lokalhyra" 0 "EL"',
|
||||
'#RTRANS 6540 {} 1200.00 20240301 "IT-tjänster" 0 "EL"',
|
||||
'#TRANS 6540 {} 1200.00 20240301 "IT-tjänster"',
|
||||
'#TRANS 1930 {} -1200.00',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
// Spec violation: an #RTRANS with no identical #TRANS twin after it.
|
||||
const SIE_RTRANS_WITHOUT_TWIN = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#FNAMN "Trasig AB"',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 1930 "Företagskonto"',
|
||||
'#KONTO 6540 "IT-tjänster"',
|
||||
'#VER A 8 20240301 "Utan tvilling"',
|
||||
'{',
|
||||
'#RTRANS 6540 {} 1200.00',
|
||||
'#TRANS 1930 {} -1200.00',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
// SIE file with { on same line as #VER
|
||||
const SIE_BRACE_ON_VER_LINE = [
|
||||
'#FLAGGA 0',
|
||||
@@ -445,6 +480,49 @@ describe('parseSIEFile', () => {
|
||||
expect(errors.length).toBeGreaterThanOrEqual(1)
|
||||
expect(errors.some((e) => e.message.includes('balanserar inte'))).toBe(true)
|
||||
})
|
||||
|
||||
// SIE 4B #BTRANS / #RTRANS (#2427): final state stays #TRANS-only, the
|
||||
// correction history is kept aside on the voucher.
|
||||
it('books #TRANS only and keeps #BTRANS/#RTRANS as correction history', () => {
|
||||
const result = parseSIEFile(SIE_WITH_CORRECTIONS)
|
||||
expect(result.vouchers).toHaveLength(1)
|
||||
const v = result.vouchers[0]
|
||||
|
||||
// Final state: exactly the #TRANS rows, balanced, no double counting.
|
||||
expect(v.lines).toHaveLength(2)
|
||||
expect(v.lines[0]).toMatchObject({ account: '6540', amount: 1200 })
|
||||
expect(v.lines[1]).toMatchObject({ account: '1930', amount: -1200 })
|
||||
expect(result.issues.filter((i) => i.severity === 'error')).toHaveLength(0)
|
||||
|
||||
// History: the struck original and the added replacement, with the
|
||||
// SIE `sign` (who corrected in the source system).
|
||||
expect(v.corrections).toBeDefined()
|
||||
expect(v.corrections!.struck).toHaveLength(1)
|
||||
expect(v.corrections!.struck[0]).toMatchObject({ account: '5010', amount: 1200, description: 'Lokalhyra', signature: 'EL' })
|
||||
expect(v.corrections!.added).toHaveLength(1)
|
||||
expect(v.corrections!.added[0]).toMatchObject({ account: '6540', amount: 1200, signature: 'EL' })
|
||||
expect(result.stats.totalTransactionLines).toBe(2)
|
||||
})
|
||||
|
||||
it('leaves corrections undefined on a voucher without #BTRANS/#RTRANS', () => {
|
||||
const result = parseSIEFile(SIE_WITH_VOUCHERS)
|
||||
for (const v of result.vouchers) {
|
||||
expect(v.corrections).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('warns when an #RTRANS is not twinned by an identical #TRANS', () => {
|
||||
const result = parseSIEFile(SIE_RTRANS_WITHOUT_TWIN)
|
||||
const v = result.vouchers[0]
|
||||
|
||||
// Spec rule: #RTRANS is not part of the final state on its own.
|
||||
expect(v.lines).toHaveLength(1)
|
||||
expect(v.corrections!.added).toHaveLength(1)
|
||||
|
||||
const twinWarnings = result.issues.filter((i) => i.tag === 'RTRANS' && i.severity === 'warning')
|
||||
expect(twinWarnings).toHaveLength(1)
|
||||
expect(twinWarnings[0].message).toContain('följs inte av en identisk #TRANS')
|
||||
})
|
||||
})
|
||||
|
||||
describe('statistics', () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
FiscalYearPrecheck,
|
||||
SIEImport,
|
||||
MigrationDocumentation,
|
||||
SIETransactionLine,
|
||||
} from './types'
|
||||
import type { CreateJournalEntryLineInput } from '@/types'
|
||||
import { roundOre } from '@/lib/money'
|
||||
@@ -1257,7 +1258,12 @@ export async function importVouchers(
|
||||
fiscalPeriodId: string,
|
||||
parsed: ParsedSIEFile,
|
||||
accountMap: Map<string, string>,
|
||||
defaultSeries: string
|
||||
defaultSeries: string,
|
||||
// The sie_imports row this run belongs to. Stamped on the correction
|
||||
// history rows (journal_entry_rattelse_log.sie_import_id) so the log can
|
||||
// be traced back to the file that carried it. Null in callers that have
|
||||
// no import record (tests, legacy paths).
|
||||
sieImportId: string | null = null
|
||||
): Promise<{
|
||||
created: number
|
||||
ids: string[]
|
||||
@@ -1339,7 +1345,41 @@ export async function importVouchers(
|
||||
line_description: string | null
|
||||
dimensions?: Record<string, string>
|
||||
}[]
|
||||
// Source-system correction history (#BTRANS struck / #RTRANS added, see
|
||||
// SIEVoucherCorrections). Persisted by the RPC as a journal_entry_rattelse_log
|
||||
// row with source='sie_import'; never booked as lines.
|
||||
corrections?: {
|
||||
struck: CorrectionLineSnapshot[]
|
||||
added: CorrectionLineSnapshot[]
|
||||
signature: string | null
|
||||
}
|
||||
}
|
||||
|
||||
interface CorrectionLineSnapshot {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
line_description: string | null
|
||||
sort_order: number
|
||||
/** SIE `sign` of this row: who removed/added it in the source system. */
|
||||
signature: string | null
|
||||
}
|
||||
|
||||
// History keeps the source account when it is unmapped: the row is audit
|
||||
// trail, not a booking, and a rewritten account would misdescribe what the
|
||||
// source system showed. Zero-amount rows carry no information and are dropped
|
||||
// like their #TRANS counterparts.
|
||||
const toCorrectionSnapshots = (sourceLines: SIETransactionLine[]): CorrectionLineSnapshot[] =>
|
||||
sourceLines
|
||||
.filter((line) => line.amount !== 0)
|
||||
.map((line, index) => ({
|
||||
account_number: accountMap.get(line.account) ?? line.account,
|
||||
debit_amount: line.amount > 0 ? Math.round(line.amount * 100) / 100 : 0,
|
||||
credit_amount: line.amount < 0 ? Math.round(Math.abs(line.amount) * 100) / 100 : 0,
|
||||
line_description: line.description || null,
|
||||
sort_order: index,
|
||||
signature: line.signature?.trim() || null,
|
||||
}))
|
||||
|
||||
const preparedVouchers: PreparedVoucher[] = []
|
||||
|
||||
@@ -1507,6 +1547,22 @@ export async function importVouchers(
|
||||
OPENING_BALANCE_DESCRIPTION_RE.test(voucher.description || '') &&
|
||||
!SHARE_CAPITAL_DESCRIPTION_RE.test(voucher.description || '')
|
||||
|
||||
let corrections: PreparedVoucher['corrections']
|
||||
if (voucher.corrections) {
|
||||
const struck = toCorrectionSnapshots(voucher.corrections.struck)
|
||||
const added = toCorrectionSnapshots(voucher.corrections.added)
|
||||
if (struck.length > 0 || added.length > 0) {
|
||||
// SIE 4B: `sign` on #BTRANS/#RTRANS names who removed or added the row.
|
||||
// Each snapshot keeps its own; this voucher-level summary is the first
|
||||
// one, for the log row's external_signature column.
|
||||
const signature =
|
||||
[...voucher.corrections.struck, ...voucher.corrections.added]
|
||||
.map((line) => line.signature?.trim())
|
||||
.find((sig) => !!sig) ?? null
|
||||
corrections = { struck, added, signature }
|
||||
}
|
||||
}
|
||||
|
||||
preparedVouchers.push({
|
||||
sourceId: voucherId,
|
||||
series: resolvedSeries,
|
||||
@@ -1516,6 +1572,7 @@ export async function importVouchers(
|
||||
sourceNumber: rawSourceNumber,
|
||||
sourceType: isLikelyOpeningBalance ? 'opening_balance' : 'import',
|
||||
lines,
|
||||
...(corrections ? { corrections } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1572,6 +1629,7 @@ export async function importVouchers(
|
||||
sourceSeries: voucher.sourceSeries,
|
||||
sourceNumber: voucher.sourceNumber,
|
||||
sourceType: voucher.sourceType,
|
||||
...(voucher.corrections ? { corrections: voucher.corrections, sieImportId } : {}),
|
||||
lines: voucher.lines.map((line, lineIndex) => ({
|
||||
account_number: line.account_number,
|
||||
account_id: accountIdMap.get(line.account_number) || null,
|
||||
@@ -2914,7 +2972,8 @@ export async function executeSIEImport(
|
||||
result.fiscalPeriodId,
|
||||
parsed,
|
||||
accountMap,
|
||||
defaultSeries
|
||||
defaultSeries,
|
||||
result.importId
|
||||
)
|
||||
|
||||
result.journalEntriesCreated += voucherResults.created
|
||||
|
||||
+111
-48
@@ -396,6 +396,67 @@ function addIssue(
|
||||
issues.push({ severity, line, message, tag })
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the fields of a #TRANS / #RTRANS / #BTRANS record (identical layout):
|
||||
* #TAG accountNumber {objectList} amount [date] [description] [quantity] [signature]
|
||||
* Returns null (after reporting) when the amount is missing.
|
||||
*/
|
||||
function parseTransactionLine(
|
||||
fields: string[],
|
||||
tag: string,
|
||||
issues: ParseIssue[],
|
||||
lineNum: number
|
||||
): SIETransactionLine | null {
|
||||
// Parse account and capture the object list (in braces)
|
||||
let fieldIndex = 1
|
||||
const account = parseStringField(fields[fieldIndex++])
|
||||
|
||||
// Object list (single field thanks to brace-aware splitting):
|
||||
// dimension tags like {1 "KS01" 6 "P001"}. Parsed onto the line so
|
||||
// import is lossless (dimensions plan PR5).
|
||||
let objectListRaw: string | null = null
|
||||
if (fields[fieldIndex]?.startsWith('{')) {
|
||||
objectListRaw = fields[fieldIndex]
|
||||
fieldIndex++
|
||||
}
|
||||
|
||||
const transAmountStr = fields[fieldIndex]
|
||||
if (!transAmountStr || transAmountStr.trim() === '') {
|
||||
addIssue(issues, 'warning', lineNum, `Belopp saknas i #${tag}: raden hoppas över`, tag)
|
||||
return null
|
||||
}
|
||||
|
||||
const amount = parseNumberField(fields[fieldIndex++])
|
||||
|
||||
const transLine: SIETransactionLine = {
|
||||
account,
|
||||
amount,
|
||||
}
|
||||
|
||||
if (objectListRaw) {
|
||||
const dims = parseObjectList(objectListRaw, issues, lineNum)
|
||||
if (dims) {
|
||||
transLine.dimensions = dims
|
||||
}
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.date = parseSIEDate(parseStringField(fields[fieldIndex++])) || undefined
|
||||
}
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.description = parseStringField(fields[fieldIndex++])
|
||||
}
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.quantity = parseNumberField(fields[fieldIndex++])
|
||||
}
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.signature = parseStringField(fields[fieldIndex++])
|
||||
}
|
||||
|
||||
return transLine
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a SIE file content string
|
||||
*/
|
||||
@@ -432,6 +493,24 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
// Track current voucher being parsed (inside #VER { ... })
|
||||
let currentVoucher: SIEVoucher | null = null
|
||||
|
||||
// SIE 4B: an #RTRANS row must be immediately followed by an identical
|
||||
// #TRANS row (the twin older readers use). Remembered here so the twin
|
||||
// can be verified; a missing twin is reported, since the final state is
|
||||
// built from #TRANS only and would silently lack that line.
|
||||
let pendingRtrans: { line: SIETransactionLine; lineNum: number } | null = null
|
||||
|
||||
const reportMissingRtransTwin = (): void => {
|
||||
if (!pendingRtrans) return
|
||||
addIssue(
|
||||
issues,
|
||||
'warning',
|
||||
pendingRtrans.lineNum,
|
||||
`#RTRANS ${pendingRtrans.line.account} ${pendingRtrans.line.amount.toFixed(2)} följs inte av en identisk #TRANS-rad: rättelseraden ingår inte i verifikatets slutliga rader`,
|
||||
'RTRANS'
|
||||
)
|
||||
pendingRtrans = null
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineNum = i + 1
|
||||
const line = lines[i].trim()
|
||||
@@ -441,6 +520,7 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
|
||||
// Handle voucher block end
|
||||
if (line === '}') {
|
||||
reportMissingRtransTwin()
|
||||
if (currentVoucher) {
|
||||
// Validate voucher balance
|
||||
const total = currentVoucher.lines.reduce((sum, l) => sum + l.amount, 0)
|
||||
@@ -473,6 +553,10 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
const fields = splitSIELine(line)
|
||||
const tag = fields[0].substring(1).toUpperCase()
|
||||
|
||||
if (pendingRtrans && tag !== 'TRANS') {
|
||||
reportMissingRtransTwin()
|
||||
}
|
||||
|
||||
try {
|
||||
switch (tag) {
|
||||
case 'FLAGGA':
|
||||
@@ -708,71 +792,50 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
case 'TRANS':
|
||||
case 'RTRANS':
|
||||
case 'BTRANS': {
|
||||
// #TRANS = final transaction lines (the current state of the voucher)
|
||||
// #RTRANS = supplementary/corrected transaction (must be followed by identical #TRANS for backward compat)
|
||||
// #BTRANS = removed/cancelled transaction (programs not understanding BTRANS simply ignore it)
|
||||
// #TRANS = the voucher's final lines (its current state).
|
||||
// #BTRANS = "removed transaction item": a line struck in the source
|
||||
// system after posting (how the voucher looked before the rättelse).
|
||||
// #RTRANS = "supplementary transaction item": a line added by a
|
||||
// rättelse. Per SIE 4B it is always immediately followed by an
|
||||
// identical #TRANS row, so the line is ALSO in the final state.
|
||||
//
|
||||
// When a voucher has been corrected, Fortnox/Visma emit all three types.
|
||||
// Only #TRANS represents the final voucher state; #RTRANS and #BTRANS are
|
||||
// supplementary history. We skip RTRANS/BTRANS to avoid double-counting
|
||||
// which would make balanced vouchers appear unbalanced.
|
||||
// When a voucher has been corrected, Fortnox/Visma emit all three
|
||||
// types. Only #TRANS is booked (summing all three would double-count
|
||||
// and make balanced vouchers look unbalanced, #63). #BTRANS/#RTRANS
|
||||
// are kept aside as `corrections`: the correction history behind the
|
||||
// verifikat, persisted by the import into the rättelselogg (#2427).
|
||||
if (!currentVoucher) {
|
||||
addIssue(issues, 'error', lineNum, `#${tag} utanför verifikationsblock (#VER): filen kan vara skadad`, tag)
|
||||
break
|
||||
}
|
||||
|
||||
// Skip RTRANS/BTRANS: they are correction audit trail, not final state
|
||||
if (tag === 'RTRANS' || tag === 'BTRANS') {
|
||||
const transLine = parseTransactionLine(fields, tag, issues, lineNum)
|
||||
if (!transLine) {
|
||||
break
|
||||
}
|
||||
|
||||
// Parse account and capture the object list (in braces)
|
||||
let fieldIndex = 1
|
||||
const account = parseStringField(fields[fieldIndex++])
|
||||
|
||||
// Object list (single field thanks to brace-aware splitting):
|
||||
// dimension tags like {1 "KS01" 6 "P001"}. Parsed onto the line so
|
||||
// import is lossless (dimensions plan PR5).
|
||||
let objectListRaw: string | null = null
|
||||
if (fields[fieldIndex]?.startsWith('{')) {
|
||||
objectListRaw = fields[fieldIndex]
|
||||
fieldIndex++
|
||||
}
|
||||
|
||||
const transAmountStr = fields[fieldIndex]
|
||||
if (!transAmountStr || transAmountStr.trim() === '') {
|
||||
addIssue(issues, 'warning', lineNum, `Belopp saknas i #${tag}: raden hoppas över`, tag)
|
||||
if (tag === 'BTRANS') {
|
||||
const corrections = (currentVoucher.corrections ??= { struck: [], added: [] })
|
||||
corrections.struck.push(transLine)
|
||||
break
|
||||
}
|
||||
|
||||
const amount = parseNumberField(fields[fieldIndex++])
|
||||
|
||||
const transLine: SIETransactionLine = {
|
||||
account,
|
||||
amount,
|
||||
if (tag === 'RTRANS') {
|
||||
const corrections = (currentVoucher.corrections ??= { struck: [], added: [] })
|
||||
corrections.added.push(transLine)
|
||||
pendingRtrans = { line: transLine, lineNum }
|
||||
break
|
||||
}
|
||||
|
||||
if (objectListRaw) {
|
||||
const dims = parseObjectList(objectListRaw, issues, lineNum)
|
||||
if (dims) {
|
||||
transLine.dimensions = dims
|
||||
if (pendingRtrans) {
|
||||
const twin = pendingRtrans.line
|
||||
if (twin.account === transLine.account && Math.abs(twin.amount - transLine.amount) < 0.005) {
|
||||
pendingRtrans = null
|
||||
} else {
|
||||
reportMissingRtransTwin()
|
||||
}
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.date = parseSIEDate(parseStringField(fields[fieldIndex++])) || undefined
|
||||
}
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.description = parseStringField(fields[fieldIndex++])
|
||||
}
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.quantity = parseNumberField(fields[fieldIndex++])
|
||||
}
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.signature = parseStringField(fields[fieldIndex++])
|
||||
}
|
||||
|
||||
currentVoucher.lines.push(transLine)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -109,6 +109,23 @@ export interface SIEDimensionValue {
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Correction history carried by a #VER (SIE 4B #BTRANS / #RTRANS).
|
||||
*
|
||||
* `struck` = #BTRANS rows: lines removed in the source system after
|
||||
* posting (how the voucher looked before the correction).
|
||||
* `added` = #RTRANS rows: lines added by a correction. Per spec each #RTRANS
|
||||
* is immediately followed by an identical #TRANS, so these lines are ALSO
|
||||
* present in `lines`; they are listed here only to mark them as corrections.
|
||||
*
|
||||
* Never part of the final voucher state: `lines` (#TRANS only) is what gets
|
||||
* booked, this is audit trail for the rättelselogg.
|
||||
*/
|
||||
export interface SIEVoucherCorrections {
|
||||
struck: SIETransactionLine[]
|
||||
added: SIETransactionLine[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Voucher/Journal entry from #VER tag
|
||||
*/
|
||||
@@ -120,6 +137,8 @@ export interface SIEVoucher {
|
||||
registrationDate?: Date
|
||||
signature?: string
|
||||
lines: SIETransactionLine[]
|
||||
/** Set only when the #VER carried #BTRANS or #RTRANS rows. */
|
||||
corrections?: SIEVoucherCorrections
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -375,6 +375,39 @@ describe('rattelseEvent', () => {
|
||||
expect(ev.details[1]).toContain('6550 D 1')
|
||||
})
|
||||
|
||||
it('labels imported SIE correction history apart from a rättelse made here (#2427)', () => {
|
||||
const ev = rattelseEvent(
|
||||
{
|
||||
id: 'r3',
|
||||
journal_entry_id: 'entry-1',
|
||||
rattelse_type: 'lines',
|
||||
old_description: null,
|
||||
new_description: null,
|
||||
old_entry_date: null,
|
||||
new_entry_date: null,
|
||||
struck_lines: [{ account_number: '5010', debit_amount: 1200, credit_amount: 0 }],
|
||||
added_lines: [{ account_number: '6540', debit_amount: 1200, credit_amount: 0 }],
|
||||
actor: null,
|
||||
created_at: '2026-03-11T08:00:00Z',
|
||||
source: 'sie_import',
|
||||
sie_import_id: 'import-1',
|
||||
external_signature: 'EL',
|
||||
},
|
||||
new Map([['entry-1', baseEntry]]),
|
||||
)
|
||||
expect(ev).toMatchObject({
|
||||
code: 'journal_entry.imported_correction_history',
|
||||
event: 'Rättelsehistorik från källsystemet (SIE-import)',
|
||||
object: 'A12',
|
||||
actor: { type: 'system', user_id: null, actor_label: 'SIE-import' },
|
||||
source: 'rattelse_log',
|
||||
})
|
||||
expect(ev.details[0]).toContain('Strukna rader i källsystemet (1): 5010 D 1')
|
||||
expect(ev.details[1]).toContain('Tillagda rader i källsystemet (1): 6540 D 1')
|
||||
expect(ev.details[2]).toBe('Signatur i källsystemet: EL')
|
||||
expect(ev.details[3]).toContain('registrering vid SIE-import')
|
||||
})
|
||||
|
||||
it('describes metadata changes', () => {
|
||||
const ev = rattelseEvent(
|
||||
{
|
||||
|
||||
@@ -133,6 +133,11 @@ interface RattelseRow {
|
||||
added_lines: unknown
|
||||
actor: string | null
|
||||
created_at: string
|
||||
/** 'sie_import' = correction history carried by the SIE file (#2427); absent/'user' = made here. */
|
||||
source?: 'user' | 'sie_import' | null
|
||||
sie_import_id?: string | null
|
||||
/** SIE `sign` on the #BTRANS/#RTRANS rows: who corrected in the source system. */
|
||||
external_signature?: string | null
|
||||
}
|
||||
|
||||
interface MigrationResetRow {
|
||||
@@ -714,6 +719,31 @@ export function rattelseEvent(
|
||||
): RawBehandlingshistorikEvent {
|
||||
const entry = entryById.get(row.journal_entry_id)
|
||||
const details: string[] = []
|
||||
if (row.source === 'sie_import') {
|
||||
// History the source system recorded before the migration (SIE 4B
|
||||
// #BTRANS/#RTRANS). Not a rättelse made here: no actor, and the row's
|
||||
// created_at is the import moment, not when the correction happened
|
||||
// (SIE carries who, never when). Labelled apart so behandlingshistoriken
|
||||
// never claims a correction Accounted did not perform.
|
||||
const struck = Array.isArray(row.struck_lines) ? row.struck_lines : []
|
||||
const added = Array.isArray(row.added_lines) ? row.added_lines : []
|
||||
if (struck.length > 0) details.push(`Strukna rader i källsystemet (${struck.length}): ${struck.map(describeLine).join('; ')}`)
|
||||
if (added.length > 0) details.push(`Tillagda rader i källsystemet (${added.length}): ${added.map(describeLine).join('; ')}`)
|
||||
if (row.external_signature) details.push(`Signatur i källsystemet: ${row.external_signature}`)
|
||||
details.push('Tidpunkt = registrering vid SIE-import; rättelsedatum saknas i SIE-formatet')
|
||||
return {
|
||||
id: `rattelse:${row.id}`,
|
||||
occurred_at: toIso(row.created_at)!,
|
||||
category: 'verifikation',
|
||||
code: 'journal_entry.imported_correction_history',
|
||||
event: 'Rättelsehistorik från källsystemet (SIE-import)',
|
||||
object: entry ? voucherLabel(entry.voucher_series, entry.voucher_number) : null,
|
||||
actor: { type: 'system', user_id: null, actor_label: 'SIE-import' },
|
||||
details,
|
||||
source: 'rattelse_log',
|
||||
count: 1,
|
||||
}
|
||||
}
|
||||
if (row.rattelse_type === 'metadata') {
|
||||
if (!same(row.old_description, row.new_description)) {
|
||||
details.push(`Beskrivning: ${fmtValue(row.old_description)} → ${fmtValue(row.new_description)}`)
|
||||
|
||||
@@ -5406,11 +5406,15 @@
|
||||
"correct_metadata": "Change text or date",
|
||||
"struck_marker": "Struck {date}",
|
||||
"struck_marker_by": "Struck {date} by {actor}",
|
||||
"struck_marker_imported": "Struck in the source system before import",
|
||||
"struck_marker_imported_by": "Struck in the source system by {actor}",
|
||||
"help_rattelse_tracks_open": "While the period is open and unlocked, a posted voucher is corrected inside the voucher itself: strike lines or change the text and date. The original stays visible with a strikethrough and every correction is logged with who and when.",
|
||||
"help_rattelse_tracks_locked": "Once the period is locked or closed, storno is the only path: the voucher is reversed and, if needed, replaced by a correction voucher. If the month has already been VAT-declared, correcting VAT accounts can affect the filed declaration.",
|
||||
"rattelse_history_title": "Correction history",
|
||||
"rattelse_kind_metadata": "Text/date corrected",
|
||||
"rattelse_kind_lines": "Lines struck and replaced",
|
||||
"rattelse_kind_imported": "Correction history from the source system (SIE)",
|
||||
"rattelse_imported_signature": "source-system signature: {actor}",
|
||||
"correct_opening_balances": "Correct opening balances",
|
||||
"reverse_action": "Reverse (storno)",
|
||||
"reverse_confirm_title": "Reverse journal entry",
|
||||
|
||||
@@ -5406,11 +5406,15 @@
|
||||
"correct_metadata": "Ändra text eller datum",
|
||||
"struck_marker": "Struken {date}",
|
||||
"struck_marker_by": "Struken {date} av {actor}",
|
||||
"struck_marker_imported": "Struken i källsystemet före importen",
|
||||
"struck_marker_imported_by": "Struken i källsystemet av {actor}",
|
||||
"help_rattelse_tracks_open": "I en öppen, olåst period rättar du ett bokfört verifikat direkt i verifikatet: stryk rader eller ändra text och datum. Originalet förblir synligt överstruket och varje rättelse loggas med vem och när.",
|
||||
"help_rattelse_tracks_locked": "När perioden är låst eller stängd är storno den enda vägen: verifikatet återförs och ersätts vid behov av ett ändringsverifikat. Om månaden redan är momsdeklarerad kan en rättelse av momskonton påverka den inlämnade deklarationen.",
|
||||
"rattelse_history_title": "Rättelsehistorik",
|
||||
"rattelse_kind_metadata": "Text/datum rättat",
|
||||
"rattelse_kind_lines": "Rader strukna och ersatta",
|
||||
"rattelse_kind_imported": "Rättelsehistorik från källsystemet (SIE)",
|
||||
"rattelse_imported_signature": "signatur i källsystemet: {actor}",
|
||||
"correct_opening_balances": "Korrigera ingående balanser",
|
||||
"reverse_action": "Återför (storno)",
|
||||
"reverse_confirm_title": "Återför verifikat",
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
-- SIE import: keep the source system's correction history (#2427).
|
||||
--
|
||||
-- SIE 4B carries a corrected voucher as three record types: #TRANS (final
|
||||
-- state), #BTRANS (a line struck after posting) and #RTRANS (a line added by
|
||||
-- a rättelse, always twinned by an identical #TRANS). The parser has booked
|
||||
-- #TRANS only since #63 (summing all three double-counted), and dropped the
|
||||
-- other two, so a migrated verifikat lost the trail of what was corrected.
|
||||
--
|
||||
-- This migration keeps that trail in journal_entry_rattelse_log, the table
|
||||
-- the inline rättelse flow already writes and every reader (verifikat page,
|
||||
-- rattelse-flags "Rättad" marker, behandlingshistorik, full archive) already
|
||||
-- renders:
|
||||
--
|
||||
-- 1. Three nullable/defaulted columns tell an imported history row apart
|
||||
-- from a rättelse made here: source ('user' | 'sie_import'),
|
||||
-- sie_import_id (the file it came from) and external_signature (the
|
||||
-- SIE `sign` field: who removed/added the row in the source system;
|
||||
-- SIE carries who, never when). actor stays NULL for imported rows.
|
||||
-- 2. import_sie_journal_entries writes one 'lines' log row per voucher
|
||||
-- that carried #BTRANS/#RTRANS, inside the same atomic transaction as
|
||||
-- the voucher itself. The ledger insert is untouched: lines still come
|
||||
-- from #TRANS only, so balances and voucher numbering are unchanged.
|
||||
--
|
||||
-- Existing rows and existing imports are untouched (no backfill). Import
|
||||
-- history rows survive undo/replace like every other log row (the table has
|
||||
-- no FK to journal_entries on purpose: behandlingshistorik must not vanish
|
||||
-- with its subject).
|
||||
--
|
||||
-- Function body below is the 20260712150000 text verbatim plus the
|
||||
-- correction block; the statement_timeout from 20260721144311 is carried in
|
||||
-- the header because CREATE OR REPLACE resets function configuration.
|
||||
--
|
||||
-- pg-test: lib/import/__tests__/sie-import-atomic.pg.test.ts
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. journal_entry_rattelse_log: provenance columns
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.journal_entry_rattelse_log
|
||||
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'user',
|
||||
ADD COLUMN IF NOT EXISTS sie_import_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS external_signature text;
|
||||
|
||||
ALTER TABLE public.journal_entry_rattelse_log
|
||||
DROP CONSTRAINT IF EXISTS journal_entry_rattelse_log_source_check;
|
||||
ALTER TABLE public.journal_entry_rattelse_log
|
||||
ADD CONSTRAINT journal_entry_rattelse_log_source_check
|
||||
CHECK (source IN ('user', 'sie_import'));
|
||||
|
||||
-- Imported rows carry no actor; a user rättelse always does (the RPCs
|
||||
-- resolve it before writing). Keeps the two provenances honest.
|
||||
ALTER TABLE public.journal_entry_rattelse_log
|
||||
DROP CONSTRAINT IF EXISTS journal_entry_rattelse_log_import_provenance_check;
|
||||
ALTER TABLE public.journal_entry_rattelse_log
|
||||
ADD CONSTRAINT journal_entry_rattelse_log_import_provenance_check
|
||||
CHECK (
|
||||
(source = 'sie_import' AND actor IS NULL)
|
||||
OR (source = 'user' AND sie_import_id IS NULL AND external_signature IS NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entry_rattelse_log_sie_import
|
||||
ON public.journal_entry_rattelse_log (company_id, sie_import_id)
|
||||
WHERE sie_import_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN public.journal_entry_rattelse_log.source IS
|
||||
'user: rättelse made in Accounted (actor set). sie_import: correction history carried by the imported SIE file (#BTRANS/#RTRANS), actor NULL.';
|
||||
COMMENT ON COLUMN public.journal_entry_rattelse_log.sie_import_id IS
|
||||
'sie_imports.id of the file that carried this history row (source = sie_import). No FK: the log outlives undo/replace.';
|
||||
COMMENT ON COLUMN public.journal_entry_rattelse_log.external_signature IS
|
||||
'SIE sign field on the #BTRANS/#RTRANS rows: who removed/added the line in the source system. Free text, never a user id.';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Snapshot builder: SIE correction rows in the inline-rättelse snapshot shape
|
||||
-- =============================================================================
|
||||
-- correct_entry_lines_inline stores to_jsonb(journal_entry_lines) rows. The
|
||||
-- verifikat page keys on id and interleaves on sort_order, so imported rows
|
||||
-- get a fresh id and the same field set. Amounts are what the caller sends
|
||||
-- (already rounded to öre by the importer); currency is the import's SEK.
|
||||
-- `signature` is the SIE `sign` of that row: who removed/added it in the
|
||||
-- source system, kept per line so distinct correctors are not collapsed.
|
||||
-- VOLATILE because of gen_random_uuid().
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.sie_correction_snapshots(
|
||||
p_entry_id uuid,
|
||||
p_rows jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE sql
|
||||
VOLATILE
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT COALESCE(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', gen_random_uuid(),
|
||||
'journal_entry_id', p_entry_id,
|
||||
'account_number', btrim(r.value->>'account_number'),
|
||||
'debit_amount', round(COALESCE((r.value->>'debit_amount')::numeric, 0), 2),
|
||||
'credit_amount', round(COALESCE((r.value->>'credit_amount')::numeric, 0), 2),
|
||||
'currency', 'SEK',
|
||||
'line_description', NULLIF(btrim(COALESCE(r.value->>'line_description', '')), ''),
|
||||
'sort_order', COALESCE((r.value->>'sort_order')::integer, r.ord::integer - 1),
|
||||
'signature', NULLIF(btrim(COALESCE(r.value->>'signature', '')), '')
|
||||
)
|
||||
ORDER BY r.ord
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
FROM jsonb_array_elements(p_rows) WITH ORDINALITY AS r(value, ord)
|
||||
WHERE jsonb_typeof(r.value) = 'object';
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.sie_correction_snapshots(uuid, jsonb) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.sie_correction_snapshots(uuid, jsonb) TO authenticated, service_role;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. import_sie_journal_entries: write the history row per corrected voucher
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.import_sie_journal_entries(
|
||||
p_company_id uuid,
|
||||
p_user_id uuid,
|
||||
p_fiscal_period_id uuid,
|
||||
p_entries jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
SET statement_timeout = '290s'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_entry jsonb;
|
||||
v_line jsonb;
|
||||
v_series text;
|
||||
v_count integer;
|
||||
v_new_last integer;
|
||||
v_start integer;
|
||||
v_assigned_number integer;
|
||||
v_entry_id uuid;
|
||||
v_deb numeric;
|
||||
v_cred numeric;
|
||||
v_inserted jsonb := '[]'::jsonb;
|
||||
v_corrections jsonb;
|
||||
v_struck jsonb;
|
||||
v_added jsonb;
|
||||
v_sie_import_id uuid;
|
||||
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
|
||||
BEGIN
|
||||
IF p_entries IS NULL OR jsonb_typeof(p_entries) <> 'array' THEN
|
||||
RAISE EXCEPTION 'p_entries must be a JSON array';
|
||||
END IF;
|
||||
|
||||
-- Tenant guard: anon/authenticated may only import into their own companies;
|
||||
-- service_role / direct access (no JWT role) bypasses for migrations and
|
||||
-- server-side maintenance paths that scope company access before calling.
|
||||
-- NULL-safe predicate (a NULL company resolves to false) per #881.
|
||||
IF v_jwt_role IN ('anon', 'authenticated')
|
||||
AND NOT public.caller_is_company_member(p_company_id) THEN
|
||||
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
IF v_jwt_role IN ('anon', 'authenticated')
|
||||
AND auth.uid() IS DISTINCT FROM p_user_id THEN
|
||||
RAISE EXCEPTION 'unauthorized: p_user_id must match auth.uid()'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- The fiscal period must belong to the target company: a caller could
|
||||
-- otherwise post into another company's period id (defense in depth; the
|
||||
-- header company_id/FK would still scope the rows, but fail closed here).
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.fiscal_periods
|
||||
WHERE id = p_fiscal_period_id AND company_id = p_company_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'fiscal period % does not belong to company %', p_fiscal_period_id, p_company_id
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
CREATE TEMP TABLE IF NOT EXISTS pg_temp.sie_import_series_numbers (
|
||||
series text PRIMARY KEY,
|
||||
next_number integer NOT NULL
|
||||
) ON COMMIT DROP;
|
||||
TRUNCATE pg_temp.sie_import_series_numbers;
|
||||
|
||||
FOR v_series, v_count IN
|
||||
SELECT COALESCE(NULLIF(e.value->>'series', ''), 'A') AS series, count(*)::integer AS count
|
||||
FROM jsonb_array_elements(p_entries) WITH ORDINALITY AS e(value, ord)
|
||||
GROUP BY COALESCE(NULLIF(e.value->>'series', ''), 'A')
|
||||
ORDER BY min(e.ord)
|
||||
LOOP
|
||||
INSERT INTO public.voucher_sequences
|
||||
(company_id, user_id, fiscal_period_id, voucher_series, last_number)
|
||||
VALUES
|
||||
(p_company_id, p_user_id, p_fiscal_period_id, v_series, v_count)
|
||||
ON CONFLICT (company_id, fiscal_period_id, voucher_series)
|
||||
DO UPDATE SET
|
||||
last_number = public.voucher_sequences.last_number + EXCLUDED.last_number,
|
||||
updated_at = now()
|
||||
RETURNING last_number INTO v_new_last;
|
||||
|
||||
v_start := v_new_last - v_count + 1;
|
||||
|
||||
INSERT INTO pg_temp.sie_import_series_numbers(series, next_number)
|
||||
VALUES (v_series, v_start);
|
||||
END LOOP;
|
||||
|
||||
FOR v_entry IN
|
||||
SELECT e.value
|
||||
FROM jsonb_array_elements(p_entries) WITH ORDINALITY AS e(value, ord)
|
||||
ORDER BY e.ord
|
||||
LOOP
|
||||
v_series := COALESCE(NULLIF(v_entry->>'series', ''), 'A');
|
||||
|
||||
SELECT next_number
|
||||
INTO v_assigned_number
|
||||
FROM pg_temp.sie_import_series_numbers
|
||||
WHERE series = v_series
|
||||
FOR UPDATE;
|
||||
|
||||
UPDATE pg_temp.sie_import_series_numbers
|
||||
SET next_number = next_number + 1
|
||||
WHERE series = v_series;
|
||||
|
||||
INSERT INTO public.journal_entries (
|
||||
user_id,
|
||||
company_id,
|
||||
fiscal_period_id,
|
||||
voucher_number,
|
||||
voucher_series,
|
||||
entry_date,
|
||||
description,
|
||||
source_type,
|
||||
source_voucher_series,
|
||||
source_voucher_number,
|
||||
status
|
||||
)
|
||||
VALUES (
|
||||
p_user_id,
|
||||
p_company_id,
|
||||
p_fiscal_period_id,
|
||||
v_assigned_number,
|
||||
v_series,
|
||||
(v_entry->>'date')::date,
|
||||
v_entry->>'description',
|
||||
COALESCE(NULLIF(v_entry->>'sourceType', ''), 'import'),
|
||||
NULLIF(v_entry->>'sourceSeries', ''),
|
||||
CASE
|
||||
WHEN v_entry ? 'sourceNumber' AND v_entry->>'sourceNumber' IS NOT NULL
|
||||
THEN (v_entry->>'sourceNumber')::integer
|
||||
ELSE NULL
|
||||
END,
|
||||
'draft'
|
||||
)
|
||||
RETURNING id INTO v_entry_id;
|
||||
|
||||
IF jsonb_typeof(v_entry->'lines') <> 'array' OR jsonb_array_length(v_entry->'lines') = 0 THEN
|
||||
RAISE EXCEPTION 'SIE journal entry % has no lines', COALESCE(v_entry->>'sourceId', '<unknown>');
|
||||
END IF;
|
||||
|
||||
FOR v_line IN
|
||||
SELECT l.value
|
||||
FROM jsonb_array_elements(v_entry->'lines') WITH ORDINALITY AS l(value, ord)
|
||||
ORDER BY l.ord
|
||||
LOOP
|
||||
INSERT INTO public.journal_entry_lines (
|
||||
journal_entry_id,
|
||||
account_number,
|
||||
account_id,
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
currency,
|
||||
line_description,
|
||||
sort_order,
|
||||
dimensions
|
||||
)
|
||||
VALUES (
|
||||
v_entry_id,
|
||||
v_line->>'account_number',
|
||||
CASE
|
||||
WHEN v_line ? 'account_id' AND v_line->>'account_id' IS NOT NULL
|
||||
THEN (v_line->>'account_id')::uuid
|
||||
ELSE NULL
|
||||
END,
|
||||
COALESCE((v_line->>'debit_amount')::numeric, 0),
|
||||
COALESCE((v_line->>'credit_amount')::numeric, 0),
|
||||
COALESCE(NULLIF(v_line->>'currency', ''), 'SEK'),
|
||||
NULLIF(v_line->>'line_description', ''),
|
||||
COALESCE((v_line->>'sort_order')::integer, 0),
|
||||
COALESCE(v_line->'dimensions', '{}'::jsonb)
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
-- Per-voucher balance enforcement (hard rule #3). SECURITY DEFINER + the
|
||||
-- direct draft->posted UPDATE below bypass the trigger path, so assert
|
||||
-- balance here; a RAISE rolls the whole atomic import back.
|
||||
SELECT COALESCE(sum(debit_amount), 0), COALESCE(sum(credit_amount), 0)
|
||||
INTO v_deb, v_cred
|
||||
FROM public.journal_entry_lines
|
||||
WHERE journal_entry_id = v_entry_id;
|
||||
|
||||
IF round(v_deb, 2) <> round(v_cred, 2) OR round(v_deb, 2) <= 0 THEN
|
||||
RAISE EXCEPTION 'SIE journal entry % is unbalanced (debit %, credit %)',
|
||||
COALESCE(v_entry->>'sourceId', '<unknown>'), v_deb, v_cred;
|
||||
END IF;
|
||||
|
||||
UPDATE public.journal_entries
|
||||
SET status = 'posted',
|
||||
committed_at = now()
|
||||
WHERE id = v_entry_id
|
||||
AND company_id = p_company_id;
|
||||
|
||||
-- Source-system correction history (#2427). The SIE file's #BTRANS
|
||||
-- (struck) and #RTRANS (added) rows for this voucher land as ONE
|
||||
-- rättelselogg row with source='sie_import': the same snapshot shape
|
||||
-- correct_entry_lines_inline writes, so the verifikat page and
|
||||
-- behandlingshistoriken render it unchanged. Never booked as lines; the
|
||||
-- ledger above is built from #TRANS only.
|
||||
v_corrections := v_entry->'corrections';
|
||||
IF jsonb_typeof(v_corrections) = 'object' THEN
|
||||
v_struck := CASE WHEN jsonb_typeof(v_corrections->'struck') = 'array'
|
||||
THEN v_corrections->'struck' ELSE '[]'::jsonb END;
|
||||
v_added := CASE WHEN jsonb_typeof(v_corrections->'added') = 'array'
|
||||
THEN v_corrections->'added' ELSE '[]'::jsonb END;
|
||||
|
||||
IF jsonb_array_length(v_struck) > 0 OR jsonb_array_length(v_added) > 0 THEN
|
||||
-- Provenance is caller-supplied JSON, so verify it before it becomes
|
||||
-- WORM audit trail: the import id must be this company's own
|
||||
-- sie_imports row (a foreign or fabricated id fails closed, never
|
||||
-- silently nulled). A malformed uuid string raises on the cast, which
|
||||
-- rolls the whole import back like every other payload defect.
|
||||
v_sie_import_id := NULLIF(btrim(COALESCE(v_entry->>'sieImportId', '')), '')::uuid;
|
||||
IF v_sie_import_id IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM public.sie_imports si
|
||||
WHERE si.id = v_sie_import_id AND si.company_id = p_company_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'sie import % does not belong to company %', v_sie_import_id, p_company_id
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.journal_entry_rattelse_log (
|
||||
company_id,
|
||||
journal_entry_id,
|
||||
rattelse_type,
|
||||
struck_lines,
|
||||
added_lines,
|
||||
actor,
|
||||
source,
|
||||
sie_import_id,
|
||||
external_signature
|
||||
)
|
||||
VALUES (
|
||||
p_company_id,
|
||||
v_entry_id,
|
||||
'lines',
|
||||
public.sie_correction_snapshots(v_entry_id, v_struck),
|
||||
public.sie_correction_snapshots(v_entry_id, v_added),
|
||||
NULL,
|
||||
'sie_import',
|
||||
v_sie_import_id,
|
||||
NULLIF(btrim(COALESCE(v_corrections->>'signature', '')), '')
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_inserted := v_inserted || jsonb_build_array(jsonb_build_object(
|
||||
'id', v_entry_id,
|
||||
'sourceId', v_entry->>'sourceId',
|
||||
'series', v_series,
|
||||
'voucherNumber', v_assigned_number,
|
||||
'sourceType', COALESCE(NULLIF(v_entry->>'sourceType', ''), 'import')
|
||||
));
|
||||
END LOOP;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'inserted_entries', v_inserted,
|
||||
'skipped_duplicates', '[]'::jsonb,
|
||||
'validation_errors', '[]'::jsonb
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.import_sie_journal_entries(uuid, uuid, uuid, jsonb) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.import_sie_journal_entries(uuid, uuid, uuid, jsonb) TO authenticated, service_role;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user