Mcp/template data feedback (#617)

* fix(booking-templates): scope template list to the active company

GET /api/settings/booking-templates relied solely on the btl_select RLS
policy, which is membership-wide (user_company_ids) and returns templates
from every company the user belongs to. A user who owns multiple companies
saw all their templates merged regardless of which company was active.

Narrow the list in the API layer (mirroring counterparty-templates) to
system + the active company + the active company's team. RLS stays the
security backstop; this fixes the cross-company merge within a single
user's own view (it was never a cross-tenant data leak).

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

* fix(import): show proper message for duplicate bank file upload

The bank file import page mis-parsed the structured error envelope
({ error: { code, message, details } }), so a BANK_FILE_DUPLICATE
(409) fell through to the generic "Kunde inte läsa filen" fallback.
The upload step also hardcoded that same string as the error heading,
so duplicates were doubly misreported as parse failures.

- Parse the structured envelope by error.code; surface error.message
  for all codes instead of rendering the error object.
- Add a dedicated BANK_FILE_DUPLICATE message using the importedAt /
  importedCount details the route already returns.
- Add an optional errorTitle prop to BankFileUploadStep (defaults to
  the previous text) and pass "Filen är redan importerad" for dupes.

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

* feat(tests): add comprehensive tests for recordateEntry, inbox-linking, and external-id handling

- Implemented unit tests for recordateEntry in the bookkeeping module to validate various scenarios including date changes, non-posted entries, and fiscal period restrictions.
- Created tests for inbox-linking status in pending operations to ensure correct handling of invoice inbox items and supplier invoices, addressing historical bugs related to status updates.
- Added tests for external-id utilities to ensure consistent handling of monetary amounts and deduplication keys across different transaction sources.
- Introduced new functions in external-id.ts for stable external ID generation and normalization of imported descriptions, enhancing transaction deduplication reliability.

feat(migrations): add new database migrations for transaction handling

- Created migration to exclude storno and correction vouchers from unmatched GL lines, ensuring accurate reconciliation.
- Added a migration to preserve original bank transaction descriptions in a new immutable column, allowing for user edits while maintaining audit trails and deduplication integrity.

* feat(migrations): add function to exclude storno/correction vouchers from unmatched GL lines

* feat(transactions): enhance transaction handling with improved description normalization and preloaded original entries

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-01 14:45:49 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2c59c3633f
commit c6c86cded4
54 changed files with 3673 additions and 158 deletions
@@ -0,0 +1,61 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/bookkeeping/fiscal-periods/period-status?date=YYYY-MM-DD
*
* Read-only preview of whether a verifikation with the given entry_date could
* be posted right now (company lock date + period is_closed/locked_at), plus
* the covering period's label so the UI can show "flyttas till <år>" before a
* write is attempted. Mirrors resolvePeriodStatusForDate / the DB triggers.
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const date = new URL(request.url).searchParams.get('date')
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return NextResponse.json({ error: 'Ogiltigt datum (förväntat ÅÅÅÅ-MM-DD)' }, { status: 400 })
}
const companyId = await requireCompanyId(supabase, user.id)
try {
const status = await resolvePeriodStatusForDate(supabase, companyId, date)
let period_name: string | null = null
if (status.period_id) {
const { data: period } = await supabase
.from('fiscal_periods')
.select('name')
.eq('id', status.period_id)
.eq('company_id', companyId)
.maybeSingle()
period_name = period?.name ?? null
}
return NextResponse.json({
data: {
status: status.status,
period_id: status.period_id,
lock_date: status.lock_date,
period_name,
},
})
} catch (err) {
return NextResponse.json(
{
error: {
code: 'PERIOD_STATUS_ERROR',
message: err instanceof Error ? err.message : 'Kunde inte hämta periodstatus',
},
},
{ status: 500 }
)
}
}
@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
makeJournalEntry,
} from '@/tests/helpers'
import { TargetPeriodLockedError, MeaninglessCorrectionError } from '@/lib/bookkeeping/errors'
const mockCreateClient = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => mockCreateClient(),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockRecordateEntry = vi.fn()
vi.mock('@/lib/core/bookkeeping/storno-service', () => ({
recordateEntry: (...args: unknown[]) => mockRecordateEntry(...args),
}))
import { POST } from '../route'
describe('POST /api/bookkeeping/journal-entries/[id]/recordate', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
mockCreateClient.mockResolvedValue({
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) },
})
})
it('returns 401 when not authenticated', async () => {
mockCreateClient.mockResolvedValue({
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) },
})
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', {
method: 'POST',
body: { new_entry_date: '2025-07-03' },
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 400 when new_entry_date is missing', async () => {
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', {
method: 'POST',
body: {},
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Validation failed')
})
it('returns 400 when new_entry_date is not an ISO date', async () => {
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', {
method: 'POST',
body: { new_entry_date: '03/07/2025' },
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Validation failed')
})
it('returns reversal and corrected entries on success', async () => {
const reversal = makeJournalEntry({ id: 'reversal-1', reverses_id: 'entry-1', source_type: 'storno' })
const corrected = makeJournalEntry({
id: 'corrected-1',
correction_of_id: 'entry-1',
source_type: 'correction',
entry_date: '2025-07-03',
})
mockRecordateEntry.mockResolvedValue({ reversal, corrected })
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', {
method: 'POST',
body: { new_entry_date: '2025-07-03' },
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse<{ data: { reversal: unknown; corrected: unknown } }>(response)
expect(status).toBe(200)
expect(body.data.corrected).toEqual(corrected)
expect(mockRecordateEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'entry-1',
'2025-07-03'
)
})
it('maps a no-op move (same date) to a 400 with the typed reason', async () => {
mockRecordateEntry.mockRejectedValue(new MeaninglessCorrectionError('no_date_change'))
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', {
method: 'POST',
body: { new_entry_date: '2026-07-03' },
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string; details: { reason: string } } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('MEANINGLESS_CORRECTION')
expect(body.error.details.reason).toBe('no_date_change')
})
it('maps a locked target period to a 409 with the typed code', async () => {
mockRecordateEntry.mockRejectedValue(new TargetPeriodLockedError('2025-07-03', '2025-12-31'))
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/recordate', {
method: 'POST',
body: { new_entry_date: '2025-07-03' },
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string; details: { lockDate: string } } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('TARGET_PERIOD_LOCKED')
expect(body.error.details.lockDate).toBe('2025-12-31')
})
})
@@ -0,0 +1,47 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { recordateEntry } from '@/lib/core/bookkeeping/storno-service'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { RecordateJournalEntrySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
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)
const validation = await validateBody(request, RecordateJournalEntrySchema)
if (!validation.success) return validation.response
const body = validation.data
try {
const result = await recordateEntry(supabase, companyId, user.id, id, body.new_entry_date)
return NextResponse.json({ data: result })
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
// Not a recognized domain error — an unexpected server fault, not a client
// error, so surface it as 500.
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to move entry' },
{ status: 500 }
)
}
}
+34 -1
View File
@@ -5,6 +5,12 @@ import { requireWritePermission } from '@/lib/auth/require-write'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
// The GET scope below builds a PostgREST .or() filter by string interpolation.
// Guard every interpolated id against a strict UUID shape so a tainted value
// can never inject filter syntax. Both ids are server-derived (companyId from
// membership, teamId from a DB column), so this is defense-in-depth.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const BookingTemplateLineSchema = z.object({
account: z.string().regex(/^\d{4}$/),
label: z.string().min(1),
@@ -43,12 +49,39 @@ export async function GET() {
const companyId = await requireCompanyId(supabase, user.id)
// RLS handles scoping (system OR company OR team)
// Resolve the team this company belongs to (if any) so team-shared
// templates stay visible while this company is selected.
const { data: company } = await supabase
.from('companies')
.select('team_id')
.eq('id', companyId)
.maybeSingle()
const teamId = company?.team_id ?? null
// requireCompanyId only ever returns a real membership UUID, but assert the
// shape before interpolating it into the .or() filter.
if (!UUID_RE.test(companyId)) {
return NextResponse.json({ error: 'Invalid company context' }, { status: 400 })
}
// Scope to the SELECTED company: system + this company + this company's team.
// RLS (btl_select) is membership-wide — it returns templates from *every*
// company the user belongs to — so the active-company narrowing must happen
// here in the API layer (mirrors counterparty-templates). Without this, a
// user who owns several companies sees all of their templates merged.
// Only interpolate a team id that passes the strict UUID guard.
const scope = [
'is_system.eq.true',
`company_id.eq.${companyId}`,
...(teamId && UUID_RE.test(teamId) ? [`team_id.eq.${teamId}`] : []),
].join(',')
const [templatesRes, usageRes] = await Promise.all([
supabase
.from('booking_template_library')
.select('*')
.eq('is_active', true)
.or(scope)
.order('category')
.order('name'),
supabase
@@ -305,6 +305,45 @@ describe('POST /api/supplier-invoices', () => {
expect((body.error as unknown as { code: string }).code).toBe('SI_CREATE_FAILED')
})
it('rolls back and returns SI_CREATE_NO_FISCAL_PERIOD when invoice_date is outside every fiscal period', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-1', invoice_date: '2099-06-01' })
// Fetch supplier
enqueue({ data: supplier, error: null })
// RPC get_next_arrival_number
enqueue({ data: 9 })
// Insert invoice
enqueue({ data: createdInvoice, error: null })
// Insert items
enqueue({ data: null, error: null })
// Fetch company settings → accrual, so a registration JE is attempted
enqueue({ data: { accounting_method: 'accrual' }, error: null })
// Engine returns null because no fiscal period covers 2099-06-01
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue(null)
// Rollback: delete the orphan invoice (items cascade)
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-NOFY',
invoice_date: '2099-06-01',
due_date: '2099-07-01',
items: [{ description: 'Material', quantity: 1, unit_price: 8000, account_number: '4010' }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_CREATE_NO_FISCAL_PERIOD')
expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
// The orphan must be rolled back — the delete is the 6th queued call.
expect(mockSupabase.from).toHaveBeenCalledWith('supplier_invoices')
})
it('returns 409 with credit chain on duplicate supplier_invoice_number for credited original', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
+24
View File
@@ -329,6 +329,17 @@ export const POST = withRouteContext(
journal_entry_id: journalEntry.id,
notes: 'Eget utlägg — betalat privat',
})
} else {
// createSupplierInvoicePrivatelyPaidEntry returns null ONLY when no
// fiscal period covers invoice_date (every other failure throws and
// lands in the catch below). Without this branch the invoice would be
// saved as status='paid' with no verifikat — a silent orphan. Roll
// back and surface an actionable error, per the fatal-orphan note above.
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
return errorResponseFromCode('SI_CREATE_NO_FISCAL_PERIOD', log, {
requestId,
details: { invoiceDate: invoice.invoice_date },
})
}
} catch (err) {
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
@@ -363,6 +374,19 @@ export const POST = withRouteContext(
.from('supplier_invoices')
.update({ registration_journal_entry_id: journalEntry.id })
.eq('id', invoice.id)
} else {
// createSupplierInvoiceRegistrationEntry returns null ONLY when no
// fiscal period covers invoice_date (every other failure throws and
// lands in the catch below). An orphan supplier_invoices row without a
// registration JE silently understates leverantörsskuld (2440) and
// ingående moms (2641) for the momsdeklaration — exactly the fatal
// case the note above warns about. Roll back and surface an
// actionable error instead of returning 200.
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
return errorResponseFromCode('SI_CREATE_NO_FISCAL_PERIOD', log, {
requestId,
details: { invoiceDate: invoice.invoice_date },
})
}
} catch (err) {
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
@@ -20,7 +20,19 @@ vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { DELETE } from '../route'
// PATCH (edit title) goes through withRouteContext → requireAuth.
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
vi.mock('@/lib/sandbox/guard', () => ({
guardSandbox: vi.fn(),
}))
import { DELETE, PATCH } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { guardSandbox } from '@/lib/sandbox/guard'
import { NextResponse } from 'next/server'
describe('DELETE /api/transactions/[id]', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
@@ -117,3 +129,158 @@ describe('DELETE /api/transactions/[id]', () => {
expect(body).toEqual({ error: 'Failed to delete transaction' })
})
})
describe('PATCH /api/transactions/[id] (edit title)', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
function patchReq(body: unknown) {
return new Request('http://localhost/api/transactions/tx-1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
beforeEach(() => {
vi.clearAllMocks()
reset()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: mockSupabase as never,
error: null,
})
vi.mocked(guardSandbox).mockResolvedValue(null)
})
it('returns 401 when not authenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null as never,
supabase: mockSupabase as never,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await PATCH(patchReq({ description: 'Ny titel' }), createMockRouteParams({ id: 'tx-1' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(401)
})
it('returns 400 when the title is empty / whitespace-only', async () => {
const res = await PATCH(patchReq({ description: ' ' }), createMockRouteParams({ id: 'tx-1' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns 404 when the transaction is not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
const res = await PATCH(patchReq({ description: 'Ny titel' }), createMockRouteParams({ id: 'tx-1' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(404)
})
it('returns 409 when the transaction is booked', async () => {
enqueue({
data: {
id: 'tx-1',
description: 'X',
original_description: 'X',
journal_entry_id: 'je-1',
invoice_id: null,
supplier_invoice_id: null,
},
error: null,
})
const res = await PATCH(patchReq({ description: 'Ny titel' }), createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(status).toBe(409)
expect(body.error.code).toBe('TRANSACTION_TITLE_LOCKED')
})
it('returns 409 when matched to an invoice even if journal_entry_id is null', async () => {
enqueue({
data: {
id: 'tx-1',
description: 'X',
original_description: 'X',
journal_entry_id: null,
invoice_id: 'inv-1',
supplier_invoice_id: null,
},
error: null,
})
const res = await PATCH(patchReq({ description: 'Ny titel' }), createMockRouteParams({ id: 'tx-1' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(409)
})
it('updates the title for an editable (unbooked, unmatched) transaction', async () => {
enqueue({
data: {
id: 'tx-1',
description: 'ICA',
original_description: 'ICA',
journal_entry_id: null,
invoice_id: null,
supplier_invoice_id: null,
},
error: null,
}) // fetch
enqueue({
data: { id: 'tx-1', description: 'Lunch med kund', title_edited_at: '2026-06-01T10:00:00Z' },
error: null,
}) // update
const res = await PATCH(
patchReq({ description: 'Lunch med kund' }),
createMockRouteParams({ id: 'tx-1' }),
)
const { status, body } = await parseJsonResponse<{ data: { description: string } }>(res)
expect(status).toBe(200)
expect(body.data.description).toBe('Lunch med kund')
})
it('restores the original title (200) when the new title equals original_description', async () => {
enqueue({
data: {
id: 'tx-1',
description: 'Lunch med kund',
original_description: 'ICA MAXI',
journal_entry_id: null,
invoice_id: null,
supplier_invoice_id: null,
},
error: null,
}) // fetch
enqueue({
data: { id: 'tx-1', description: 'ICA MAXI', title_edited_at: null },
error: null,
}) // update
const res = await PATCH(patchReq({ description: 'ICA MAXI' }), createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ data: { title_edited_at: string | null } }>(res)
expect(status).toBe(200)
expect(body.data.title_edited_at).toBeNull()
})
it('returns 409 when the row is matched/booked between read and write (optimistic-lock miss)', async () => {
enqueue({
data: {
id: 'tx-1',
description: 'ICA',
original_description: 'ICA',
journal_entry_id: null,
invoice_id: null,
supplier_invoice_id: null,
},
error: null,
}) // fetch passes the read gate
enqueue({ data: null, error: null }) // UPDATE affects 0 rows (gate re-assert failed)
const res = await PATCH(patchReq({ description: 'Ny titel' }), createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(status).toBe(409)
expect(body.error.code).toBe('TRANSACTION_TITLE_LOCKED')
})
})
+104
View File
@@ -2,6 +2,12 @@ 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,
@@ -52,3 +58,101 @@ export async function DELETE(
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 },
)
@@ -365,6 +365,32 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices', () => {
expect(body.error.details.step).toBe('registration_journal_entry')
})
it('rolls back SI row and returns SI_CREATE_NO_FISCAL_PERIOD when no period covers invoice_date', async () => {
// Engine returns null (not a throw) when no fiscal period covers the date.
mockedReg.mockResolvedValueOnce(null)
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
suppliers: { data: SAMPLE_SUPPLIER, error: null },
company_settings: { data: { accounting_method: 'accrual' }, error: null },
fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null },
supplier_invoices: { data: SAMPLE_SI, error: null },
supplier_invoice_items: { data: null, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await createSI(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices`, {
method: 'POST',
body: JSON.stringify(validBody),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('SI_CREATE_NO_FISCAL_PERIOD')
})
it('returns a dry-run preview when ?dry_run=true', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
@@ -701,9 +701,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
// Engine returned null (no open fiscal period). Strict-mode: roll back.
// Engine returned null before posting — no JE exists.
await rollbackSupplierInvoice(ctx.supabase, invoiceId, ctx.companyId!, ctx.log, 'no_fiscal_period', false)
return v1ErrorResponseFromCode('SI_CREATE_FAILED', ctx.log, {
return v1ErrorResponseFromCode('SI_CREATE_NO_FISCAL_PERIOD', ctx.log, {
requestId: ctx.requestId,
details: { step: 'registration_journal_entry', reason: 'no_fiscal_period' },
details: { step: 'registration_journal_entry', invoice_date: body.invoice_date },
})
}
} catch (err) {
@@ -22,6 +22,7 @@ import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { ingestTransactions } from '@/lib/transactions/ingest'
import { contentDedupKey } from '@/lib/transactions/external-id'
import type { RawTransaction } from '@/types'
const RawTx = z.object({
@@ -152,27 +153,25 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
const { data: bookedInRange } = await ctx.supabase
.from('transactions')
.select('date, amount')
.select('date, amount, description')
.eq('company_id', ctx.companyId!)
.not('journal_entry_id', 'is', null)
.gte('date', dateFrom)
.lte('date', dateTo)
// Normalize the amount to a fixed-precision string before keying.
// Both JS number-to-string ("-349.5") and Postgres numeric round-trip
// ("-349.50") collapse to the same "-349.50" representation here, so
// a SIE amount with trailing-zero precision lines up with an already-
// booked row whose amount JSON-encodes without it.
const amountKey = (n: number): string => n.toFixed(2)
// Build the content-dedup key with the SAME helper the live pipeline uses
// (lib/transactions/ingest.ts), so the preview's content-match decision
// matches the eventual ingest exactly: öre-normalized amount (handles a
// PostgREST numeric returned as a string) plus the description prefix.
const bookedKeys = new Set(
(bookedInRange ?? []).map((r) => {
const row = r as { date: string; amount: number }
return `${row.date}|${amountKey(row.amount)}`
const row = r as { date: string; amount: number | string; description: string | null }
return contentDedupKey(row.date, row.amount, row.description)
}),
)
const previewRows = body.transactions.map((tx) => {
const extIdHit = knownExtIds.has(tx.external_id)
const contentHit = bookedKeys.has(`${tx.date}|${amountKey(tx.amount)}`)
const contentHit = bookedKeys.has(contentDedupKey(tx.date, tx.amount, tx.description))
const wouldSkip = extIdHit || contentHit
const reason = extIdHit
? 'external_id_match'