47c039453c
* feat(import): undo a bank file import including ignored transactions (#1672) A mis-parsed bank CSV could not be cleaned up: re-importing dedup-skips the bad rows, the single-row DELETE refuses imported rows by design (TRANSACTION_DELETE_IMPORTED), and there was no bulk action. Transactions also never recorded which import batch inserted them, so a strictly scoped undo was impossible. - transactions.bank_file_import_id: batch link stamped at ingest by both bank-file import paths (dashboard execute route, v1 REST route). PSD2/ manual/MCP rows stay NULL. No retroactive backfill: fuzzy attribution could delete rows belonging to a different import. - undo_bank_file_import RPC: owner/admin-only bulk delete of the batch's unbooked rows, ignored INCLUDED. Booked rows (journal link, payment rows, voucher links) and rows with append-only payment_match_log history are skipped and reported, mirroring the single-row route's guards. Marks the import 'undone' (re-import reuses the row via the company_id+file_hash upsert), writes one audit_log summary row, and hardens the actor gate like undo_sie_import: p_user_id honored only for service_role callers, 42501 otherwise, no anon EXECUTE. - DELETE /api/import/bank-file/[id]/undo returns the deletion report; RPC 42501 maps to BANK_FILE_UNDO_FORBIDDEN (403). Closes #1672 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(import): return 404 when the bank-file undo target does not exist An unknown or out-of-company import id answered 400 BANK_FILE_UNDO_FAILED, hiding the not-found semantics the SIE import routes already expose ('Import not found', 404). Flag the case in undoBankFileImport (notFound) and map it to a new BANK_FILE_UNDO_NOT_FOUND structured error (404); status-refusals and RPC failures keep the 400 envelope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * feat(import): show bank file import history with undo on the import tab The undo shipped for issue #1672 was API-only: no surface listed a company's bank_file_imports, so neither users nor founders could reach DELETE /api/import/bank-file/[id]/undo, and the deletion report existed only in JSON. Mirror the SIE pattern (SIEImportHistory, #1574): - GET /api/import/bank-file: list the company's imports newest-first, same { data, count, limit, offset } shape as GET /api/import/sie. - BankFileImportHistory: fold-open 'Tidigare bankfilsimporter' row on the Importera tab with filename, date, format, imported count and status per import, plus an undo action on completed rows behind a DestructiveConfirmDialog. The undo stays owner/admin-only via the undo_bank_file_import RPC's actor gate, like the SIE one. - After undo the toast shows the full report: transactions removed, booked rows skipped, rows with match history skipped, so nothing disappears silently from the ledger's surroundings. - i18n strings in messages/sv.json and messages/en.json following the sie_history_* key style; list-route test mirroring the SIE list test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * chore(migrations): move undo_bank_file_import after main's 2026-08-19 migrations Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(import): validate bank-file list params, fail closed on undo lookup, log lost batch attribution Review findings on #1764 (CodeRabbit): - GET /api/import/bank-file rejects non-integer/negative/oversized limit and offset and unknown status with a mapped 400 (BANK_FILE_LIST_INVALID_QUERY), limit capped at 100; boundary and invalid-input tests added. - undoBankFileImport distinguishes PGRST116 (zero rows -> notFound/404) from other lookup failures, which now return an error instead of masquerading as a permanent 404. - The v1 import route no longer discards the bank_file_imports upsert error: kept non-fatal by design (an unattributed batch imports fine and never appears in undo history), but the failure is now logged loudly. - Route test beforeEach clears the event bus (repo convention). Signed-off-by: Emil <emilmattsson14@gmail.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
318 lines
9.9 KiB
TypeScript
318 lines
9.9 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { getPool } from './setup'
|
|
|
|
// Minimal fixture inserters for pg-real tests. All inserts go through the
|
|
// pool (superuser `postgres`), which bypasses RLS: that is intentional for
|
|
// seeding. RLS is exercised only where a test explicitly opens a user
|
|
// context via withUserContext().
|
|
|
|
export async function insertAuthUser(id: string = randomUUID()): Promise<string> {
|
|
// auth.users has many columns but most default. We only need `id` and a
|
|
// non-conflicting `email`. Everything else (role, aud, timestamps, etc.)
|
|
// has a default or is nullable in the supabase/postgres image.
|
|
await getPool().query(
|
|
`INSERT INTO auth.users (id, email, instance_id)
|
|
VALUES ($1, $2, '00000000-0000-0000-0000-000000000000'::uuid)`,
|
|
[id, `pg-real-${id}@test.invalid`],
|
|
)
|
|
return id
|
|
}
|
|
|
|
export async function insertCompany(params: {
|
|
createdBy: string
|
|
name?: string
|
|
entityType?: 'enskild_firma' | 'aktiebolag'
|
|
}): Promise<string> {
|
|
const id = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.companies (id, name, entity_type, created_by)
|
|
VALUES ($1, $2, $3, $4)`,
|
|
[id, params.name ?? 'Test AB', params.entityType ?? 'aktiebolag', params.createdBy],
|
|
)
|
|
return id
|
|
}
|
|
|
|
export async function insertCompanyMember(params: {
|
|
companyId: string
|
|
userId: string
|
|
role?: 'owner' | 'admin' | 'member' | 'viewer'
|
|
}): Promise<void> {
|
|
await getPool().query(
|
|
`INSERT INTO public.company_members (company_id, user_id, role)
|
|
VALUES ($1, $2, $3)`,
|
|
[params.companyId, params.userId, params.role ?? 'owner'],
|
|
)
|
|
}
|
|
|
|
export async function insertFiscalPeriod(params: {
|
|
userId: string
|
|
companyId: string
|
|
isClosed?: boolean
|
|
periodStart?: string
|
|
periodEnd?: string
|
|
name?: string
|
|
}): Promise<string> {
|
|
const id = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.fiscal_periods
|
|
(id, user_id, company_id, name, period_start, period_end, is_closed, closed_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
[
|
|
id,
|
|
params.userId,
|
|
params.companyId,
|
|
params.name ?? '2026',
|
|
params.periodStart ?? '2026-01-01',
|
|
params.periodEnd ?? '2026-12-31',
|
|
params.isClosed ?? false,
|
|
params.isClosed ? new Date() : null,
|
|
],
|
|
)
|
|
return id
|
|
}
|
|
|
|
// One-call helper: creates user + company + owner membership + open fiscal
|
|
// period. Returns the IDs tests need.
|
|
export async function seedCompany(overrides: { isClosed?: boolean } = {}): Promise<{
|
|
userId: string
|
|
companyId: string
|
|
fiscalPeriodId: string
|
|
}> {
|
|
const userId = await insertAuthUser()
|
|
const companyId = await insertCompany({ createdBy: userId })
|
|
await insertCompanyMember({ companyId, userId, role: 'owner' })
|
|
const fiscalPeriodId = await insertFiscalPeriod({
|
|
userId,
|
|
companyId,
|
|
isClosed: overrides.isClosed,
|
|
})
|
|
return { userId, companyId, fiscalPeriodId }
|
|
}
|
|
|
|
// Insert a cash account (cash_accounts row). ledger_account is unique per
|
|
// company; is_primary defaults false to avoid the one-primary partial index.
|
|
export async function insertCashAccount(params: {
|
|
companyId: string
|
|
ledgerAccount: string
|
|
currency?: string
|
|
iban?: string | null
|
|
externalUid?: string | null
|
|
isPrimary?: boolean
|
|
enabled?: boolean
|
|
source?: 'enable_banking' | 'manual' | 'sie_import'
|
|
bankConnectionId?: string | null
|
|
}): Promise<string> {
|
|
const id = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.cash_accounts
|
|
(id, company_id, ledger_account, currency, iban, external_uid,
|
|
is_primary, enabled, source, bank_connection_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
|
[
|
|
id,
|
|
params.companyId,
|
|
params.ledgerAccount,
|
|
params.currency ?? 'SEK',
|
|
params.iban ?? null,
|
|
params.externalUid ?? null,
|
|
params.isPrimary ?? false,
|
|
params.enabled ?? true,
|
|
params.source ?? 'manual',
|
|
params.bankConnectionId ?? null,
|
|
],
|
|
)
|
|
return id
|
|
}
|
|
|
|
// Insert a bank transaction row. cashAccountId/journalEntryId default null so
|
|
// tests can exercise the backfill and the NULL-fallback scoping.
|
|
export async function insertTransaction(params: {
|
|
companyId: string
|
|
userId: string
|
|
currency?: string
|
|
amount?: number
|
|
date?: string
|
|
description?: string
|
|
externalId?: string | null
|
|
journalEntryId?: string | null
|
|
cashAccountId?: string | null
|
|
isIgnored?: boolean
|
|
bankFileImportId?: string | null
|
|
}): Promise<string> {
|
|
const id = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.transactions
|
|
(id, company_id, user_id, currency, amount, date, description,
|
|
external_id, journal_entry_id, cash_account_id, is_ignored,
|
|
bank_file_import_id, category)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'uncategorized')`,
|
|
[
|
|
id,
|
|
params.companyId,
|
|
params.userId,
|
|
params.currency ?? 'SEK',
|
|
params.amount ?? -100,
|
|
params.date ?? '2026-06-01',
|
|
params.description ?? 'Test tx',
|
|
params.externalId ?? null,
|
|
params.journalEntryId ?? null,
|
|
params.cashAccountId ?? null,
|
|
params.isIgnored ?? false,
|
|
params.bankFileImportId ?? null,
|
|
],
|
|
)
|
|
return id
|
|
}
|
|
|
|
// Insert a draft journal entry and return its id. Uses a placeholder
|
|
// voucher_number=0 which commit_journal_entry() will overwrite on commit.
|
|
export async function insertDraftJournalEntry(params: {
|
|
userId: string
|
|
companyId: string
|
|
fiscalPeriodId: string
|
|
entryDate?: string
|
|
description?: string
|
|
voucherSeries?: string
|
|
status?: 'draft' | 'posted' | 'reversed' | 'cancelled'
|
|
voucherNumber?: number
|
|
sourceType?: string
|
|
sourceId?: string | null
|
|
createdAt?: string
|
|
// Inserting directly as 'posted' skips the set_committed_at() trigger (it
|
|
// fires on draft->posted UPDATE), so committed_at stays null unless set here.
|
|
committedAt?: string | null
|
|
}): Promise<string> {
|
|
if (params.status === 'posted') {
|
|
return insertPostedJournalEntry(params)
|
|
}
|
|
|
|
const id = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.journal_entries
|
|
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
|
entry_date, description, source_type, source_id, status, created_at, committed_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, COALESCE($12::timestamptz, now()), $13::timestamptz)`,
|
|
[
|
|
id,
|
|
params.userId,
|
|
params.companyId,
|
|
params.fiscalPeriodId,
|
|
params.voucherNumber ?? 0,
|
|
params.voucherSeries ?? 'A',
|
|
params.entryDate ?? '2026-06-01',
|
|
params.description ?? 'Test entry',
|
|
params.sourceType ?? 'manual',
|
|
params.sourceId ?? null,
|
|
params.status ?? 'draft',
|
|
params.createdAt ?? null,
|
|
params.committedAt ?? null,
|
|
],
|
|
)
|
|
return id
|
|
}
|
|
|
|
export interface PostedJournalEntryLine {
|
|
accountNumber: string
|
|
debitAmount: number
|
|
creditAmount: number
|
|
currency?: string
|
|
lineDescription?: string | null
|
|
sortOrder?: number
|
|
dimensions?: Record<string, string>
|
|
}
|
|
|
|
// Insert a posted entry and all of its lines in one transaction. This is the
|
|
// only valid shape for pg fixtures that intentionally exercise a direct posted
|
|
// INSERT: check_balance_on_posted_insert is deferred until the lines exist, but
|
|
// still executes before COMMIT.
|
|
export async function insertPostedJournalEntry(params: {
|
|
userId: string
|
|
companyId: string
|
|
fiscalPeriodId: string
|
|
entryDate?: string
|
|
description?: string
|
|
voucherSeries?: string
|
|
voucherNumber?: number
|
|
sourceType?: string
|
|
sourceId?: string | null
|
|
createdAt?: string
|
|
committedAt?: string | null
|
|
lines?: PostedJournalEntryLine[]
|
|
}): Promise<string> {
|
|
const id = randomUUID()
|
|
const lines = params.lines ?? [
|
|
{ accountNumber: '1930', debitAmount: 1000, creditAmount: 0 },
|
|
{ accountNumber: '3001', debitAmount: 0, creditAmount: 1000 },
|
|
]
|
|
const client = await getPool().connect()
|
|
|
|
try {
|
|
await client.query('BEGIN')
|
|
await client.query(
|
|
`INSERT INTO public.journal_entries
|
|
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
|
entry_date, description, source_type, source_id, status, created_at, committed_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'posted',
|
|
COALESCE($11::timestamptz, now()), $12::timestamptz)`,
|
|
[
|
|
id,
|
|
params.userId,
|
|
params.companyId,
|
|
params.fiscalPeriodId,
|
|
params.voucherNumber ?? 0,
|
|
params.voucherSeries ?? 'A',
|
|
params.entryDate ?? '2026-06-01',
|
|
params.description ?? 'Test entry',
|
|
params.sourceType ?? 'manual',
|
|
params.sourceId ?? null,
|
|
params.createdAt ?? null,
|
|
params.committedAt ?? null,
|
|
],
|
|
)
|
|
|
|
for (const [index, line] of lines.entries()) {
|
|
await client.query(
|
|
`INSERT INTO public.journal_entry_lines
|
|
(journal_entry_id, account_number, debit_amount, credit_amount,
|
|
currency, line_description, sort_order, dimensions)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
|
|
[
|
|
id,
|
|
line.accountNumber,
|
|
line.debitAmount,
|
|
line.creditAmount,
|
|
line.currency ?? 'SEK',
|
|
line.lineDescription ?? null,
|
|
line.sortOrder ?? index,
|
|
JSON.stringify(line.dimensions ?? {}),
|
|
],
|
|
)
|
|
}
|
|
|
|
await client.query('SET CONSTRAINTS check_balance_on_posted_insert IMMEDIATE')
|
|
await client.query('COMMIT')
|
|
return id
|
|
} catch (error) {
|
|
await client.query('ROLLBACK').catch(() => {})
|
|
throw error
|
|
} finally {
|
|
client.release()
|
|
}
|
|
}
|
|
|
|
// Insert a balanced pair of journal entry lines (1 debit row + 1 credit row
|
|
// at the given amount). Needed before commit_journal_entry() because the
|
|
// balance constraint trigger fires on draft→posted.
|
|
export async function insertBalancedLines(
|
|
journalEntryId: string,
|
|
amount: number = 1000,
|
|
): Promise<void> {
|
|
await getPool().query(
|
|
`INSERT INTO public.journal_entry_lines
|
|
(journal_entry_id, account_number, debit_amount, credit_amount)
|
|
VALUES ($1, '1930', $2, 0),
|
|
($1, '3001', 0, $2)`,
|
|
[journalEntryId, amount],
|
|
)
|
|
}
|