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:
Mattsson
2026-05-21 21:33:22 +02:00
committed by GitHub
parent cc351158f8
commit e4488a900b
144 changed files with 12304 additions and 3853 deletions
@@ -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}`))
})
})
+104 -8
View File
@@ -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}.`)
}