feat: add user locale preference to user_preferences table (#555)
* feat: add user locale preference to user_preferences table
- Introduced a new column 'locale' in the user_preferences table to store per-user UI language preferences.
- Added a CHECK constraint to ensure only supported locales ('sv', 'en') are allowed.
- Triggered a schema reload notification for the changes.
chore: declare CSS module support in TypeScript
- Added a declaration for CSS modules in globals.d.ts to enable TypeScript support for importing CSS files.
* feat: add Swish as an invoice payment method in company settings
This commit is contained in:
@@ -301,7 +301,7 @@ describe('gnubok_reverse_journal_entry — staging gates', () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'je-1',
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
status: 'draft',
|
||||
entry_date: '2026-05-12',
|
||||
description: 'Test',
|
||||
@@ -314,7 +314,12 @@ describe('gnubok_reverse_journal_entry — staging gates', () => {
|
||||
error: null,
|
||||
})
|
||||
await expect(
|
||||
reverseEntry.execute({ entry_id: 'je-1' }, 'company-1', 'user-1', supabase as never),
|
||||
reverseEntry.execute(
|
||||
{ entry_id: '11111111-1111-1111-1111-111111111111' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/posted entries can be reversed/i)
|
||||
})
|
||||
|
||||
@@ -322,7 +327,7 @@ describe('gnubok_reverse_journal_entry — staging gates', () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'je-1',
|
||||
id: '22222222-2222-2222-2222-222222222222',
|
||||
status: 'posted',
|
||||
entry_date: '2025-12-31',
|
||||
description: 'Test',
|
||||
@@ -335,7 +340,120 @@ describe('gnubok_reverse_journal_entry — staging gates', () => {
|
||||
error: null,
|
||||
})
|
||||
await expect(
|
||||
reverseEntry.execute({ entry_id: 'je-1' }, 'company-1', 'user-1', supabase as never),
|
||||
reverseEntry.execute(
|
||||
{ entry_id: '22222222-2222-2222-2222-222222222222' },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/closed/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('entry_id resolution — voucher refs and hallucinated UUIDs', () => {
|
||||
// These tests cover the resolveJournalEntryRef helper as exercised through
|
||||
// gnubok_correct_entry. The same resolution path is wired into
|
||||
// gnubok_reverse_journal_entry, so one tool is enough to lock the behaviour.
|
||||
|
||||
const balancedCorrection = [
|
||||
{ account_number: '2645', debit_amount: 250, credit_amount: 0 },
|
||||
{ account_number: '2614', debit_amount: 0, credit_amount: 250 },
|
||||
]
|
||||
|
||||
it('resolves a voucher ref like "A-113" to its UUID before the lookup', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const resolvedId = '33333333-3333-3333-3333-333333333333'
|
||||
|
||||
// 1) resolveJournalEntryRef -> single match by (series, number)
|
||||
enqueue({
|
||||
data: [{ id: resolvedId, entry_date: '2026-03-06', description: 'Cursor 2' }],
|
||||
error: null,
|
||||
})
|
||||
// 2) original journal_entries lookup, but the period is closed so we
|
||||
// short-circuit before staging. That's enough to confirm the helper
|
||||
// resolved the ref and passed the UUID through to the next query.
|
||||
enqueue({
|
||||
data: {
|
||||
id: resolvedId,
|
||||
status: 'posted',
|
||||
entry_date: '2026-03-06',
|
||||
description: 'Cursor 2',
|
||||
voucher_number: 113,
|
||||
voucher_series: 'A',
|
||||
fiscal_period_id: 'fp-closed',
|
||||
fiscal_periods: { name: '2026', is_closed: true, locked_at: null },
|
||||
lines: [],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
await expect(
|
||||
correctEntry.execute(
|
||||
{ entry_id: 'A-113', lines: balancedCorrection },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/locked or closed/i)
|
||||
})
|
||||
|
||||
it('errors when a voucher ref matches multiple entries across fiscal periods', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', entry_date: '2026-03-06', description: 'Cursor 2' },
|
||||
{ id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', entry_date: '2025-03-06', description: 'Cursor 1' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
await expect(
|
||||
correctEntry.execute(
|
||||
{ entry_id: 'A-113', lines: balancedCorrection },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/matches multiple entries/i)
|
||||
})
|
||||
|
||||
it('errors when a voucher ref matches nothing', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [], error: null })
|
||||
await expect(
|
||||
correctEntry.execute(
|
||||
{ entry_id: 'Z-999', lines: balancedCorrection },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/no journal entry found for voucher "z-999"/i)
|
||||
})
|
||||
|
||||
it('errors with a parse hint when the ref is neither a UUID nor a voucher format', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
correctEntry.execute(
|
||||
{ entry_id: 'not-an-id-at-all', lines: balancedCorrection },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/could not parse entry reference/i)
|
||||
})
|
||||
|
||||
it('surfaces the supplied UUID in not-found errors so hallucinated IDs are debuggable', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const hallucinated = 'a71a11ae-b8e2-450f-aaa6-a227d03b0c94'
|
||||
// UUID passes through resolution unchanged → straight to the original
|
||||
// lookup, which returns no row.
|
||||
enqueue({ data: null, error: null })
|
||||
await expect(
|
||||
correctEntry.execute(
|
||||
{ entry_id: hallucinated, lines: balancedCorrection },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(new RegExp(`id=${hallucinated}`))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -289,6 +289,80 @@ async function stagePendingOperation(
|
||||
return response
|
||||
}
|
||||
|
||||
// ── Journal entry reference resolution ────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a journal entry reference to a journal_entries.id UUID.
|
||||
*
|
||||
* Accepts either a raw UUID (returned as-is) or a voucher reference like
|
||||
* "A-113" / "A113" / "A/113" (resolved by voucher_series + voucher_number
|
||||
* scoped to the company).
|
||||
*
|
||||
* Voucher refs are the preferred input shape for LLM-driven callers: short,
|
||||
* semantically meaningful, and resistant to UUID hallucination — a failure
|
||||
* mode where the agent reproduces the first 8 hex chars correctly but
|
||||
* fabricates the remaining 24, so a downstream lookup rejects the ID even
|
||||
* though the entry exists.
|
||||
*/
|
||||
async function resolveJournalEntryRef(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
ref: string
|
||||
): Promise<string> {
|
||||
const trimmed = ref.trim()
|
||||
|
||||
// UUIDs pass through. If the UUID was hallucinated, the caller's own
|
||||
// lookup surfaces the "not found" diagnostic with the supplied value.
|
||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// Voucher ref: letters (series) + optional separator + digits (number).
|
||||
const match = trimmed.match(/^([A-Za-z]+)\s*[-:/ ]?\s*(\d+)$/)
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Could not parse entry reference "${ref}". Expected a UUID or a voucher ref like "A-113".`
|
||||
)
|
||||
}
|
||||
const series = match[1].toUpperCase()
|
||||
const number = parseInt(match[2], 10)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, entry_date, description')
|
||||
.eq('company_id', companyId)
|
||||
.eq('voucher_series', series)
|
||||
.eq('voucher_number', number)
|
||||
.order('entry_date', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Database error resolving voucher "${series}-${number}": ${error.message}`)
|
||||
}
|
||||
|
||||
const matches = (data ?? []) as Array<{ id: string; entry_date: string; description: string }>
|
||||
|
||||
if (matches.length === 0) {
|
||||
throw new Error(
|
||||
`No journal entry found for voucher "${series}-${number}" in this company. ` +
|
||||
`Verify the series and number, or supply the full UUID.`
|
||||
)
|
||||
}
|
||||
|
||||
// Voucher numbers reset per fiscal period. The same (series, number) pair
|
||||
// can therefore appear in multiple years — refuse to guess.
|
||||
if (matches.length > 1) {
|
||||
const summary = matches
|
||||
.map((m) => `${m.entry_date} "${m.description}" (id=${m.id})`)
|
||||
.join('; ')
|
||||
throw new Error(
|
||||
`Voucher "${series}-${number}" matches multiple entries across fiscal periods: ${summary}. ` +
|
||||
`Supply the specific UUID instead.`
|
||||
)
|
||||
}
|
||||
|
||||
return matches[0].id
|
||||
}
|
||||
|
||||
// ── Shared categorization logic ──────────────────────────────
|
||||
|
||||
async function categorizeTransactionCore(
|
||||
@@ -5850,7 +5924,7 @@ export const tools: McpTool[] = [
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
entry_id: { type: 'string', description: 'UUID of the posted journal entry to correct' },
|
||||
entry_id: { type: 'string', description: 'Journal entry UUID OR voucher ref like "A-113". Prefer voucher refs: UUIDs reused from earlier tool output are frequently hallucinated by LLM callers.' },
|
||||
lines: {
|
||||
type: 'array',
|
||||
description: 'Replacement lines (≥ 2, balanced). Use the same accounts as the original where unchanged.',
|
||||
@@ -5877,10 +5951,10 @@ export const tools: McpTool[] = [
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const entryId = args.entry_id as string
|
||||
const entryRef = args.entry_id as string
|
||||
const rawLines = args.lines as Array<Record<string, unknown>> | undefined
|
||||
|
||||
if (!entryId || !Array.isArray(rawLines) || rawLines.length < 2) {
|
||||
if (!entryRef || !Array.isArray(rawLines) || rawLines.length < 2) {
|
||||
throw new Error('entry_id and at least two lines are required')
|
||||
}
|
||||
|
||||
@@ -5905,6 +5979,8 @@ export const tools: McpTool[] = [
|
||||
)
|
||||
}
|
||||
|
||||
const entryId = await resolveJournalEntryRef(supabase, companyId, entryRef)
|
||||
|
||||
// Pre-flight: the executor checks again, but failing fast here gives the
|
||||
// agent a clearer error message than waiting until commit-time.
|
||||
// The Supabase types don't infer through `fiscal_periods!inner(...)`,
|
||||
@@ -5936,7 +6012,16 @@ export const tools: McpTool[] = [
|
||||
.maybeSingle()
|
||||
const original = data as OriginalRow | null
|
||||
|
||||
if (origErr || !original) throw new Error('Journal entry not found')
|
||||
if (origErr) {
|
||||
throw new Error(`Database error looking up journal entry ${entryId}: ${origErr.message}`)
|
||||
}
|
||||
if (!original) {
|
||||
throw new Error(
|
||||
`Journal entry not found: id=${entryId}. ` +
|
||||
`If this UUID came from an earlier tool result, re-fetch via gnubok_query_journal — ` +
|
||||
`UUIDs are frequently hallucinated when reused across turns. You can also pass a voucher ref like "A-113".`
|
||||
)
|
||||
}
|
||||
if (original.status !== 'posted') {
|
||||
throw new Error(`Only posted entries can be corrected. Current status: ${original.status}.`)
|
||||
}
|
||||
@@ -5997,7 +6082,7 @@ export const tools: McpTool[] = [
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
entry_id: { type: 'string', description: 'UUID of the posted journal entry to reverse' },
|
||||
entry_id: { type: 'string', description: 'Journal entry UUID OR voucher ref like "A-113". Prefer voucher refs: UUIDs reused from earlier tool output are frequently hallucinated by LLM callers.' },
|
||||
reversal_date: { type: 'string', pattern: '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', description: 'Optional ISO yyyy-MM-dd date for the storno verifikation. Defaults to today (Swedish timezone). Period attribution always follows the original entry, regardless of this date.' },
|
||||
reason: { type: 'string', maxLength: 500, description: 'Optional human-readable reason — shown in pending_operations review. Not stored on the storno itself. Max 500 chars.' },
|
||||
},
|
||||
@@ -6006,11 +6091,11 @@ export const tools: McpTool[] = [
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const entryId = args.entry_id as string
|
||||
const entryRef = args.entry_id as string
|
||||
const reversalDate = typeof args.reversal_date === 'string' ? args.reversal_date : undefined
|
||||
const reason = typeof args.reason === 'string' ? args.reason : undefined
|
||||
|
||||
if (!entryId) {
|
||||
if (!entryRef) {
|
||||
throw new Error('entry_id is required')
|
||||
}
|
||||
// Belt-and-braces runtime check: inputSchema declares the pattern, but the
|
||||
@@ -6023,6 +6108,8 @@ export const tools: McpTool[] = [
|
||||
throw new Error('reason must be 500 characters or fewer')
|
||||
}
|
||||
|
||||
const entryId = await resolveJournalEntryRef(supabase, companyId, entryRef)
|
||||
|
||||
// Pre-flight mirrors commitReverseEntry: posted + period not closed/locked.
|
||||
// Failing fast gives a clearer Swedish error than waiting until commit-time.
|
||||
// Both is_closed and locked_at are checked so the staging-time signal
|
||||
@@ -6056,7 +6143,16 @@ export const tools: McpTool[] = [
|
||||
.maybeSingle()
|
||||
const original = data as OriginalRow | null
|
||||
|
||||
if (origErr || !original) throw new Error('Journal entry not found')
|
||||
if (origErr) {
|
||||
throw new Error(`Database error looking up journal entry ${entryId}: ${origErr.message}`)
|
||||
}
|
||||
if (!original) {
|
||||
throw new Error(
|
||||
`Journal entry not found: id=${entryId}. ` +
|
||||
`If this UUID came from an earlier tool result, re-fetch via gnubok_query_journal — ` +
|
||||
`UUIDs are frequently hallucinated when reused across turns. You can also pass a voucher ref like "A-113".`
|
||||
)
|
||||
}
|
||||
if (original.status !== 'posted') {
|
||||
throw new Error(`Only posted entries can be reversed. Current status: ${original.status}.`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user