* fix(reconciliation): surface bank transactions via robust cash_account_id scoping The per-account reconciliation scoping silently returned zero transactions for companies whose rows were NULL or mis-assigned mid-backfill (e.g. Arcim: 138 transactions, 101 unbooked, yet Bankavstämning showed "0 kr" while the 1930 GL movement and a large difference still displayed). Two causes, both fixed: - scopeTransactionsToAccount used a fragile nested or(...,and(is.null,...)) PostgREST filter. Replace it with a flat, reliable `currency = X AND (cash_account_id = id OR cash_account_id IS NULL)` and share the one implementation with /api/transactions so the status card and the lists can never drift. - The original best-effort backfill only touched NULL rows and an earlier revision mis-assigned cash_account_id (the since-fixed min(uuid) bug), which migrations cannot self-correct. Add an idempotent repair migration that re-seeds the default 1930 account and re-derives cash_account_id (correcting non-NULL mis-assignments) for booked rows and single-account companies. Also localise manualLink's user-facing errors to Swedish. Adds unit coverage for the new filter shape and pg-real coverage for the repair (incl. the Arcim single-account reproduction). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(transactions): match a bank transaction to an existing verifikat Adds a "Matcha mot befintlig verifikation" action to the Transactions inbox so a bank line that is already booked elsewhere (a salary run, a Fortnox/manual voucher, an invoice paid from the invoice page) can be linked to that existing verifikat with no new bokföring — the capability previously lived only in Reports → Bankavstämning. - Extract the searchable MatchVerifikationPicker into a shared client component. - New MatchVoucherDialog: resolves the tx's cash account, fetches candidates ranked server-side by reconciliation confidence, links via /api/reconciliation/bank/link (so the link is undoable in Bankavstämning). - unmatched-entries route gains an optional transaction_id that ranks candidates (ranking stays server-side; the recon lib is not client-safe). - Inbox row's overflow (⋯) menu gains the new action. Also fixes the Bankavstämning view: editing the date no longer auto-reloads (applies on Filtrera / account change only) and Datum till defaults to today. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(transactions): actionable Swedish errors when a bank tx cannot be deleted The delete route returned a hardcoded English 409 that getErrorMessage mapped to the misleading generic "En konflikt uppstod. Ladda om sidan...". Return structured bilingual envelopes instead: - TRANSACTION_DELETE_BOOKED (409) for a booked/linked row — steers the user to unlink in Bankavstämning or storna the voucher. - TRANSACTION_DELETE_HAS_AUDIT_TRAIL (409) for the real, common case where an unbooked row carries payment_match_log rows: the cascade hits the audit-immutability trigger (P0001), previously surfaced as a bare 500. Steers the user to match-to-voucher or ignore instead. Updates the DELETE test suite and adds the P0001 case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(settings): surface DPA and privacy policy links The /dpa page (personuppgiftsbiträdesavtal, GDPR Art. 28) was complete but linked from nowhere. Add a "Sekretess och avtal" card on Inställningar → Konto linking to /privacy and /dpa, and a reciprocal link to the DPA from the privacy policy's sub-processor section. (The DPA already links back to /privacy.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(review): address PR #623 feedback - unmatched-entries: when transaction_id is supplied but resolves to no row in the caller's company, return an empty candidate list instead of silently falling back to the full unranked list (Compliance Swarm V8.2.1, high). - MatchVoucherDialog: preserve a manually-picked voucher when the candidate list reloads (e.g. "Visa alla datum") instead of discarding it (Greptile P2). - DELETE /api/transactions/[id]: return the 404 as the structured { error: { code, message, message_en } } envelope like the handler's other errors, for a uniform contract (Greptile P2). Test updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): allow editing notes on a committed journal entry Saving a note on a posted verifikation failed with "Committed entries are immutable": enforce_journal_entry_immutability() had no posted→posted path, so a notes-only UPDATE fell through to the final RAISE. `notes` is internal annotation metadata (not verifikation content under BFL 5 kap. / BFNAR 2013:2), so add a narrow carve-out that permits a notes-only change on a committed entry — verified with a whole-row to_jsonb() diff so any other field change still raises, and only when status is unchanged. Period-lock enforcement is unaffected. CREATE OR REPLACE in a new migration (same pattern as 20260428160000_fix_journal_entry_immutability_delete_bypass); the migration-017 protections are extended, never weakened. Covered by a pg-real test asserting a notes edit succeeds while amount/description/account edits still fail. (Already applied to production; committing the file + test for repo consistency.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sandbox): make pre-staged pending operations executor-complete The seeded pending_operations existed only as display previews — approving them failed because commit executors in lib/pending-operations/commit.ts validate required fields on "Godkänn". Seed a backing invoice_inbox_items row and fill the supplier-invoice and categorize params with every field the executors require (inbox_item_id, full items array; real uncategorized transaction_id + category), so the sandbox approval queue is actually approvable end to end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
208 lines
8.6 KiB
TypeScript
208 lines
8.6 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { requireCompanyId } from '@/lib/company/context'
|
|
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
import { validateBody } from '@/lib/api/validate'
|
|
import { UpdateTransactionTitleSchema } from '@/lib/api/schemas'
|
|
import { guardSandbox } from '@/lib/sandbox/guard'
|
|
import type { Transaction } from '@/types'
|
|
|
|
export async function DELETE(
|
|
_request: Request,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const supabase = await createClient()
|
|
const { id } = await params
|
|
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const writeCheck = await requireWritePermission(supabase, user.id)
|
|
if (!writeCheck.ok) return writeCheck.response
|
|
|
|
const companyId = await requireCompanyId(supabase, user.id)
|
|
|
|
// Fetch the transaction with ownership check
|
|
const { data: transaction, error: fetchError } = await supabase
|
|
.from('transactions')
|
|
.select('id, journal_entry_id')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (fetchError || !transaction) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: 'TRANSACTION_NOT_FOUND',
|
|
message: 'Transaktionen hittades inte.',
|
|
message_en: 'Transaction not found.',
|
|
},
|
|
},
|
|
{ status: 404 }
|
|
)
|
|
}
|
|
|
|
// Guard: only unbooked transactions can be deleted. A booked/matched row is
|
|
// räkenskapsinformation — the fix is to unlink (reconciliation) or storno, not
|
|
// delete. Return a structured bilingual envelope so the UI shows this clear,
|
|
// actionable message instead of the generic "Ladda om sidan" 409 fallback.
|
|
if (transaction.journal_entry_id) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: 'TRANSACTION_DELETE_BOOKED',
|
|
message:
|
|
'Transaktionen är redan bokförd eller kopplad till en verifikation och kan inte raderas. Koppla bort den under Rapporter → Bankavstämning om kopplingen är fel, eller storna verifikationen.',
|
|
message_en:
|
|
'The transaction is already booked or linked to a journal entry and cannot be deleted. Unlink it under Reports → Bank reconciliation if the link is wrong, or reverse (storno) the voucher.',
|
|
},
|
|
},
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
|
|
const { error: deleteError } = await supabase
|
|
.from('transactions')
|
|
.delete()
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
|
|
if (deleteError) {
|
|
// An unbooked row can still carry payment_match_log rows (written at ingest
|
|
// for every auto-suggested match). Their FK cascades on delete, but the
|
|
// audit-immutability trigger raises P0001 — surface that as an actionable
|
|
// message (match or ignore instead) rather than a bare 500.
|
|
const code = (deleteError as { code?: string }).code
|
|
const message = (deleteError as { message?: string }).message ?? ''
|
|
if (code === 'P0001' || /Audit log entries cannot be modified or deleted/i.test(message)) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: 'TRANSACTION_DELETE_HAS_AUDIT_TRAIL',
|
|
message:
|
|
'Transaktionen kan inte raderas eftersom den har en kopplad matchningshistorik (räkenskapsinformation, BFL 7 kap.). Matcha den mot en befintlig verifikation, eller ignorera den under Rapporter → Bankavstämning om du inte vill bokföra den.',
|
|
message_en:
|
|
'The transaction cannot be deleted because it has linked match-history records (accounting information, BFL ch. 7). Match it to an existing voucher, or ignore it under Reports → Bank reconciliation if you do not want to book it.',
|
|
},
|
|
},
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: 'TRANSACTION_DELETE_FAILED',
|
|
message: 'Kunde inte ta bort transaktionen. Försök igen.',
|
|
message_en: 'Could not delete the transaction. Please try again.',
|
|
},
|
|
},
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({ success: true })
|
|
}
|
|
|
|
/**
|
|
* Edit a bank transaction's title (description).
|
|
*
|
|
* Legal under BFL only while the row is a mutable staging label — i.e. NOT yet
|
|
* booked into a verifikat and NOT confirmed-matched to an invoice. Once booked
|
|
* the description is räkenskapsinformation and corrections go through storno
|
|
* (reverseEntry/correctEntry), so this route hard-blocks those rows. The bank's
|
|
* original title is preserved immutably in original_description (set at ingest)
|
|
* and is never written here; passing it back restores the "not edited" tag.
|
|
*/
|
|
export const PATCH = withRouteContext(
|
|
'transaction.updateTitle',
|
|
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
|
const { id } = await params
|
|
const { supabase, companyId, log, requestId, user } = ctx
|
|
|
|
const blocked = await guardSandbox(supabase, companyId)
|
|
if (blocked) return blocked
|
|
|
|
const validation = await validateBody(request, UpdateTransactionTitleSchema, {
|
|
log,
|
|
operation: 'transaction.updateTitle',
|
|
})
|
|
if (!validation.success) return validation.response
|
|
const { description } = validation.data
|
|
|
|
const { data: transaction, error: fetchError } = await supabase
|
|
.from('transactions')
|
|
.select('id, description, original_description, journal_entry_id, invoice_id, supplier_invoice_id')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (fetchError || !transaction) {
|
|
return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId })
|
|
}
|
|
|
|
// Gate: editable only when neither booked nor confirmed-matched. (A
|
|
// confirmed invoice/supplier-invoice match also sets journal_entry_id, but
|
|
// we check all three for defense-in-depth.) An unbooked row has no fiscal
|
|
// period, so the period-lock requirement is satisfied implicitly.
|
|
if (transaction.journal_entry_id || transaction.invoice_id || transaction.supplier_invoice_id) {
|
|
return errorResponseFromCode('TRANSACTION_TITLE_LOCKED', log, { requestId })
|
|
}
|
|
|
|
// Restoring to the bank original clears the "edited" tag; any other value
|
|
// marks the title as user-edited. Compare against the TRIMMED original (the
|
|
// incoming description is already trimmed by the schema) so a legacy
|
|
// original carrying surrounding whitespace still restores cleanly.
|
|
const isRestore =
|
|
transaction.original_description != null &&
|
|
description === transaction.original_description.trim()
|
|
const titleEditedAt = isRestore ? null : new Date().toISOString()
|
|
|
|
const { data: updated, error: updateError } = await supabase
|
|
.from('transactions')
|
|
.update({ description, title_edited_at: titleEditedAt })
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
// Re-assert the FULL editable gate atomically against a concurrent book
|
|
// or auto-match. Ingest's supplier auto-match can set supplier_invoice_id
|
|
// WITHOUT journal_entry_id, so guarding journal_entry_id alone leaves a
|
|
// narrow TOCTOU window — mirror the read-time gate here.
|
|
.is('journal_entry_id', null)
|
|
.is('invoice_id', null)
|
|
.is('supplier_invoice_id', null)
|
|
// Return only what the client renders (data minimisation — the row also
|
|
// carries company_id and other internal fields the caller doesn't need).
|
|
.select('id, description, title_edited_at')
|
|
.maybeSingle<Pick<Transaction, 'id' | 'description' | 'title_edited_at'>>()
|
|
|
|
if (updateError) {
|
|
return errorResponse(updateError, log, { requestId })
|
|
}
|
|
if (!updated) {
|
|
// 0 rows updated → the row was booked/matched between read and write.
|
|
return errorResponseFromCode('TRANSACTION_TITLE_LOCKED', log, { requestId })
|
|
}
|
|
|
|
// Behandlingshistorik (BFNAR 2013:2 kap 8) — light-touch for a pre-verifikat
|
|
// working label; updated_at (trigger) captures "when". We deliberately do
|
|
// NOT log the description text: a bank label can carry PII (payee names,
|
|
// reference numbers). The before-value stays recoverable in
|
|
// original_description and the after-value is the row's current
|
|
// description, so the log only needs to record that/which way it changed.
|
|
log.info('transaction title edited', {
|
|
transactionId: id,
|
|
actor: user.id,
|
|
restored: isRestore,
|
|
previousLength: transaction.description?.length ?? 0,
|
|
newLength: description.length,
|
|
})
|
|
|
|
return NextResponse.json({ data: updated })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|