Bug/open banking flow (#854)

* fix(enable-banking): pin Mobile BankID (decoupled) auth_method so Handelsbanken corporate connects

We never sent auth_method to Enable Banking, so it fell back to the ASPSP's
visible default — REDIRECT for Handelsbanken. For Handelsbanken *corporate*
PSUs the redirect flow does not support Mobile BankID, so authorization failed
right after the user approved in the BankID app. Mobile BankID at Handelsbanken
is a DECOUPLED method flagged hidden_method=true, which Enable Banking only uses
when requested explicitly.

Resolve the bank's preferred auth method before /auth: query the ASPSP's
auth_methods and pick the DECOUPLED (Mobile BankID) method when present,
otherwise leave auth_method unset so banks that already work are untouched.
The method name is read dynamically per psu_type, so it is robust across
sandbox/production naming.

- api-client: add approach/hidden_method to AuthMethod, fix ASPSP.auth_methods
  field name (was available_auth_methods, never populated), add
  getPreferredAuthMethod(), thread optional authMethod through startAuthorization
- index: resolve authMethod in /connect and pass it on both fresh + reconnect
- tests: cover method selection and request-body shaping

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

* refactor(invoice-inbox): clean up bulk-selection toolbar UI

Redesign the selection toolbar shown when inbox items are checked:
one solid primary "Bokför valda" button with outlined secondary
actions ("Fråga assistenten", "Ta bort") and a plain selection
count. Removes the redundant "Avmarkera" button (users uncheck the
still-visible box), fixes label clipping, and gives the toolbar more
breathing room.

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

* chore(entitlements): bypass paywall in local development

Add isPaywallBypassed() so all gated capabilities are testable locally
without a subscription. Fires only on NODE_ENV=development (npm run dev)
or an explicit DISABLE_PAYWALL=true escape hatch — production builds run
under NODE_ENV=production and the entitlement suite runs under 'test',
so both keep exercising the real gate.

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

* fix(tic): resolve enskild firma bolagsuppgifter via 12-digit personnummer

TIC's Lens search is fuzzy and only resolves an enskild firma from the 12-digit (century-prefixed) personnummer; a 10-digit form fuzzy-matched an unrelated entity. Expand personnummer to 12 digits before querying and reject hits whose registration number is unrelated to the request. Add a "Hämta" action to the settings Bolagsuppgifter panel to (re)fetch on demand.

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

* feat(transactions): implement categorize core for bank transaction categorization

- Added `categorize-core.ts` to handle categorization of bank transactions, supporting single and bulk operations.
- Introduced `categorizeMatchedTransaction` and `bulkBookMatchedInboxItems` functions for transaction processing.
- Implemented fiscal period validation and duplicate booking detection.
- Enhanced logging and error handling for transaction categorization.

feat(scripts): add diagnostic script for Handelsbanken ASPSP metadata

- Created `check-handelsbanken-aspsp.mjs` to fetch and display available authentication methods for Handelsbanken.
- Outputs metadata for business and personal PSU types, including default authentication methods.

fix(migrations): increase statement timeout for SIE bulk delete operations

- Updated `20260629160000_sie_bulk_delete_statement_timeout.sql` to set a longer statement timeout for bulk delete RPCs to prevent cancellations during large imports.

feat(migrations): add bulk book inbox items to pending operations

- Expanded `pending_operations` table to include `bulk_book_inbox_items` operation type in `20260630120000_pending_operations_add_bulk_book_inbox_items.sql`.
- Supports bulk booking of matched inbox items against bank transactions.

test(pg): add tests for replace_period_opening_balance_link RPC

- Implemented tests in `replace-period-opening-balance-link.pg.test.ts` to validate the functionality of the opening-balance correction flow.
- Ensured immutability of opening balance links and proper handling of posted vs. non-posted entries.

* fix(sie-export): update journal entries and lines handling in SIE export tests

* fix(migrations): resolve version collision on 20260629160000

The SIE bulk-delete statement_timeout migration shared version
20260629160000 with journal_entries_list_series_filter (merged from
main via #798/#823), causing a schema_migrations_pkey duplicate key
error on apply. Rename the branch's migration to 20260629160100.

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

* fix(compliance): resolve compliance-swarm + review findings

- opening-balance/correct: compensating rollback for the non-atomic
  storno+rebook so a mid-sequence failure never leaves two posted OB
  entries (ASVS V2.3); durable audit event on every failure path
  (V16); reference the original verifikationsnummer in the corrected
  entry per BFL 5 kap 5§; document that requireWrite already enforces
  write-role + membership (V8.2.1 was a false positive)
- reports sources routes: validate the cursor date component as ISO
  (/^\d{4}-\d{2}-\d{2}$/) before use, 400 on malformed (ASVS V1.2),
  applied to both the VAT-declaration and trial-balance routes
- AgentSessionList: await the rename PATCH, revert the optimistic
  title and toast on failure (ASVS V4.5)
- bank booking: exclude same-batch siblings from the booking-time
  duplicate guard so bulk-booking distinct same-(date,amount)
  transactions no longer false-positives; pre-existing duplicate
  detection is preserved
- BulkBookInboxDialog: drop the unsafe currency-based reverse_charge
  default, add an omvänd skattskyldighet advisory, and type VAT
  options to the backend VatTreatment union
- OpeningBalanceRowEditor: hold onChange in a ref (synced in effect,
  not during render) so an unstable callback can't cause a render loop

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-01 18:13:00 +02:00
committed by GitHub
parent 2da9c71eb3
commit f63d3e3100
83 changed files with 6769 additions and 1360 deletions
+1
View File
@@ -107,3 +107,4 @@ scripts/*.csv
scripts/reopen-bokslut.sql
.claude/plans/write-up-a-plan-streamed-fiddle.md
/ingaende-balanser-test.csv
+46 -1
View File
@@ -22,8 +22,10 @@ import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import JournalEntryStatusBadge, { useSourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
import CorrectOpeningBalanceDialog from '@/components/bookkeeping/CorrectOpeningBalanceDialog'
import EditDraftEntryDialog from '@/components/bookkeeping/EditDraftEntryDialog'
import RecordateEntryDialog from '@/components/bookkeeping/RecordateEntryDialog'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import CorrectionChain from '@/components/bookkeeping/CorrectionChain'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { useToast } from '@/components/ui/use-toast'
@@ -43,6 +45,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showCorrection, setShowCorrection] = useState(false)
const [showCorrectIB, setShowCorrectIB] = useState(false)
const [showEdit, setShowEdit] = useState(false)
const [showRecordate, setShowRecordate] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
@@ -237,6 +240,14 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
// correction (or the original) and corrects that one.
const canCorrect = entry.status === 'posted' && entry.source_type !== 'storno'
// An opening-balance verifikat must be corrected through the IB-aware flow
// (storno + rebook + relink the period's opening_balance_entry_id), never the
// generic "Rätta rader" — that books a `correction` entry but leaves the
// period pointing at the stornoed IB, so the Balansrapport "Ingående balans"
// column goes stale. Only surface it on the *active* IB (posted; stornoed
// predecessors are `reversed`, so exactly one posted IB exists per period).
const isOpeningBalance = entry.source_type === 'opening_balance' && entry.status === 'posted'
// Include current entry in the chain for the visualization
const fullChain = [entry, ...chain]
@@ -265,6 +276,14 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
{(entry.status === 'posted' || entry.status === 'draft') && (
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
{entry.status === 'draft' && (
<AgentSparkleButton
intentId="verifikation.draft"
intentArgs={{ journal_entry_id: id }}
contextRef={`verifikation:${id}`}
className="w-full sm:w-auto"
/>
)}
{entry.status === 'draft' && (
<Button
variant="outline"
@@ -303,7 +322,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
{entry.status === 'draft' ? t('delete_draft') : t('delete_entry')}
</Button>
)}
{canCorrect && (
{canCorrect && !isOpeningBalance && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -335,6 +354,19 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
</DropdownMenuContent>
</DropdownMenu>
)}
{canCorrect && isOpeningBalance && (
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() => setShowCorrectIB(true)}
disabled={!canWrite}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : <Pencil className="mr-2 h-4 w-4" />}
{t('correct_opening_balances')}
</Button>
)}
{entry.status === 'posted' && (
<Button
variant="outline"
@@ -697,6 +729,19 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
/>
)}
{/* Opening-balance correction dialog — IB-aware (storno + rebook + relink) */}
{showCorrectIB && entry && (
<CorrectOpeningBalanceDialog
entry={entry}
open={showCorrectIB}
onOpenChange={setShowCorrectIB}
onCorrected={() => {
setShowCorrectIB(false)
fetchData()
}}
/>
)}
{/* Recordate (move to correct date) dialog */}
{showRecordate && entry && (
<RecordateEntryDialog
+25 -15
View File
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
import { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
import NewJournalEntryDialog, { type CopyPrefill } from '@/components/bookkeeping/NewJournalEntryDialog'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import { useToast } from '@/components/ui/use-toast'
import { Plus } from 'lucide-react'
import { PageHeader } from '@/components/ui/page-header'
@@ -123,21 +124,30 @@ export default function BookkeepingPage() {
<PageHeader
title={t('title')}
action={
<Button
className="w-full sm:w-auto"
onClick={() => {
setCopyPrefill(null)
setShowNewEntry(true)
}}
>
<Plus className="mr-2 h-4 w-4" />
{t('tab_new_entry')}
{nextVoucher && (
<span className="ml-1 text-primary-foreground/70 tabular-nums">
({nextVoucher.series}{nextVoucher.next})
</span>
)}
</Button>
<div className="flex gap-2 w-full sm:w-auto">
<Button
className="w-full sm:w-auto"
onClick={() => {
setCopyPrefill(null)
setShowNewEntry(true)
}}
>
<Plus className="mr-2 h-4 w-4" />
{t('tab_new_entry')}
{nextVoucher && (
<span className="ml-1 text-primary-foreground/70 tabular-nums">
({nextVoucher.series}{nextVoucher.next})
</span>
)}
</Button>
<AgentSparkleButton
intentId="verifikation.draft"
contextRef="verifikation:new"
label={t('create_with_assistant')}
size="default"
className="w-full sm:w-auto"
/>
</div>
}
/>
+23 -9
View File
@@ -813,6 +813,7 @@ const OB_STEP_LABELS: Record<OpeningBalanceStep, string> = {
function OpeningBalanceFlow() {
const { toast } = useToast()
const { dialogProps, confirm } = useDestructiveConfirm()
const [obStep, setObStep] = useState<OpeningBalanceStep>('upload')
const [obIsLoading, setObIsLoading] = useState(false)
@@ -917,12 +918,27 @@ function OpeningBalanceFlow() {
setObStep('period')
}, [])
const handleExecute = useCallback(async (fiscalPeriodId: string) => {
const handleExecute = useCallback(async (fiscalPeriodId: string, replace: boolean) => {
if (replace) {
const ok = await confirm({
title: 'Ersätt ingående balanser?',
description:
'Den befintliga IB-verifikationen makuleras (stornas) och en ny bokförs med beloppen du angett. Detta går inte att ångra automatiskt.',
confirmLabel: 'Ersätt',
variant: 'warning',
})
if (!ok) return
}
setObIsLoading(true)
setObError(null)
const endpoint = replace
? '/api/import/opening-balance/correct'
: '/api/import/opening-balance/execute'
try {
const res = await fetch('/api/import/opening-balance/execute', {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -938,11 +954,7 @@ function OpeningBalanceFlow() {
const data = await res.json()
if (!res.ok) {
if (res.status === 409) {
setObError(data.error || 'Perioden har redan ingående balanser')
} else {
setObError(data.error || 'Importen misslyckades')
}
setObError(getErrorMessage(data))
return
}
@@ -951,7 +963,7 @@ function OpeningBalanceFlow() {
if (data.data.success) {
toast({
title: 'Ingående balanser bokförda',
title: replace ? 'Ingående balanser korrigerade' : 'Ingående balanser bokförda',
description: `${data.data.lines_created} kontorader skapades`,
})
}
@@ -960,7 +972,7 @@ function OpeningBalanceFlow() {
} finally {
setObIsLoading(false)
}
}, [editedRows, toast])
}, [editedRows, toast, confirm])
const handleNewImport = () => {
setObStep('upload')
@@ -1047,6 +1059,8 @@ function OpeningBalanceFlow() {
onNewImport={handleNewImport}
/>
)}
<DestructiveConfirmDialog {...dialogProps} />
</div>
)
}
@@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockAuth = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn().mockResolvedValue({
from: vi.fn(),
auth: { getUser: () => mockAuth() },
}),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { GET } from '../route'
function mkReq() {
return new Request('http://localhost/api/bookkeeping/accounts/bas-catalog')
}
function mkParams() {
return { params: Promise.resolve({}) }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/bookkeeping/accounts/bas-catalog', () => {
it('returns 401 when not authenticated', async () => {
mockAuth.mockResolvedValue({ data: { user: null } })
const res = await GET(mkReq(), mkParams())
expect(res.status).toBe(401)
})
it('returns the full BAS catalogue with the projected fields', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
const res = await GET(mkReq(), mkParams())
const body = await res.json()
expect(res.status).toBe(200)
expect(Array.isArray(body.data)).toBe(true)
// The real BAS 2026 chart is ~1,276 accounts.
expect(body.data.length).toBeGreaterThan(1000)
const it = body.data.find((a: { account_number: string }) => a.account_number === '6540')
expect(it).toMatchObject({
account_number: '6540',
account_name: 'IT-tjänster',
account_class: 6,
account_group: '65',
})
expect(typeof it.description).toBe('string')
})
it('sets a client cache header (static reference data)', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
const res = await GET(mkReq(), mkParams())
expect(res.headers.get('Cache-Control')).toContain('max-age=')
})
})
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
/**
* GET /api/bookkeeping/accounts/bas-catalog
*
* The full BAS 2026 catalogue (~1,276 accounts), projected to the fields the
* AccountCombobox needs to search and render. This lets the manual bookkeeping
* flow surface accounts by name even when they aren't in the company's chart
* yet — selecting one routes through the existing activate-on-commit rail
* (ACCOUNTS_NOT_IN_CHART → ActivateAccountsDialog → /accounts/activate).
*
* The payload is static reference data for the deploy and identical for every
* company, so it's cached hard on the client. Wrapped in withRouteContext so it
* stays behind auth (MFA on hosted) like every other bookkeeping route.
*/
export const GET = withRouteContext('bookkeeping.accounts.bas_catalog', async () => {
const data = BAS_REFERENCE.map((a) => ({
account_number: a.account_number,
account_name: a.account_name,
account_class: a.account_class,
account_group: a.account_group,
description: a.description,
}))
return NextResponse.json(
{ data },
{ headers: { 'Cache-Control': 'private, max-age=86400' } },
)
})
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, createMockRouteParams } from '@/tests/helpers'
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/core/bookkeeping/period-service', () => ({
unlockPeriod: vi.fn(),
}))
import { requireAuth } from '@/lib/auth/require-auth'
import { unlockPeriod } from '@/lib/core/bookkeeping/period-service'
import { POST } from '../route'
function unlockRequest(): Request {
return createMockRequest('/api/bookkeeping/fiscal-periods/p1/unlock', { method: 'POST' })
}
function mockAuth() {
;(requireAuth as ReturnType<typeof vi.fn>).mockResolvedValue({
user: { id: 'user-1' },
supabase: {},
error: null,
})
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('POST /api/bookkeeping/fiscal-periods/[id]/unlock', () => {
it('unlocks the period and returns it on success', async () => {
mockAuth()
;(unlockPeriod as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 'p1', locked_at: null })
const res = await POST(unlockRequest(), createMockRouteParams({ id: 'p1' }))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.id).toBe('p1')
})
it('maps a not-locked period to a 409', async () => {
mockAuth()
;(unlockPeriod as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Period is not locked'))
const res = await POST(unlockRequest(), createMockRouteParams({ id: 'p1' }))
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('PERIOD_UNLOCK_NOT_LOCKED')
})
it('maps a closed period to a 409', async () => {
mockAuth()
;(unlockPeriod as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Cannot unlock a closed period'),
)
const res = await POST(unlockRequest(), createMockRouteParams({ id: 'p1' }))
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('PERIOD_UNLOCK_CLOSED')
})
it('maps a missing period to a 404', async () => {
mockAuth()
;(unlockPeriod as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Fiscal period not found'))
const res = await POST(unlockRequest(), createMockRouteParams({ id: 'p1' }))
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('PERIOD_NOT_FOUND')
})
})
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server'
import { unlockPeriod } from '@/lib/core/bookkeeping/period-service'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
export const POST = withRouteContext(
'period.unlock',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const opLog = log.child({ periodId: id })
try {
const period = await unlockPeriod(supabase, companyId!, user.id, id)
return NextResponse.json({ data: period })
} catch (err) {
opLog.error('failed to unlock period', err as Error)
// unlockPeriod() throws plain Error with messages like "Fiscal period not
// found", "Cannot unlock a closed period" or "Period is not locked" —
// translate to envelope codes, mirroring the sibling lock route.
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
}
if (/closed/i.test(message)) {
return errorResponseFromCode('PERIOD_UNLOCK_CLOSED', opLog, { requestId })
}
if (/not locked/i.test(message)) {
return errorResponseFromCode('PERIOD_UNLOCK_NOT_LOCKED', opLog, { requestId })
}
return errorResponse(err, opLog, { requestId })
}
},
{ requireWrite: true },
)
@@ -0,0 +1,219 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockCreateJournalEntry = vi.fn()
const mockReverseEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
}))
vi.mock('@/lib/bookkeeping/bas-reference', () => ({
getBASReference: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/supabase/fetch-all', () => ({
// All referenced accounts already exist → no chart activation insert.
fetchAllRows: vi.fn().mockResolvedValue([
{ account_number: '1930' },
{ account_number: '2099' },
]),
}))
import { POST } from '../correct/route'
const PERIOD_ID = '550e8400-e29b-41d4-a716-446655440000'
const BALANCED_LINES = [
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
{ account_number: '2099', debit_amount: 0, credit_amount: 50000 },
]
function makeRequest(body: unknown) {
return createMockRequest('/api/import/opening-balance/correct', {
method: 'POST',
body,
})
}
function openPeriodWithOB(overrides: Record<string, unknown> = {}) {
return {
id: PERIOD_ID,
company_id: 'company-1',
is_closed: false,
locked_at: null,
opening_balances_set: true,
opening_balance_entry_id: 'entry-old',
period_start: '2026-01-01',
...overrides,
}
}
describe('POST /api/import/opening-balance/correct', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 for unauthenticated requests', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(401)
expect(body.error).toBe('Unauthorized')
})
it('returns 400 for invalid body', async () => {
const res = await POST(makeRequest({ fiscal_period_id: 'not-a-uuid', lines: [] }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns 404 for non-existent fiscal period', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(404)
expect((body.error as unknown as { code: string }).code).toBe('OB_PERIOD_NOT_FOUND')
})
it('returns 400 when the period is closed', async () => {
enqueue({ data: openPeriodWithOB({ is_closed: true }) })
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('OB_PERIOD_CLOSED')
})
it('returns 400 when the period is locked', async () => {
enqueue({ data: openPeriodWithOB({ locked_at: '2026-06-28T00:00:00Z' }) })
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('OB_PERIOD_LOCKED')
})
it('returns 409 when the period has no opening balances to correct', async () => {
enqueue({ data: openPeriodWithOB({ opening_balances_set: false, opening_balance_entry_id: null }) })
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(409)
expect((body.error as unknown as { code: string }).code).toBe('OB_CORRECT_NO_EXISTING')
})
it('returns 409 when a year-end close exists on the period', async () => {
enqueue({ data: openPeriodWithOB() }) // period
enqueue({ count: 1 }) // year-end entry count
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(409)
expect((body.error as unknown as { code: string }).code).toBe('OB_CORRECT_YEAR_END_EXISTS')
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
expect(mockReverseEntry).not.toHaveBeenCalled()
})
it('returns 400 for unbalanced corrected lines', async () => {
enqueue({ data: openPeriodWithOB() }) // period
enqueue({ count: 0 }) // year-end check
const res = await POST(makeRequest({
fiscal_period_id: PERIOD_ID,
lines: [
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
{ account_number: '2099', debit_amount: 0, credit_amount: 40000 },
],
}))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('OB_UNBALANCED')
})
it('books a corrected IB, stornoes the old one, and relinks on success', async () => {
enqueue({ data: openPeriodWithOB() }) // period
enqueue({ count: 0 }) // year-end check
enqueue({ error: null }) // replace_period_opening_balance_link RPC
mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 5 })
mockReverseEntry.mockResolvedValue({ id: 'entry-storno' })
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(200)
expect(body.data.success).toBe(true)
expect(body.data.journal_entry_id).toBe('entry-new')
expect(body.data.reversed_entry_id).toBe('entry-old')
expect(body.data.lines_created).toBe(2)
// New IB created before the old one is reversed.
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ source_type: 'opening_balance', voucher_series: 'A' }),
)
expect(mockReverseEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'entry-old',
)
expect(mockSupabase.rpc).toHaveBeenCalledWith(
'replace_period_opening_balance_link',
expect.objectContaining({ p_period_id: PERIOD_ID, p_new_entry_id: 'entry-new' }),
)
})
it('returns 500 OB_CORRECT_FAILED if the relink RPC fails', async () => {
enqueue({ data: openPeriodWithOB() }) // period
enqueue({ count: 0 }) // year-end check
enqueue({ error: { message: 'relink boom' } }) // RPC failure
mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 5 })
mockReverseEntry.mockResolvedValue({ id: 'entry-storno' })
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('OB_CORRECT_FAILED')
})
})
@@ -0,0 +1,216 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockCreateJournalEntry = vi.fn()
const mockReverseEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
}))
vi.mock('@/lib/bookkeeping/bas-reference', () => ({
getBASReference: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/supabase/fetch-all', () => ({
// All referenced accounts already exist → no chart activation insert (and no
// extra supabase.from() call that would shift the queued-mock cursor).
fetchAllRows: vi.fn().mockResolvedValue([
{ account_number: '1930' },
{ account_number: '2099' },
]),
}))
import { POST } from '../route'
type SpyInstance = ReturnType<typeof vi.spyOn>
const PERIOD_ID = '550e8400-e29b-41d4-a716-446655440000'
const BALANCED_LINES = [
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
{ account_number: '2099', debit_amount: 0, credit_amount: 50000 },
]
function makeRequest(body: unknown) {
return createMockRequest('/api/import/opening-balance/correct', {
method: 'POST',
body,
})
}
function openPeriodWithOB(overrides: Record<string, unknown> = {}) {
return {
id: PERIOD_ID,
company_id: 'company-1',
is_closed: false,
locked_at: null,
opening_balances_set: true,
opening_balance_entry_id: 'entry-old',
period_start: '2026-01-01',
// Embedded resource from the period fetch — the original IB verifikat's
// voucher label, used to build the BFL 5 kap 5§ reference.
opening_balance_entry: { voucher_series: 'A', voucher_number: 123 },
...overrides,
}
}
/** Flatten every console.error call into one searchable string. */
function auditLines(spy: SpyInstance): string {
return spy.mock.calls
.map((call) => call.map((a: unknown) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '))
.filter((line) => line.includes('opening balance correction failed'))
.join('\n')
}
describe('POST /api/import/opening-balance/correct — atomicity, audit, BFL reference', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
let errorSpy: SpyInstance
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
// The structured logger writes error-level records to console.error even in
// the test env; spy on it so we can assert the durable audit line.
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
errorSpy.mockRestore()
})
// FIX 3 (BFL 5 kap 5§) — the corrected entry references the original voucher.
it('references the original verifikationsnummer in the corrected entry description', async () => {
enqueue({ data: openPeriodWithOB({ opening_balance_entry: { voucher_series: 'B', voucher_number: 7 } }) }) // period
enqueue({ count: 0 }) // year-end check
enqueue({ error: null }) // replace_period_opening_balance_link RPC
mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 9 })
mockReverseEntry.mockResolvedValue({ id: 'entry-storno' })
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(200)
expect(body.data.success).toBe(true)
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({
description: 'Ingående balanser (korrigerade, rättelse av B7)',
source_type: 'opening_balance',
}),
)
// Happy path stornoes ONLY the old entry — no compensating reverse.
expect(mockReverseEntry).toHaveBeenCalledTimes(1)
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-old')
})
// FIX 1 (ASVS V2.3) — compensation when the storno of the OLD entry throws
// after the new entry was already created.
it('compensates by stornoing the new entry when reverseEntry throws, returning OB_CORRECT_FAILED', async () => {
enqueue({ data: openPeriodWithOB() }) // period
enqueue({ count: 0 }) // year-end check
// No RPC enqueue: step B throws before the relink is reached.
mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 9 })
mockReverseEntry
.mockRejectedValueOnce(new Error('storno of old failed')) // step B (oldEntryId)
.mockResolvedValueOnce({ id: 'entry-storno-new' }) // compensation (newEntry.id)
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('OB_CORRECT_FAILED')
// First the failed storno of the old entry, then the compensating storno of
// the new entry.
expect(mockReverseEntry).toHaveBeenCalledTimes(2)
expect(mockReverseEntry).toHaveBeenNthCalledWith(1, expect.anything(), 'company-1', 'user-1', 'entry-old')
expect(mockReverseEntry).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'user-1', 'entry-new')
// Durable audit carries both ids for manual recovery.
const audit = auditLines(errorSpy)
expect(audit).toContain('entry-new')
expect(audit).toContain('entry-old')
})
// FIX 1 + FIX 2 — relink RPC error triggers compensation and a durable audit.
it('compensates and emits a durable audit when the relink RPC returns an error', async () => {
enqueue({ data: openPeriodWithOB() }) // period
enqueue({ count: 0 }) // year-end check
enqueue({ error: { message: 'relink boom' } }) // RPC failure
mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 9 })
mockReverseEntry.mockResolvedValue({ id: 'entry-storno' }) // step B + compensation both succeed
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(500)
const err = body.error as unknown as { code: string; details?: { newEntryId?: string; oldEntryId?: string } }
expect(err.code).toBe('OB_CORRECT_FAILED')
expect(err.details?.newEntryId).toBe('entry-new')
expect(err.details?.oldEntryId).toBe('entry-old')
// Compensation: old entry stornoed (step B) then the new entry stornoed.
expect(mockReverseEntry).toHaveBeenCalledTimes(2)
expect(mockReverseEntry).toHaveBeenNthCalledWith(1, expect.anything(), 'company-1', 'user-1', 'entry-old')
expect(mockReverseEntry).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'user-1', 'entry-new')
// Durable audit event payload contains newEntryId + oldEntryId.
const audit = auditLines(errorSpy)
expect(audit).toContain('opening_balance.correction_failed')
expect(audit).toContain('entry-new')
expect(audit).toContain('entry-old')
})
// FIX 2 — the compensating storno may itself fail; the handler must still
// return the envelope and audit the compensation failure (never rethrow).
it('audits a compensation failure and still returns OB_CORRECT_FAILED', async () => {
enqueue({ data: openPeriodWithOB() }) // period
enqueue({ count: 0 }) // year-end check
enqueue({ error: { message: 'relink boom' } }) // RPC failure
mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 9 })
mockReverseEntry
.mockResolvedValueOnce({ id: 'entry-storno' }) // step B ok
.mockRejectedValueOnce(new Error('compensation storno failed')) // compensation throws
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('OB_CORRECT_FAILED')
const audit = auditLines(errorSpy)
expect(audit).toContain('compensation_failed')
expect(audit).toContain('entry-new')
expect(audit).toContain('entry-old')
})
})
@@ -0,0 +1,254 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { OpeningBalanceExecuteSchema } from '@/lib/api/schemas'
import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import {
validateOpeningBalanceLines,
activateMissingAccounts,
buildOpeningBalanceEntryLines,
} from '@/lib/import/opening-balance/execute-helpers'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
ensureInitialized()
/**
* POST /api/import/opening-balance/correct
*
* Correct a period's existing opening balances the BFL-compliant way: the
* current IB verifikat (immutable, posted) is stornoed and a corrected IB is
* booked, then fiscal_periods.opening_balance_entry_id is relinked to the new
* entry via the replace_period_opening_balance_link RPC.
*
* Because getOpeningBalances reads the linked entry directly and the
* trial-balance / general-ledger movement queries include both `posted` and
* `reversed` lines (excluding only the linked OB entry), the stornoed old IB
* and its storno mirror cancel out in period movement — so the Balansrapport
* IB column shows the corrected figures and UB stays correct.
*
* Gated to the safe case only: the period must be open, unlocked, already have
* opening balances, and have no year-end close on top. Locked/closed periods or
* periods with a bokslut must be unwound first (assisted) — we refuse here.
*/
export const POST = withRouteContext(
'opening_balance.correct',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
const result = await validateBody(request, OpeningBalanceExecuteSchema, {
log,
operation: 'opening_balance.correct',
})
if (!result.success) return result.response
const { fiscal_period_id, lines } = result.data
const opLog = log.child({ fiscalPeriodId: fiscal_period_id })
try {
// 1. Verify the fiscal period belongs to the company and is correctable.
// Write-role (non-viewer) + company membership are already enforced by
// withRouteContext({ requireWrite: true }) before this handler runs
// (requireWritePermission + getActiveCompanyId), and this fetch is scoped
// by that verified companyId — no redundant authz here (ASVS V8.2.1).
// The embedded opening_balance_entry pulls the original IB verifikat's
// voucher label so the corrected entry can reference it (BFL 5 kap 5§).
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select(
'*, opening_balance_entry:journal_entries!opening_balance_entry_id(voucher_series, voucher_number)',
)
.eq('id', fiscal_period_id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
return errorResponseFromCode('OB_PERIOD_NOT_FOUND', opLog, { requestId })
}
if (period.is_closed) {
return errorResponseFromCode('OB_PERIOD_CLOSED', opLog, { requestId })
}
if (period.locked_at) {
return errorResponseFromCode('OB_PERIOD_LOCKED', opLog, { requestId })
}
if (!period.opening_balances_set || !period.opening_balance_entry_id) {
return errorResponseFromCode('OB_CORRECT_NO_EXISTING', opLog, { requestId })
}
// Refuse if a year-end close was built on top — correcting the IB without
// unwinding the bokslut would leave the period (and the next period's
// carried-forward IB) internally inconsistent.
const { count: yearEndCount } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscal_period_id)
.eq('source_type', 'year_end')
.eq('status', 'posted')
if ((yearEndCount ?? 0) > 0) {
return errorResponseFromCode('OB_CORRECT_YEAR_END_EXISTS', opLog, { requestId })
}
const oldEntryId = period.opening_balance_entry_id
// 2. Validate the corrected lines (drop zeros, ≥2 rows, no P&L, must balance).
const validation = validateOpeningBalanceLines(lines)
if (!validation.ok) {
return errorResponseFromCode(validation.code, opLog, {
requestId,
details:
validation.code === 'OB_PNL_ACCOUNT'
? { accounts: validation.accounts }
: validation.code === 'OB_UNBALANCED'
? { totalDebit: validation.totalDebit, totalCredit: validation.totalCredit, diff: validation.diff }
: undefined,
})
}
const { validLines, totalDebit, totalCredit } = validation
// 3. Auto-activate BAS accounts the corrected file references but the chart lacks.
const accountNumbers = [...new Set(validLines.map((l) => l.account_number))]
const activation = await activateMissingAccounts(supabase, companyId!, user.id, accountNumbers)
if (!activation.ok) {
opLog.error('opening balance account activation failed', new Error(activation.reason))
return errorResponseFromCode('OB_ACCOUNT_ACTIVATION_FAILED', opLog, {
requestId,
details: { reason: activation.reason },
})
}
// BFL 5 kap 5§ — reference the original verifikat so the correction is
// traceable to the entry it rättar. The embed above gave us the old IB's
// voucher label (e.g. "A123"). CreateJournalEntryInput exposes no dedicated
// correction-linkage field (corrects_entry_id / correction_of / metadata),
// so the description reference IS the linkage; we deliberately leave the
// generic source_id unset rather than overload it for an opening_balance.
const originalRef = (
period as {
opening_balance_entry?: {
voucher_series?: string | null
voucher_number?: number | null
} | null
}
).opening_balance_entry
const originalVoucherLabel =
originalRef?.voucher_series && originalRef?.voucher_number
? `${originalRef.voucher_series}${originalRef.voucher_number}`
: null
const correctedDescription = originalVoucherLabel
? `Ingående balanser (korrigerade, rättelse av ${originalVoucherLabel})`
: 'Ingående balanser (korrigerade)'
// 4. Book the corrected IB, storno the old one, then relink the period.
// Order matters: create the replacement BEFORE reversing the original so a
// mid-failure never leaves the period without an opening balance.
const newEntry = await createJournalEntry(supabase, companyId!, user.id, {
fiscal_period_id,
entry_date: period.period_start,
description: correctedDescription,
source_type: 'opening_balance',
voucher_series: 'A',
lines: buildOpeningBalanceEntryLines(validLines),
})
// ASVS V16 — durable audit sink for a failed correction. The core event bus
// has no opening_balance.* correction event type and lib/events/types.ts is
// outside the scope of this change, so the failure is recorded via the
// structured logger: it lands in the JSON log sink (Vercel/Sentry), tagged
// `audit: true` + both entry ids so an operator can reconcile the period by
// hand. (Follow-up: promote to a typed event persisted to event_log.)
const auditCorrectionFailure = (fields: Record<string, unknown>) => {
opLog.error('audit: opening balance correction failed', {
audit: true,
event: 'opening_balance.correction_failed',
companyId,
userId: user.id,
fiscalPeriodId: fiscal_period_id,
newEntryId: newEntry.id,
oldEntryId,
...fields,
})
}
// FIX (ASVS V2.3 — atomicity via compensation): steps B (storno old) and
// C (relink) are NOT atomic with A (create new). A already produced a second
// posted opening_balance entry for the period; if B or C fails, that entry is
// orphaned and the Balansrapport would show two OB entries. Wrap B+C so that
// on ANY failure below we compensate by stornoing the NEW entry, restoring the
// period to its original consistent state (original OB still linked, new entry
// cancelled by its own storno).
try {
// B: storno the original IB.
await reverseEntry(supabase, companyId!, user.id, oldEntryId)
// C: point the period at the corrected IB (single atomic RPC).
const { error: relinkError } = await supabase.rpc('replace_period_opening_balance_link', {
p_company_id: companyId,
p_period_id: fiscal_period_id,
p_new_entry_id: newEntry.id,
})
if (relinkError) {
// Funnel the RPC error into the single compensation path below.
throw new Error(`replace_period_opening_balance_link failed: ${relinkError.message}`)
}
} catch (seqErr) {
const reason = seqErr instanceof Error ? seqErr.message : 'unknown'
// Durable audit BEFORE compensation so the ids survive even if the
// compensating storno also throws.
//
// Residual edge (documented): if B succeeded but C failed, the old entry is
// now reversed yet still linked to the period. We still compensate the new
// entry; the audit payload carries newEntryId + oldEntryId so an operator can
// finish recovery (re-link or re-book) manually.
auditCorrectionFailure({ phase: 'sequence_failed', reason })
// Compensating rollback. This may itself throw (e.g. the period was locked
// between A and here) — catch + audit and never let it propagate past the
// handler, so the caller always gets the OB_CORRECT_FAILED envelope.
try {
await reverseEntry(supabase, companyId!, user.id, newEntry.id)
auditCorrectionFailure({ phase: 'compensated', reason })
} catch (compErr) {
auditCorrectionFailure({
phase: 'compensation_failed',
reason,
compensationError: compErr instanceof Error ? compErr.message : 'unknown',
})
}
return errorResponseFromCode('OB_CORRECT_FAILED', opLog, {
requestId,
details: { reason, newEntryId: newEntry.id, oldEntryId },
})
}
return NextResponse.json({
data: {
success: true,
journal_entry_id: newEntry.id,
reversed_entry_id: oldEntryId,
fiscal_period_id,
lines_created: validLines.length,
total_debit: totalDebit,
total_credit: totalCredit,
},
})
} catch (err) {
if (isBookkeepingError(err)) {
return errorResponse(err, opLog, { requestId })
}
opLog.error('opening balance correct failed', err as Error)
return errorResponseFromCode('OB_CORRECT_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
},
{ requireWrite: true },
)
+26 -117
View File
@@ -4,11 +4,13 @@ import { validateBody } from '@/lib/api/validate'
import { OpeningBalanceExecuteSchema } from '@/lib/api/schemas'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
validateOpeningBalanceLines,
activateMissingAccounts,
buildOpeningBalanceEntryLines,
} from '@/lib/import/opening-balance/execute-helpers'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { CreateJournalEntryLineInput } from '@/types'
ensureInitialized()
@@ -60,127 +62,34 @@ export const POST = withRouteContext(
})
}
// 2. Filter zero-amount lines and reject P&L accounts.
const validLines = lines.filter((l) => l.debit_amount > 0 || l.credit_amount > 0)
if (validLines.length < 2) {
return errorResponseFromCode('OB_TOO_FEW_LINES', opLog, { requestId })
}
const pnlAccounts = validLines
.map((l) => l.account_number)
.filter((num) => {
const cls = parseInt(num.charAt(0), 10)
return cls >= 3 && cls <= 8
})
if (pnlAccounts.length > 0) {
return errorResponseFromCode('OB_PNL_ACCOUNT', opLog, {
// 2. Validate lines (drop zeros, ≥2 rows, no P&L accounts, must balance).
const validation = validateOpeningBalanceLines(lines)
if (!validation.ok) {
return errorResponseFromCode(validation.code, opLog, {
requestId,
details: { accounts: pnlAccounts.slice(0, 5) },
details:
validation.code === 'OB_PNL_ACCOUNT'
? { accounts: validation.accounts }
: validation.code === 'OB_UNBALANCED'
? { totalDebit: validation.totalDebit, totalCredit: validation.totalCredit, diff: validation.diff }
: undefined,
})
}
const { validLines, totalDebit, totalCredit } = validation
// 3. Verify balance.
let totalDebit = 0
let totalCredit = 0
for (const line of validLines) {
totalDebit = Math.round((totalDebit + line.debit_amount) * 100) / 100
totalCredit = Math.round((totalCredit + line.credit_amount) * 100) / 100
}
const diff = Math.round((totalDebit - totalCredit) * 100) / 100
if (Math.abs(diff) >= 0.01) {
return errorResponseFromCode('OB_UNBALANCED', opLog, {
requestId,
details: { totalDebit, totalCredit, diff },
})
}
// 4. Auto-activate BAS accounts not in the company's chart.
// 3. Auto-activate BAS accounts not in the company's chart.
const accountNumbers = [...new Set(validLines.map((l) => l.account_number))]
const existingAccounts = await fetchAllRows(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.range(from, to),
)
const existingNumbers = new Set(existingAccounts.map((a) => a.account_number))
const accountsToActivate = accountNumbers
.filter((num) => !existingNumbers.has(num))
.map((num) => {
const ref = getBASReference(num)
if (ref) {
return {
user_id: user.id,
company_id: companyId,
account_number: ref.account_number,
account_name: ref.account_name,
account_class: ref.account_class,
account_group: ref.account_group,
account_type: ref.account_type,
normal_balance: ref.normal_balance,
plan_type: 'full_bas' as const,
is_active: true,
is_system_account: false,
description: ref.description,
sru_code: ref.sru_code,
sort_order: parseInt(ref.account_number),
}
}
const accountClass = parseInt(num.charAt(0), 10)
const accountGroup = num.substring(0, 2)
const accountType =
accountClass === 1 ? 'asset'
: accountClass === 2 ? 'liability'
: accountClass === 3 ? 'revenue'
: 'expense'
const normalBalance = accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit'
return {
user_id: user.id,
company_id: companyId,
account_number: num,
account_name: `Konto ${num}`,
account_class: accountClass,
account_group: accountGroup,
account_type: accountType,
normal_balance: normalBalance,
plan_type: 'full_bas' as const,
is_active: true,
is_system_account: false,
description: `Konto ${num}`,
sru_code: null,
sort_order: parseInt(num),
}
const activation = await activateMissingAccounts(supabase, companyId!, user.id, accountNumbers)
if (!activation.ok) {
opLog.error('opening balance account activation failed', new Error(activation.reason))
return errorResponseFromCode('OB_ACCOUNT_ACTIVATION_FAILED', opLog, {
requestId,
details: { reason: activation.reason },
})
if (accountsToActivate.length > 0) {
const { error: activateError } = await supabase
.from('chart_of_accounts')
.insert(accountsToActivate)
if (activateError) {
opLog.error('opening balance account activation failed', activateError)
return errorResponseFromCode('OB_ACCOUNT_ACTIVATION_FAILED', opLog, {
requestId,
details: { reason: activateError.message },
})
}
}
// 5. Create the opening balance journal entry.
const entryLines: CreateJournalEntryLineInput[] = validLines.map((line) => ({
account_number: line.account_number,
debit_amount: line.debit_amount,
credit_amount: line.credit_amount,
line_description: `IB ${line.account_number}`,
}))
// 4. Create the opening balance journal entry.
const entryLines = buildOpeningBalanceEntryLines(validLines)
const entry = await createJournalEntry(supabase, companyId!, user.id, {
fiscal_period_id,
@@ -191,7 +100,7 @@ export const POST = withRouteContext(
lines: entryLines,
})
// 6. Mark the fiscal period.
// 5. Mark the fiscal period.
await supabase
.from('fiscal_periods')
.update({
+5
View File
@@ -3,6 +3,11 @@ import { replaceSIEImport } from '@/lib/import/sie-import'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
// Hard-deleting a large import (thousands of audit-logged journal entries +
// cascading lines) can take well over the default function timeout. Match the
// SIE execute route so the serverless function doesn't kill the request first.
export const maxDuration = 300
/**
* POST /api/import/sie/[id]/replace
*
+5
View File
@@ -3,6 +3,11 @@ import { undoSIEImport } from '@/lib/import/sie-import'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
// Hard-deleting a large import (thousands of audit-logged journal entries +
// cascading lines) can take well over the default function timeout. Match the
// SIE execute route so the serverless function doesn't kill the request first.
export const maxDuration = 300
/**
* DELETE /api/import/sie/[id]/undo
*
@@ -37,7 +37,9 @@ function buildSupabase(
}
return chain
}
// journal_entry_lines
// journal_entry_lines — terminates on `.range()` (fetchAllRows), which
// resolves to the line result. `data.length < PAGE_SIZE` so a single
// page is fetched.
const chain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
@@ -47,6 +49,7 @@ function buildSupabase(
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
or: vi.fn().mockReturnThis(),
range: vi.fn().mockResolvedValue(linesResult),
then: (resolve: (v: unknown) => void) => resolve(linesResult),
}
return chain
@@ -94,6 +97,24 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () =>
expect(res.status).toBe(404)
})
it('returns 400 when the cursor date component is not a structural ISO date', async () => {
// Defense-in-depth (ASVS V1.2): the cursor is applied in JS, but a
// malformed date component must still be rejected structurally.
mockCreateClient.mockResolvedValue(
buildSupabase(
{ id: 'user-1' },
{ account_number: '1930', account_name: 'Företagskonto' },
{ data: [], error: null }
) as never
)
const req = createMockRequest(
'/api/reports/trial-balance/account/1930/sources',
{ searchParams: { fiscal_period_id: 'period-1', cursor: 'notadate|5' } }
)
const res = await GET(req, createMockRouteParams({ accountNumber: '1930' }))
expect(res.status).toBe(400)
})
it('happy path: returns mapped lines for an account', async () => {
const linesData = [
{
@@ -293,4 +314,69 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () =>
expect(body.data.lines[0].journal_entry_id).toBe('je-low') // voucher 5 first
expect(body.data.lines[1].journal_entry_id).toBe('je-high') // voucher 20 second
})
it('paginates a >500-line account deterministically regardless of DB return order', async () => {
// Regression: with no stable parent ORDER BY, a raw `.limit(500)` returned
// an arbitrary subset that varied between identical requests — the
// "different rows on every reload" bug for high-volume accounts. We now
// fetch the full set and sort/slice in JS, so the first page is always the
// 500 chronologically-earliest lines.
const total = 600
const ordered = Array.from({ length: total }, (_, i) => {
const day = String((i % 28) + 1).padStart(2, '0')
return {
debit_amount: i + 1,
credit_amount: 0,
journal_entry_id: `je-${String(i).padStart(4, '0')}`,
journal_entries: {
id: `je-${String(i).padStart(4, '0')}`,
voucher_number: i + 1, // unique, monotonic with intended order
voucher_series: 'A',
entry_date: `2026-${String((i % 12) + 1).padStart(2, '0')}-${day}`,
description: `Row ${i}`,
status: 'posted',
company_id: 'company-1',
fiscal_period_id: 'period-1',
},
}
})
// Shuffle deterministically so the DB "return order" is not the sorted one.
const shuffled = [...ordered].sort((a, b) =>
a.journal_entry_id < b.journal_entry_id ? 1 : -1
)
mockCreateClient.mockResolvedValue(
buildSupabase(
{ id: 'user-1' },
{ account_number: '3001', account_name: 'Försäljning' },
{ data: shuffled, error: null }
) as never
)
const req = createMockRequest(
'/api/reports/trial-balance/account/3001/sources',
{ searchParams: { fiscal_period_id: 'period-1' } }
)
const res = await GET(req, createMockRouteParams({ accountNumber: '3001' }))
expect(res.status).toBe(200)
const body = (await res.json()) as {
data: { lines: Array<{ voucher_number: number; date: string }>; next_cursor: string | null }
}
// First page is exactly PAGE_LIMIT rows, fully sorted (date ASC, then
// voucher_number ASC — numeric, not lexicographic).
expect(body.data.lines).toHaveLength(500)
const lines = body.data.lines
for (let i = 1; i < lines.length; i++) {
const prev = lines[i - 1]
const cur = lines[i]
const ordered =
prev.date < cur.date ||
(prev.date === cur.date && prev.voucher_number <= cur.voucher_number)
expect(ordered).toBe(true)
}
// More rows remain → a cursor is returned pointing at the last delivered row.
expect(body.data.next_cursor).toBe(`${lines[499].date}|${lines[499].voucher_number}`)
})
})
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { ReportSourceLine } from '@/lib/reports/source-lines'
/**
@@ -55,60 +56,66 @@ export async function GET(
)
}
// Pull all lines on this account in this period. We rely on the same
// join+filter pattern as `generateTrialBalance`. Pagination is server-side
// via cursor so even an account with tens of thousands of rows stays cheap.
let query = supabase
.from('journal_entry_lines')
.select(`
debit_amount,
credit_amount,
journal_entry_id,
journal_entries!inner(
id,
voucher_number,
voucher_series,
entry_date,
description,
status,
company_id,
fiscal_period_id
)
`)
.eq('account_number', accountNumber)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.in('journal_entries.status', ['posted', 'reversed'])
.limit(PAGE_LIMIT + 1)
// Parse the optional cursor up front (format: <iso-date>|<voucher_number>).
// Pagination is applied in JS after a full, deterministically-ordered fetch.
let cursorDate: string | null = null
let cursorVoucherNum = 0
if (cursor) {
// Cursor format: <iso-date>|<voucher_number>
const [cursorDate, cursorVoucher] = cursor.split('|')
const cursorVoucherNum = parseInt(cursorVoucher, 10)
if (!cursorDate || isNaN(cursorVoucherNum)) {
const [cd, cv] = cursor.split('|')
cursorVoucherNum = parseInt(cv, 10)
// The cursor is applied in JS (string compare); structurally validating the
// date component here is defense-in-depth against malformed/injection cursors.
if (!cd || !/^\d{4}-\d{2}-\d{2}$/.test(cd) || isNaN(cursorVoucherNum)) {
return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 })
}
// Filter for rows strictly after the cursor (date>cur OR same date & voucher>cur).
// Supabase doesn't expose tuple compare, so use an `or()` clause.
query = query.or(
`entry_date.gt.${cursorDate},and(entry_date.eq.${cursorDate},voucher_number.gt.${cursorVoucherNum})`,
{ foreignTable: 'journal_entries' }
)
cursorDate = cd
}
const { data, error } = await query
// Pull ALL lines on this account in this period, then sort + paginate in JS.
//
// Why not order/limit in SQL: `.order(col, { foreignTable })` in PostgREST
// sorts the *embedded* resource's rows, not the parent result set, so it
// cannot give us a chronological parent order. Without a stable parent order
// a raw `.limit()` returns an arbitrary subset that varies between identical
// requests — which surfaced as the trial-balance drill-down showing
// "different rows on every reload" for high-volume accounts. We instead page
// on the line PK (`id`) for a stable total order (see fetch-all.ts) and do
// the chronological sort here, mirroring `generateGeneralLedger`.
const rows = await fetchAllRows<{
id: string
debit_amount: number
credit_amount: number
// eslint-disable-next-line @typescript-eslint/no-explicit-any
journal_entries: any
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select(`
id,
debit_amount,
credit_amount,
journal_entry_id,
journal_entries!inner(
id,
voucher_number,
voucher_series,
entry_date,
description,
status,
company_id,
fiscal_period_id
)
`)
.eq('account_number', accountNumber)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.in('journal_entries.status', ['posted', 'reversed'])
.order('id', { ascending: true })
.range(from, to), { dedupeBy: (r) => r.id })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = (data || []) as any[]
// Map all rows then sort in JS (date ASC, voucher_number ASC).
// .order({ foreignTable }) in Supabase sorts the embedded resource's rows,
// not the parent result set, so we cannot rely on DB ordering here.
// This mirrors the sort in generateGeneralLedger.
// Map then sort in JS (date ASC, voucher_number ASC, journal_entry_id ASC as
// a final deterministic tiebreak for lines sharing a date and voucher number
// across series).
const allMapped: ReportSourceLine[] = rows.map((row) => ({
journal_entry_id: row.journal_entries.id,
voucher_number: row.journal_entries.voucher_number,
@@ -120,14 +127,26 @@ export async function GET(
}))
allMapped.sort((a, b) => {
const dateComp = a.date.localeCompare(b.date)
return dateComp !== 0 ? dateComp : a.voucher_number - b.voucher_number
if (dateComp !== 0) return dateComp
if (a.voucher_number !== b.voucher_number) return a.voucher_number - b.voucher_number
return a.journal_entry_id.localeCompare(b.journal_entry_id)
})
const lines = allMapped.slice(0, PAGE_LIMIT)
// If we got more than PAGE_LIMIT rows back, the next cursor points at the
// last delivered row so the next call resumes from after it.
// Apply the cursor in JS: keep rows strictly after (date, voucher_number).
const afterCursor = cursorDate
? allMapped.filter(
(l) =>
l.date > cursorDate! ||
(l.date === cursorDate! && l.voucher_number > cursorVoucherNum)
)
: allMapped
const lines = afterCursor.slice(0, PAGE_LIMIT)
// If more rows remain beyond this page, point the next cursor at the last
// delivered row so the next call resumes from after it.
let next_cursor: string | null = null
if (rows.length > PAGE_LIMIT && lines.length > 0) {
if (afterCursor.length > PAGE_LIMIT && lines.length > 0) {
const last = lines[lines.length - 1]
next_cursor = `${last.date}|${last.voucher_number}`
}
@@ -32,6 +32,8 @@ function buildSupabase(
limit: vi.fn().mockReturnThis(),
or: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
// journal_entry_lines terminates on `.range()` (fetchAllRows).
range: vi.fn().mockResolvedValue(linesResult),
then: (resolve: (v: unknown) => void) => resolve(linesResult),
})),
}
@@ -117,4 +119,95 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => {
expect(body.data.lines[0].voucher_number).toBe(12)
expect(body.data.lines[0].credit).toBe(250)
})
it('returns 400 when the cursor date component is not a structural ISO date', async () => {
// Defense-in-depth (ASVS V1.2): the cursor is applied in JS, but a
// malformed date component must still be rejected structurally.
mockCreateClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, { data: [], error: null }) as never
)
const req = createMockRequest(
'/api/reports/vat-declaration/ruta/10/sources',
{
searchParams: {
periodType: 'monthly',
year: '2026',
period: '5',
cursor: 'notadate|5',
},
}
)
const res = await GET(req, createMockRouteParams({ ruta: '10' }))
expect(res.status).toBe(400)
})
it('sorts lines by entry_date ASC then voucher_number ASC regardless of DB return order', async () => {
// Regression: this endpoint relied on `.order({ foreignTable })`, which
// sorts the embedded resource — not the parent — so lines came back in
// arbitrary order and the drill-down showed "different rows on reload".
const linesData = [
{
account_number: '2611',
debit_amount: 0,
credit_amount: 500,
journal_entries: {
id: 'je-late',
voucher_number: 30,
voucher_series: 'A',
entry_date: '2026-05-20',
description: 'Late',
status: 'posted',
company_id: 'company-1',
},
},
{
account_number: '2611',
debit_amount: 0,
credit_amount: 100,
journal_entries: {
id: 'je-early',
voucher_number: 4,
voucher_series: 'A',
entry_date: '2026-05-02',
description: 'Early',
status: 'posted',
company_id: 'company-1',
},
},
{
account_number: '2611',
debit_amount: 0,
credit_amount: 250,
journal_entries: {
id: 'je-mid',
voucher_number: 18,
voucher_series: 'A',
entry_date: '2026-05-11',
description: 'Mid',
status: 'posted',
company_id: 'company-1',
},
},
]
mockCreateClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, { data: linesData, error: null }) as never
)
const req = createMockRequest(
'/api/reports/vat-declaration/ruta/10/sources',
{ searchParams: { periodType: 'monthly', year: '2026', period: '5' } }
)
const res = await GET(req, createMockRouteParams({ ruta: '10' }))
expect(res.status).toBe(200)
const body = (await res.json()) as {
data: { lines: Array<{ journal_entry_id: string }> }
}
expect(body.data.lines.map((l) => l.journal_entry_id)).toEqual([
'je-early',
'je-mid',
'je-late',
])
})
})
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
ACCOUNT_RUTA,
calculatePeriodDates,
@@ -93,66 +94,94 @@ export async function GET(
end = dates.end
}
let query = supabase
.from('journal_entry_lines')
.select(`
account_number,
debit_amount,
credit_amount,
journal_entries!inner(
id,
voucher_number,
voucher_series,
entry_date,
description,
status,
company_id
)
`)
.in('account_number', accountsForRuta)
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
.gte('journal_entries.entry_date', start)
.lte('journal_entries.entry_date', end)
.order('entry_date', { foreignTable: 'journal_entries', ascending: true })
.order('voucher_number', { foreignTable: 'journal_entries', ascending: true })
.limit(PAGE_LIMIT + 1)
// Parse the optional cursor up front (format: <iso-date>|<voucher_number>).
// Pagination is applied in JS after a full, deterministically-ordered fetch.
let cursorDate: string | null = null
let cursorVoucherNum = 0
if (cursor) {
const [cursorDate, cursorVoucher] = cursor.split('|')
const cursorVoucherNum = parseInt(cursorVoucher, 10)
if (!cursorDate || isNaN(cursorVoucherNum)) {
const [cd, cv] = cursor.split('|')
cursorVoucherNum = parseInt(cv, 10)
// The cursor is applied in JS (string compare); structurally validating the
// date component here is defense-in-depth against malformed/injection cursors.
if (!cd || !/^\d{4}-\d{2}-\d{2}$/.test(cd) || isNaN(cursorVoucherNum)) {
return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 })
}
query = query.or(
`entry_date.gt.${cursorDate},and(entry_date.eq.${cursorDate},voucher_number.gt.${cursorVoucherNum})`,
{ foreignTable: 'journal_entries' }
)
cursorDate = cd
}
const { data, error } = await query
// Pull ALL contributing lines, then sort + paginate in JS.
//
// Why not order/limit in SQL: `.order(col, { foreignTable })` in PostgREST
// sorts the *embedded* resource's rows, not the parent result set, so it
// cannot give us a chronological parent order. Without a stable parent order
// a raw `.limit()` returns an arbitrary subset that varies between identical
// requests, making the drill-down show "different rows on every reload". We
// page on the line PK (`id`) for a stable total order (see fetch-all.ts) and
// do the chronological sort here, mirroring `generateGeneralLedger` and the
// trial-balance sources route.
const rows = await fetchAllRows<{
id: string
debit_amount: number
credit_amount: number
// eslint-disable-next-line @typescript-eslint/no-explicit-any
journal_entries: any
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select(`
id,
account_number,
debit_amount,
credit_amount,
journal_entries!inner(
id,
voucher_number,
voucher_series,
entry_date,
description,
status,
company_id
)
`)
.in('account_number', accountsForRuta)
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
.gte('journal_entries.entry_date', start)
.lte('journal_entries.entry_date', end)
.order('id', { ascending: true })
.range(from, to), { dedupeBy: (r) => r.id })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Map then sort in JS (date ASC, voucher_number ASC, journal_entry_id ASC as
// a final deterministic tiebreak).
const allMapped: ReportSourceLine[] = rows.map((row) => ({
journal_entry_id: row.journal_entries.id,
voucher_number: row.journal_entries.voucher_number,
voucher_series: row.journal_entries.voucher_series || 'A',
date: row.journal_entries.entry_date,
description: row.journal_entries.description || '',
debit: Math.round((Number(row.debit_amount) || 0) * 100) / 100,
credit: Math.round((Number(row.credit_amount) || 0) * 100) / 100,
}))
allMapped.sort((a, b) => {
const dateComp = a.date.localeCompare(b.date)
if (dateComp !== 0) return dateComp
if (a.voucher_number !== b.voucher_number) return a.voucher_number - b.voucher_number
return a.journal_entry_id.localeCompare(b.journal_entry_id)
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = (data || []) as any[]
// Apply the cursor in JS: keep rows strictly after (date, voucher_number).
const afterCursor = cursorDate
? allMapped.filter(
(l) =>
l.date > cursorDate! ||
(l.date === cursorDate! && l.voucher_number > cursorVoucherNum)
)
: allMapped
const lines: ReportSourceLine[] = rows
.slice(0, PAGE_LIMIT)
.map((row) => ({
journal_entry_id: row.journal_entries.id,
voucher_number: row.journal_entries.voucher_number,
voucher_series: row.journal_entries.voucher_series || 'A',
date: row.journal_entries.entry_date,
description: row.journal_entries.description || '',
debit: Math.round((Number(row.debit_amount) || 0) * 100) / 100,
credit: Math.round((Number(row.credit_amount) || 0) * 100) / 100,
}))
const lines = afterCursor.slice(0, PAGE_LIMIT)
let next_cursor: string | null = null
if (rows.length > PAGE_LIMIT && lines.length > 0) {
if (afterCursor.length > PAGE_LIMIT && lines.length > 0) {
const last = lines[lines.length - 1]
next_cursor = `${last.date}|${last.voucher_number}`
}
+230
View File
@@ -0,0 +1,230 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Search, X, Loader2, MessageSquare, Pencil } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import {
type ConversationRow,
BUCKET_LABELS,
relativeTime,
intentLabel,
groupConversations,
} from './conversation-display'
interface Props {
// Highlight the row for the conversation currently open in the sheet.
activeConversationId?: string | null
// Fired when the user picks a conversation to resume. The sheet fetches its
// messages and swaps back to the chat view — the list itself stays dumb.
onSelect: (id: string) => void
}
// In-sheet conversation picker. Renders the same grouped/searchable list as the
// /chat sidebar (shared helpers in conversation-display.ts), but instead of
// navigating to /chat/[id] it hands the id back so the conversation opens
// inline in the sheet and the user keeps chatting without leaving the page.
// Rows are renameable inline (PATCH /api/agent/conversations/[id]).
export default function AgentSessionList({ activeConversationId, onSelect }: Props) {
const [conversations, setConversations] = useState<ConversationRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
// Set by Esc so the blur that fires when the input unmounts doesn't save.
const cancelRef = useRef(false)
const { toast } = useToast()
useEffect(() => {
let cancelled = false
void (async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/agent/conversations?limit=100')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = (await res.json()) as { data?: ConversationRow[] }
if (!cancelled) setConversations(json.data ?? [])
} catch {
if (!cancelled) setError('Kunde inte hämta konversationer.')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return conversations
return conversations.filter(
(c) =>
(c.title ?? '').toLowerCase().includes(q) ||
(c.last_message_preview ?? '').toLowerCase().includes(q) ||
(c.context_ref ?? '').toLowerCase().includes(q) ||
c.intent_id.toLowerCase().includes(q),
)
}, [conversations, query])
const grouped = useMemo(() => groupConversations(filtered), [filtered])
function startEdit(c: ConversationRow) {
setEditingId(c.id)
setEditValue(c.title ?? '')
cancelRef.current = false
}
function cancelEdit() {
cancelRef.current = true
setEditingId(null)
}
async function commitEdit(id: string) {
if (cancelRef.current) {
cancelRef.current = false
return
}
setEditingId(null)
const title = editValue.trim()
const current = conversations.find((c) => c.id === id)
if (!title || title === current?.title) return
// Capture the pre-rename title so we can roll back if the PATCH fails.
const previousTitle = current?.title ?? null
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)))
try {
const res = await fetch(`/api/agent/conversations/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
} catch {
// Revert the optimistic rename so the list stays in sync with the server.
setConversations((prev) =>
prev.map((c) => (c.id === id ? { ...c, title: previousTitle } : c)),
)
toast({
variant: 'destructive',
title: 'Kunde inte byta namn på konversationen.',
})
}
}
return (
<div className="flex flex-1 flex-col min-h-0">
<div className="border-b border-border px-5 py-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Sök konversationer…"
className="w-full rounded-md border border-border bg-background pl-8 pr-7 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{query.length > 0 && (
<button
type="button"
onClick={() => setQuery('')}
aria-label="Rensa sökning"
className="absolute right-1 top-1/2 -translate-y-1/2 inline-flex h-8 w-8 items-center justify-center rounded text-muted-foreground hover:bg-secondary hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Hämtar
</div>
) : error ? (
<div className="p-6 text-sm text-destructive">{error}</div>
) : grouped.length === 0 ? (
<div className="flex flex-col items-center gap-2 p-10 text-center text-sm text-muted-foreground">
<MessageSquare className="h-6 w-6 opacity-40" />
{conversations.length === 0 ? 'Inga konversationer ännu.' : 'Inga träffar.'}
</div>
) : (
grouped.map(({ bucket, rows }) => (
<section key={bucket} className="py-2">
<p className="px-4 pb-1 text-[10px] uppercase tracking-wider text-muted-foreground">
{BUCKET_LABELS[bucket]}
</p>
<ul className="space-y-1">
{rows.map((c) => (
<li key={c.id}>
{editingId === c.id ? (
<div className="px-4 py-2">
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => void commitEdit(c.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
;(e.target as HTMLInputElement).blur()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelEdit()
}
}}
placeholder="Namnge konversationen…"
maxLength={200}
aria-label="Nytt namn på konversationen"
className="w-full rounded-md border border-border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
) : (
<div
className={cn(
'group flex items-stretch border-l-2 transition-colors',
activeConversationId === c.id
? 'bg-secondary/50 border-foreground'
: 'border-transparent hover:bg-secondary/60',
)}
>
<button
type="button"
onClick={() => onSelect(c.id)}
className="flex flex-1 min-w-0 items-start gap-2 px-4 py-2 text-left"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium truncate flex-1 min-w-0">
{c.title ?? intentLabel(c.intent_id)}
</p>
<p className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{relativeTime(c.last_message_at ?? c.created_at)}
</p>
</div>
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
{c.last_message_preview ?? intentLabel(c.intent_id)}
</p>
</div>
</button>
<button
type="button"
onClick={() => startEdit(c)}
title="Byt namn"
aria-label="Byt namn på konversation"
className="shrink-0 flex w-10 items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
)}
</li>
))}
</ul>
</section>
))
)}
</div>
</div>
)
}
+208 -26
View File
@@ -1,13 +1,14 @@
'use client'
import { useEffect, useState } from 'react'
import { X, Expand } from 'lucide-react'
import Link from 'next/link'
import AgentChat from './AgentChat'
import { X, Expand, Shrink, PanelRightClose, Eraser, History, ChevronLeft, Loader2 } from 'lucide-react'
import AgentChat, { normalizeStoredMessages, type ChatMessage } from './AgentChat'
import AgentAvatar from './AgentAvatar'
import AgentSessionList from './AgentSessionList'
import SandboxAgentPreview from './SandboxAgentPreview'
import { useAgentSheet } from './AgentSheetProvider'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { cn } from '@/lib/utils'
// Undimmed non-modal side sheet — sits above the page on a hairline border +
// shadow, but the page underneath stays fully interactive. Plan §3b.
@@ -21,72 +22,251 @@ interface Props {
intentArgs?: Record<string, unknown>
contextRef?: string
seedUserMessage?: string
// Hidden (display:none) but still mounted so the conversation survives. The
// provider keeps rendering this component; we just visually remove it.
collapsed: boolean
onCollapse: () => void
onRestart: () => void
onClose: () => void
}
interface LoadedConversation {
id: string
intentId: string
contextRef: string | null
title: string | null
messages: ChatMessage[]
}
export default function AgentSheet({
intentId,
intentArgs,
contextRef,
seedUserMessage,
collapsed,
onCollapse,
onRestart,
onClose,
}: Props) {
// Live conversation id from the active AgentChat (fresh sessions report it via
// onConversationIdChange; resumed ones we set directly on select).
const [conversationId, setConversationId] = useState<string | null>(null)
// 'chat' shows the conversation; 'list' shows the session picker.
const [view, setView] = useState<'chat' | 'list'>('chat')
// A past conversation the user picked from the list, hydrated for resume. When
// set, it replaces the intent-driven fresh chat.
const [loaded, setLoaded] = useState<LoadedConversation | null>(null)
const [loadingConversation, setLoadingConversation] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
// Enlarge the panel IN PLACE (no navigation) — the user stays on the current
// page (e.g. /bookkeeping) with a wider reading/verifying surface.
const [expanded, setExpanded] = useState(false)
const { identity } = useAgentSheet()
const companyCtx = useCompanyOptional()
const isSandbox = companyCtx?.isSandbox ?? false
const agentName = identity.displayName?.trim() || null
const sheetTitle = intentToTitle(intentId, agentName)
const displayTitle = loaded ? (loaded.title ?? intentToTitle(loaded.intentId, agentName)) : sheetTitle
const activeConversationId = loaded?.id ?? conversationId
// Esc closes the sheet.
// Esc: back out of the session list first, otherwise close. Never while
// collapsed (the sheet is hidden off-screen, so Esc belongs elsewhere).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (collapsed || e.key !== 'Escape') return
if (view === 'list') setView('chat')
else onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
}, [onClose, collapsed, view])
// Move focus off the sheet before hiding it, so it never sits on a
// display:none node (accessibility).
const handleCollapse = () => {
if (typeof document !== 'undefined') {
;(document.activeElement as HTMLElement | null)?.blur()
}
onCollapse()
}
// Resume a past conversation inline: fetch its messages, hydrate, and swap the
// sheet back to the chat view. Picking the one already open just closes the
// list (keeps its live in-memory state instead of re-hydrating it).
async function handleSelectConversation(id: string) {
if (id === activeConversationId) {
setView('chat')
return
}
setView('chat')
setLoaded(null)
setLoadingConversation(true)
setLoadError(null)
try {
const res = await fetch(`/api/agent/conversations/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = (await res.json()) as {
data?: {
conversation: {
id: string
intent_id: string
context_ref: string | null
title: string | null
}
messages: { role: string; content: unknown; hidden?: boolean | null }[]
}
}
const data = json.data
if (!data) throw new Error('missing data')
setLoaded({
id: data.conversation.id,
intentId: data.conversation.intent_id,
contextRef: data.conversation.context_ref,
title: data.conversation.title,
messages: normalizeStoredMessages(data.messages),
})
setConversationId(data.conversation.id)
} catch {
setLoadError('Kunde inte öppna konversationen.')
} finally {
setLoadingConversation(false)
}
}
return (
<div
role="dialog"
aria-label={sheetTitle}
aria-label={displayTitle}
// z-[60] sits above the mobile bottom nav (z-50) so on phones the sheet
// covers the full screen including where the nav would otherwise show.
className="fixed inset-y-0 right-0 z-[60] flex w-full max-w-[480px] flex-col border-l border-border bg-background shadow-lg"
// `hidden` (display:none) when collapsed keeps the component mounted — the
// conversation state in AgentChat survives — while removing it from view
// and layout entirely (no stray horizontal scroll from an off-screen box).
className={cn(
'fixed inset-y-0 right-0 z-[60] flex w-full flex-col border-l border-border bg-background shadow-lg transition-[max-width] duration-200 ease-out',
collapsed && 'hidden',
// Expanded grows the panel leftward over the page (still non-modal — the
// page stays interactive); normal is the compact side sheet.
expanded ? 'max-w-[min(100vw,1100px)]' : 'max-w-[480px]',
)}
style={{
// iOS notch / Android cutout — the sheet top edge needs to clear the
// status bar. Bottom is handled inside the form below.
paddingTop: 'env(safe-area-inset-top, 0px)',
}}
>
<header className="flex items-center gap-3 border-b border-border px-5 py-4">
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName ?? 'Assistent'} />
<h2 className="font-display text-lg tracking-tight truncate">{sheetTitle}</h2>
<div className="ml-auto flex items-center gap-1">
{conversationId && !isSandbox && (
<Link
href={`/chat/${conversationId}`}
onClick={onClose}
className="h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Öppna i fullskärm"
title="Öppna i fullskärm"
>
<Expand className="h-4 w-4" />
</Link>
)}
{view === 'list' ? (
<header className="flex items-center gap-3 border-b border-border px-5 py-4">
<button
onClick={() => setView('chat')}
className="h-9 w-9 -ml-1 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Tillbaka"
title="Tillbaka"
>
<ChevronLeft className="h-4 w-4" />
</button>
<h2 className="font-display text-lg tracking-tight truncate">Konversationer</h2>
<button
onClick={onClose}
className="h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
className="ml-auto h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Stäng"
title="Avsluta sessionen"
>
<X className="h-4 w-4" />
</button>
</div>
</header>
</header>
) : (
<header className="flex items-center gap-2 border-b border-border px-4 py-4">
{!isSandbox && (
<button
onClick={() => setView('list')}
className="h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Tidigare konversationer"
title="Tidigare konversationer"
>
<History className="h-4 w-4" />
</button>
)}
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName ?? 'Assistent'} />
<h2 className="font-display text-lg tracking-tight truncate">{displayTitle}</h2>
<div className="ml-auto flex items-center gap-1">
{/* Grow/shrink the panel in place — NEVER navigates away, so the
user stays on the current page. Hidden on mobile where the sheet
is already full-width (the toggle would be a no-op). */}
{!isSandbox && (
<button
onClick={() => setExpanded((v) => !v)}
className="hidden md:inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label={expanded ? 'Förminska' : 'Förstora'}
title={expanded ? 'Förminska' : 'Förstora'}
>
{expanded ? <Shrink className="h-4 w-4" /> : <Expand className="h-4 w-4" />}
</button>
)}
{/* Labeled (not icon-only) so it isn't mistaken for close/minimize —
and gated on an existing conversation so there's nothing to
mis-click on a fresh, empty chat. */}
{activeConversationId && !isSandbox && (
<button
onClick={onRestart}
className="h-9 inline-flex items-center gap-2 rounded-md px-2 text-xs font-medium text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Rensa — börja en ny konversation"
title="Rensa — börja en ny konversation"
>
<Eraser className="h-4 w-4" />
Rensa
</button>
)}
<button
onClick={handleCollapse}
className="h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Minimera"
title="Minimera — behåll sessionen"
>
<PanelRightClose className="h-4 w-4" />
</button>
<button
onClick={onClose}
className="h-9 w-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label="Stäng"
title="Avsluta sessionen"
>
<X className="h-4 w-4" />
</button>
</div>
</header>
)}
{isSandbox ? (
<SandboxAgentPreview agentName={agentName} />
) : view === 'list' ? (
<AgentSessionList
activeConversationId={activeConversationId}
onSelect={handleSelectConversation}
/>
) : loadingConversation ? (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Öppnar konversation
</div>
) : loadError ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center text-sm">
<p className="text-destructive">{loadError}</p>
<button
onClick={() => setView('list')}
className="text-xs font-medium text-foreground hover:underline"
>
Tillbaka till konversationer
</button>
</div>
) : loaded ? (
<AgentChat
key={loaded.id}
intentId={loaded.intentId}
contextRef={loaded.contextRef ?? undefined}
initialConversationId={loaded.id}
initialMessages={loaded.messages}
onConversationIdChange={(id) => setConversationId(id)}
/>
) : (
<AgentChat
intentId={intentId}
@@ -106,6 +286,8 @@ function intentToTitle(intentId: string, agentName: string | null): string {
return agentName ? `Fråga ${agentName}` : 'Fråga din assistent'
case 'transaction.categorization':
return 'Hjälp med transaktion'
case 'verifikation.draft':
return 'Hjälp med verifikation'
case 'invoice.draft':
return 'Hjälp med faktura'
case 'supplier_invoice.review':
+50 -4
View File
@@ -38,7 +38,20 @@ export interface OpenAgentSheetArgs {
interface AgentSheetContextValue {
openAgentSheet: (args: OpenAgentSheetArgs) => void
closeAgentSheet: () => void
// Collapse hides the sheet WITHOUT unmounting it, so the in-memory
// conversation (messages, streaming, pending approval cards) survives — the
// floating trigger re-expands the same session. Distinct from close, which
// ends the session entirely.
collapseAgentSheet: () => void
expandAgentSheet: () => void
// Discard the current thread and start a fresh conversation on the same
// intent (the header "Ny konversation" control). Implemented by remounting
// the sheet via a nonce in its key.
restartAgentSheet: () => void
// True while a session exists (open or collapsed).
isOpen: boolean
// True while a session exists but is minimized off-screen.
collapsed: boolean
// Agent name + avatar — set once from the server-loaded agent_profile
// and exposed through context so the trigger / chat headers can render
// them without their own fetches. Null when the user hasn't verified a
@@ -55,26 +68,56 @@ interface AgentSheetProviderProps {
export function AgentSheetProvider({ children, identity }: AgentSheetProviderProps) {
const [activeArgs, setActiveArgs] = useState<OpenAgentSheetArgs | null>(null)
// Collapsed = session alive but hidden. Kept separate from activeArgs so
// collapsing never unmounts AgentChat (which would wipe the conversation).
const [collapsed, setCollapsed] = useState(false)
// Bumped by restartAgentSheet to force a fresh AgentChat mount (a new thread)
// on the same intent, without closing the sheet.
const [restartNonce, setRestartNonce] = useState(0)
const openAgentSheet = useCallback((args: OpenAgentSheetArgs) => {
setActiveArgs(args)
setCollapsed(false)
}, [])
const closeAgentSheet = useCallback(() => {
setActiveArgs(null)
setCollapsed(false)
}, [])
const resolvedIdentity: AgentIdentity =
identity ?? { displayName: null, avatarId: null, isVerified: false }
const collapseAgentSheet = useCallback(() => setCollapsed(true), [])
const expandAgentSheet = useCallback(() => setCollapsed(false), [])
const restartAgentSheet = useCallback(() => {
setRestartNonce((n) => n + 1)
setCollapsed(false)
}, [])
const resolvedIdentity = useMemo<AgentIdentity>(
() => identity ?? { displayName: null, avatarId: null, isVerified: false },
[identity],
)
const value = useMemo<AgentSheetContextValue>(
() => ({
openAgentSheet,
closeAgentSheet,
collapseAgentSheet,
expandAgentSheet,
restartAgentSheet,
isOpen: activeArgs !== null,
collapsed,
identity: resolvedIdentity,
}),
[openAgentSheet, closeAgentSheet, activeArgs, resolvedIdentity],
[
openAgentSheet,
closeAgentSheet,
collapseAgentSheet,
expandAgentSheet,
restartAgentSheet,
activeArgs,
collapsed,
resolvedIdentity,
],
)
return (
@@ -82,11 +125,14 @@ export function AgentSheetProvider({ children, identity }: AgentSheetProviderPro
{children}
{activeArgs && (
<AgentSheet
key={`${activeArgs.intentId}:${activeArgs.contextRef ?? ''}:${activeArgs.seedUserMessage ?? ''}`}
key={`${activeArgs.intentId}:${activeArgs.contextRef ?? ''}:${activeArgs.seedUserMessage ?? ''}:${restartNonce}`}
intentId={activeArgs.intentId}
intentArgs={activeArgs.intentArgs}
contextRef={activeArgs.contextRef}
seedUserMessage={activeArgs.seedUserMessage}
collapsed={collapsed}
onCollapse={collapseAgentSheet}
onRestart={restartAgentSheet}
onClose={closeAgentSheet}
/>
)}
+45 -26
View File
@@ -27,21 +27,28 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
// "Fråga assistenten" in Dokumentinkorgen — both passing a transaction_id the
// pathname-only FAB can't know.)
export default function AgentTrigger() {
const { openAgentSheet, isOpen, identity } = useAgentSheet()
const { openAgentSheet, expandAgentSheet, isOpen, collapsed, identity } = useAgentSheet()
const pathname = usePathname()
const router = useRouter()
const hasAi = useCapability(CAPABILITY.ai)
if (isOpen) return null
// The /chat surface IS the chat — a floating "Fråga …" pill on top of it
// is redundant and overlaps the input. Suppress while the user is here.
if (pathname?.startsWith('/chat')) return null
// The verifikation editor is a dense regulatory surface (debits/credits,
// BAS codes, period locks) — a floating "Fråga … om denna verifikation"
// pill on top of it adds noise without earning its place. Suppress on
// /bookkeeping/[id] specifically; /bookkeeping (list), /bookkeeping/new,
// and /bookkeeping/year-end still get the FAB.
{
// Sheet open AND visible → hide the FAB so the icon doesn't double up. When
// the session is merely collapsed we KEEP the FAB — it's the handle that
// brings the minimized conversation back.
if (isOpen && !collapsed) return null
// The page-suppression rules below apply only to a FRESH open. A collapsed
// session always gets its reopen handle, regardless of page — otherwise a
// conversation minimized on /chat or /bookkeeping/[id] could never be
// brought back.
if (!collapsed) {
// The /chat surface IS the chat — a floating "Fråga …" pill on top of it
// is redundant and overlaps the input. Suppress while the user is here.
if (pathname?.startsWith('/chat')) return null
// The verifikation editor is a dense regulatory surface (debits/credits,
// BAS codes, period locks) — a floating "Fråga … om denna verifikation"
// pill on top of it adds noise without earning its place. Suppress on
// /bookkeeping/[id] specifically; /bookkeeping (list), /bookkeeping/new,
// and /bookkeeping/year-end still get the FAB.
const segs = pathname?.split('/').filter(Boolean) ?? []
if (segs[0] === 'bookkeeping' && segs[1] && segs[1] !== 'year-end' && segs[1] !== 'new') {
return null
@@ -49,7 +56,8 @@ export default function AgentTrigger() {
}
// Pre-onboarding: no agent_profile.verified_at yet. The FAB would lead
// into a generic chat with no specialization. Better to hide it until
// the user has finished /onboarding/agent.
// the user has finished /onboarding/agent. (A collapsed session implies the
// agent is already in use, so this only gates fresh opens in practice.)
if (!identity.isVerified) return null
const name = identity.displayName?.trim() || 'min assistent'
@@ -57,23 +65,34 @@ export default function AgentTrigger() {
// AI assistant runs on a paid cloud service. Without the capability, opening
// the sheet would land the user in a chat whose send is dead. Keep the FAB
// visible (it's the conversion surface) but route it to billing instead.
const labelText = !hasAi
? `Uppgradera för att använda ${name}`
: dispatch.labelSuffix
? `Fråga ${name} ${dispatch.labelSuffix}`
: `Fråga ${name}`
const labelText = collapsed
? `Fortsätt med ${name}`
: !hasAi
? `Uppgradera för att använda ${name}`
: dispatch.labelSuffix
? `Fråga ${name} ${dispatch.labelSuffix}`
: `Fråga ${name}`
const handleClick = () => {
// Collapsed → bring the existing session back, don't start a new one.
if (collapsed) {
expandAgentSheet()
return
}
if (!hasAi) {
router.push('/settings/billing')
return
}
openAgentSheet({
intentId: dispatch.intentId,
intentArgs: dispatch.intentArgs,
contextRef: dispatch.contextRef,
})
}
return (
<button
onClick={() =>
!hasAi
? router.push('/settings/billing')
: openAgentSheet({
intentId: dispatch.intentId,
intentArgs: dispatch.intentArgs,
contextRef: dispatch.contextRef,
})
}
onClick={handleClick}
// Mobile: sit above the bottom nav (h-16 = 64px) AND the iOS home
// indicator (env(safe-area-inset-bottom)). Desktop: standard 20px lift,
// no mobile nav to worry about.
+28
View File
@@ -72,6 +72,12 @@ interface CommitResultData {
invoice_id?: string | null
customer_id?: string | null
supplier_invoice_id?: string | null
// bulk_book_inbox_items creates N verifikationer, not one artifact — the
// executor returns per-item counts instead of a single id. Surfaced as a
// "N bokförda" summary + a link to the ledger (or the sole verifikat).
booked_count?: number
skipped_count?: number
booked?: Array<{ journal_entry_id?: string | null }>
}
export default function ApprovalCard({
@@ -234,6 +240,22 @@ export default function ApprovalCard({
label: 'Öppna kund',
}
}
// Bulk operations (bulk_book_inbox_items) book N underlag at once and return
// counts instead of a single id. Show the outcome ("N bokförda · M
// överhoppade") — a bulk commit silently skips non-bookable items, so
// without this the user can't tell whether anything was booked — and link to
// the ledger list, or straight to the sole verifikat when exactly one landed.
const bulkSummary =
typeof commitResult?.booked_count === 'number'
? { booked: commitResult.booked_count, skipped: commitResult.skipped_count ?? 0 }
: null
if (bulkSummary && !deepLink) {
const soleEntryId =
bulkSummary.booked === 1 ? commitResult?.booked?.[0]?.journal_entry_id : null
deepLink = soleEntryId
? { href: `/bookkeeping/${soleEntryId}`, label: 'Öppna verifikation' }
: { href: '/bookkeeping', label: 'Öppna bokföringen' }
}
// The server's `message` field (e.g. "Operation staged for review …
// Open the Accounted web app to approve or reject it.") was written for
// MCP clients without an inline approval surface. Inside the in-app
@@ -248,6 +270,12 @@ export default function ApprovalCard({
<p className="flex items-center gap-2 font-medium">
<Check className="h-4 w-4" /> Godkänt
</p>
{bulkSummary && (
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
{bulkSummary.booked} {bulkSummary.booked === 1 ? 'underlag bokfört' : 'underlag bokförda'}
{bulkSummary.skipped > 0 ? ` · ${bulkSummary.skipped} överhoppade` : ''}
</p>
)}
{deepLink && (
<Link
href={deepLink.href}
+139 -154
View File
@@ -1,73 +1,24 @@
'use client'
import { useEffect, useMemo, useState, useTransition } from 'react'
import { useEffect, useMemo, useRef, useState, useTransition } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { Pin, PinOff, Archive, Search, X, PanelLeftOpen, PanelLeftClose } from 'lucide-react'
import { Pin, PinOff, Archive, Pencil, Search, X, PanelLeftOpen, PanelLeftClose } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAgentSheet } from './AgentSheetProvider'
import AgentAvatar from './AgentAvatar'
interface ConversationRow {
id: string
intent_id: string
context_ref: string | null
title: string | null
pinned: boolean
archived: boolean
last_message_at: string | null
last_message_preview: string | null
created_at: string
}
import {
type ConversationRow,
BUCKET_LABELS,
relativeTime,
intentLabel,
groupConversations,
} from './conversation-display'
interface Props {
initialConversations: ConversationRow[]
}
// Time buckets for date grouping. Computed once per render against now().
// Mirrors the Idag / Igår / Denna vecka / Äldre pattern users know from
// Mail and iMessage.
type DateBucket = 'pinned' | 'today' | 'yesterday' | 'thisWeek' | 'older'
const BUCKET_LABELS: Record<DateBucket, string> = {
pinned: 'Fästade',
today: 'Idag',
yesterday: 'Igår',
thisWeek: 'Denna vecka',
older: 'Äldre',
}
function bucketFor(c: ConversationRow): DateBucket {
if (c.pinned) return 'pinned'
const when = c.last_message_at ?? c.created_at
if (!when) return 'older'
const t = new Date(when)
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000)
const weekStart = new Date(todayStart.getTime() - 6 * 24 * 60 * 60 * 1000)
if (t >= todayStart) return 'today'
if (t >= yesterdayStart) return 'yesterday'
if (t >= weekStart) return 'thisWeek'
return 'older'
}
// Compact relative-time label shown to the right of each row. Locale-tuned
// to feel native in Swedish without going full date-fns.
function relativeTime(iso: string | null | undefined): string {
if (!iso) return ''
const t = new Date(iso).getTime()
const now = Date.now()
const diffMin = Math.round((now - t) / 60000)
if (diffMin < 1) return 'nu'
if (diffMin < 60) return `${diffMin} min`
const diffHr = Math.round(diffMin / 60)
if (diffHr < 24) return `${diffHr} h`
const diffDay = Math.round(diffHr / 24)
if (diffDay < 7) return `${diffDay} d`
return new Date(iso).toLocaleDateString('sv-SE', { month: 'short', day: 'numeric' })
}
export default function ChatSidebar({ initialConversations }: Props) {
const router = useRouter()
const pathname = usePathname()
@@ -110,20 +61,7 @@ export default function ChatSidebar({ initialConversations }: Props) {
// Group filtered into ordered buckets, preserving the sort order already
// applied server-side (pinned first, then last_message_at desc).
const grouped = useMemo(() => {
const buckets: Record<DateBucket, ConversationRow[]> = {
pinned: [],
today: [],
yesterday: [],
thisWeek: [],
older: [],
}
for (const c of filtered) buckets[bucketFor(c)].push(c)
const order: DateBucket[] = ['pinned', 'today', 'yesterday', 'thisWeek', 'older']
return order
.map((b) => ({ bucket: b, rows: buckets[b] }))
.filter((g) => g.rows.length > 0)
}, [filtered])
const grouped = useMemo(() => groupConversations(filtered), [filtered])
async function togglePin(id: string, current: boolean) {
setConversations((prev) =>
@@ -146,6 +84,38 @@ export default function ChatSidebar({ initialConversations }: Props) {
if (activeId === id) startTransition(() => router.push('/chat'))
}
// Inline rename of a conversation's title (PATCH /api/agent/conversations/[id]).
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
// Set by Esc so the blur that fires when the input unmounts doesn't save.
const cancelRef = useRef(false)
function startEdit(c: ConversationRow) {
setEditingId(c.id)
setEditValue(c.title ?? '')
cancelRef.current = false
}
function cancelEdit() {
cancelRef.current = true
setEditingId(null)
}
async function commitEdit(id: string) {
if (cancelRef.current) {
cancelRef.current = false
return
}
setEditingId(null)
const title = editValue.trim()
const current = conversations.find((c) => c.id === id)
if (!title || title === current?.title) return
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)))
await fetch(`/api/agent/conversations/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
}
// Collapsed rail (desktop only). Mobile keeps the existing behavior where
// the sidebar IS the page when no conversation is open, so the rail is
// hidden below md. On desktop the rail keeps a thin column with toggle
@@ -248,69 +218,107 @@ export default function ChatSidebar({ initialConversations }: Props) {
<p className="px-4 pb-1 text-[10px] uppercase tracking-wider text-muted-foreground">
{BUCKET_LABELS[bucket]}
</p>
<ul>
<ul className="space-y-1">
{rows.map((c) => (
<li key={c.id}>
<Link
href={`/chat/${c.id}`}
className={cn(
'group flex items-start gap-2 px-4 py-2 hover:bg-secondary/60 transition-colors border-l-2',
activeId === c.id
? 'bg-secondary/50 border-foreground'
: 'border-transparent',
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium truncate flex-1 min-w-0">
{c.title ?? intentLabel(c.intent_id)}
</p>
<p className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{relativeTime(c.last_message_at ?? c.created_at)}
{editingId === c.id ? (
<div className="px-4 py-2">
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => void commitEdit(c.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
;(e.target as HTMLInputElement).blur()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelEdit()
}
}}
placeholder="Namnge konversationen…"
maxLength={200}
aria-label="Nytt namn på konversationen"
className="w-full rounded-md border border-border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
) : (
<Link
href={`/chat/${c.id}`}
className={cn(
'group flex items-start gap-2 px-4 py-2 hover:bg-secondary/60 transition-colors border-l-2',
activeId === c.id
? 'bg-secondary/50 border-foreground'
: 'border-transparent',
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium truncate flex-1 min-w-0">
{c.title ?? intentLabel(c.intent_id)}
</p>
<p className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{relativeTime(c.last_message_at ?? c.created_at)}
</p>
</div>
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
{c.last_message_preview ?? intentLabel(c.intent_id)}
</p>
</div>
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
{c.last_message_preview ?? intentLabel(c.intent_id)}
</p>
</div>
{/* Always-visible action icons. Touch-friendly, no
hover-only invisibility on mobile. */}
<div className="flex flex-col gap-1 shrink-0 -mr-1">
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void togglePin(c.id, c.pinned)
}}
title={c.pinned ? 'Avfäst' : 'Fäst'}
aria-label={c.pinned ? 'Avfäst konversation' : 'Fäst konversation'}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded transition-colors',
c.pinned
? 'text-foreground'
: 'text-muted-foreground/50 hover:text-foreground hover:bg-secondary',
)}
>
{c.pinned ? (
<Pin className="h-3 w-3" fill="currentColor" />
) : (
<PinOff className="h-3 w-3" />
)}
</button>
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void archive(c.id)
}}
title="Arkivera"
aria-label="Arkivera konversation"
className="inline-flex h-8 w-8 items-center justify-center rounded text-muted-foreground/50 hover:text-foreground hover:bg-secondary transition-colors"
>
<Archive className="h-3 w-3" />
</button>
</div>
</Link>
{/* Always-visible action icons. Touch-friendly, no
hover-only invisibility on mobile. Laid out
horizontally so three icons don't stack and inflate
the row height. */}
<div className="flex items-center gap-1 shrink-0 -mr-1">
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
startEdit(c)
}}
title="Byt namn"
aria-label="Byt namn på konversation"
className="inline-flex h-8 w-8 items-center justify-center rounded text-muted-foreground/50 hover:text-foreground hover:bg-secondary transition-colors"
>
<Pencil className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void togglePin(c.id, c.pinned)
}}
title={c.pinned ? 'Avfäst' : 'Fäst'}
aria-label={c.pinned ? 'Avfäst konversation' : 'Fäst konversation'}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded transition-colors',
c.pinned
? 'text-foreground'
: 'text-muted-foreground/50 hover:text-foreground hover:bg-secondary',
)}
>
{c.pinned ? (
<Pin className="h-3 w-3" fill="currentColor" />
) : (
<PinOff className="h-3 w-3" />
)}
</button>
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void archive(c.id)
}}
title="Arkivera"
aria-label="Arkivera konversation"
className="inline-flex h-8 w-8 items-center justify-center rounded text-muted-foreground/50 hover:text-foreground hover:bg-secondary transition-colors"
>
<Archive className="h-3 w-3" />
</button>
</div>
</Link>
)}
</li>
))}
</ul>
@@ -322,26 +330,3 @@ export default function ChatSidebar({ initialConversations }: Props) {
</>
)
}
function intentLabel(intentId: string): string {
switch (intentId) {
case 'general.help':
return 'Fråga din assistent'
case 'transaction.categorization':
return 'Hjälp med transaktion'
case 'invoice.draft':
return 'Hjälp med faktura'
case 'supplier_invoice.review':
return 'Granska leverantörsfaktura'
case 'vat.review':
return 'Granska moms­deklaration'
case 'bokslut.step':
return 'Hjälp med bokslut'
case 'verifikation.draft':
return 'Hjälp med verifikation'
case 'kpi.explain':
return 'Förklara nyckeltal'
default:
return intentId
}
}
+103
View File
@@ -0,0 +1,103 @@
// Shared display helpers for the agent conversation list — used by both the
// full-page /chat sidebar (ChatSidebar) and the in-sheet "resume conversation"
// list (AgentSessionList). Pure functions; no React. Keeping them in one place
// means the Idag / Igår / Denna vecka / Äldre grouping and the relative-time
// labels stay identical across both surfaces.
export interface ConversationRow {
id: string
intent_id: string
context_ref: string | null
title: string | null
pinned: boolean
archived: boolean
last_message_at: string | null
last_message_preview: string | null
created_at: string
}
// Time buckets for date grouping. Computed once per render against now().
// Mirrors the Idag / Igår / Denna vecka / Äldre pattern users know from
// Mail and iMessage.
export type DateBucket = 'pinned' | 'today' | 'yesterday' | 'thisWeek' | 'older'
export const BUCKET_LABELS: Record<DateBucket, string> = {
pinned: 'Fästade',
today: 'Idag',
yesterday: 'Igår',
thisWeek: 'Denna vecka',
older: 'Äldre',
}
export const BUCKET_ORDER: DateBucket[] = ['pinned', 'today', 'yesterday', 'thisWeek', 'older']
export function bucketFor(c: ConversationRow): DateBucket {
if (c.pinned) return 'pinned'
const when = c.last_message_at ?? c.created_at
if (!when) return 'older'
const t = new Date(when)
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000)
const weekStart = new Date(todayStart.getTime() - 6 * 24 * 60 * 60 * 1000)
if (t >= todayStart) return 'today'
if (t >= yesterdayStart) return 'yesterday'
if (t >= weekStart) return 'thisWeek'
return 'older'
}
// Compact relative-time label shown to the right of each row. Locale-tuned
// to feel native in Swedish without going full date-fns.
export function relativeTime(iso: string | null | undefined): string {
if (!iso) return ''
const t = new Date(iso).getTime()
const now = Date.now()
const diffMin = Math.round((now - t) / 60000)
if (diffMin < 1) return 'nu'
if (diffMin < 60) return `${diffMin} min`
const diffHr = Math.round(diffMin / 60)
if (diffHr < 24) return `${diffHr} h`
const diffDay = Math.round(diffHr / 24)
if (diffDay < 7) return `${diffDay} d`
return new Date(iso).toLocaleDateString('sv-SE', { month: 'short', day: 'numeric' })
}
export function intentLabel(intentId: string): string {
switch (intentId) {
case 'general.help':
return 'Fråga din assistent'
case 'transaction.categorization':
return 'Hjälp med transaktion'
case 'invoice.draft':
return 'Hjälp med faktura'
case 'supplier_invoice.review':
return 'Granska leverantörsfaktura'
case 'vat.review':
return 'Granska moms­deklaration'
case 'bokslut.step':
return 'Hjälp med bokslut'
case 'verifikation.draft':
return 'Hjälp med verifikation'
case 'kpi.explain':
return 'Förklara nyckeltal'
default:
return intentId
}
}
// Group a flat (already server-sorted: pinned first, then last_message_at desc)
// list into ordered, non-empty buckets. Shared so both list surfaces render
// the same section order.
export function groupConversations(
rows: ConversationRow[],
): { bucket: DateBucket; rows: ConversationRow[] }[] {
const buckets: Record<DateBucket, ConversationRow[]> = {
pinned: [],
today: [],
yesterday: [],
thisWeek: [],
older: [],
}
for (const c of rows) buckets[bucketFor(c)].push(c)
return BUCKET_ORDER.map((b) => ({ bucket: b, rows: buckets[b] })).filter((g) => g.rows.length > 0)
}
+65 -34
View File
@@ -4,6 +4,12 @@ import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { Plus } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions'
import {
buildAccountIndex,
searchAccounts,
type SearchableAccount,
type AccountSearchItem,
} from '@/lib/bookkeeping/account-search'
import type { BASAccount } from '@/types'
interface AccountComboboxProps {
@@ -19,51 +25,60 @@ interface AccountComboboxProps {
// dropdown's empty state. The current search string is passed so the caller
// can prefill the create dialog.
onCreateAccount?: (prefill: string) => void
// The full BAS catalogue. When provided, accounts not yet in `accounts`
// (the company's active chart) become searchable by name and are surfaced
// with the `notActivatedLabel` marker; picking one activates it at commit
// via the existing ACCOUNTS_NOT_IN_CHART rail.
catalog?: SearchableAccount[]
// Label shown next to catalogue-only (not-yet-activated) accounts. Defaults
// to Swedish; bilingual hosts pass a localized string.
notActivatedLabel?: string
// Extra classes merged into the trigger Input — callers pass `h-8` for dense
// table rows, omit it to use the default Input height.
className?: string
// Optional callback ref to the underlying <input>, invoked alongside the
// internal one. Lets a parent imperatively focus the field (e.g. auto-advance
// to the next konteringsrad's account on Enter — see JournalEntryForm.focusAccount).
inputRef?: React.RefCallback<HTMLInputElement>
}
const MAX_RESULTS = 50
export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, className }: AccountComboboxProps) {
export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, catalog, notActivatedLabel = 'Aktiveras vid bokföring', className, inputRef }: AccountComboboxProps) {
const [search, setSearch] = useState(value)
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const internalInputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
// Attach the internal ref (used for focus bookkeeping) and forward the element
// to any external callback ref the parent passed.
const setInputRef = useCallback((el: HTMLInputElement | null) => {
internalInputRef.current = el
inputRef?.(el)
}, [inputRef])
// Sync external value changes into the search field
useEffect(() => {
setSearch(value)
}, [value])
// Filter accounts based on search input
const filteredAccounts = useMemo(() => {
if (!search) return accounts.slice(0, MAX_RESULTS)
// Index the active chart + the full BAS catalogue once per source change.
// Searching it per keystroke is then just substring checks over pre-folded
// haystacks (number + name + description, diacritics stripped).
const accountIndex = useMemo(
() => buildAccountIndex({ active: accounts, catalog }),
[accounts, catalog]
)
const trimmed = search.trim()
if (!trimmed) return accounts.slice(0, MAX_RESULTS)
const startsWithDigit = /^\d/.test(trimmed)
if (startsWithDigit) {
return accounts
.filter((a) => a.account_number.startsWith(trimmed))
.slice(0, MAX_RESULTS)
}
const lowerSearch = trimmed.toLowerCase()
return accounts
.filter((a) => a.account_name.toLowerCase().includes(lowerSearch))
.slice(0, MAX_RESULTS)
}, [accounts, search])
const filteredAccounts = useMemo(
() => searchAccounts(accountIndex, search),
[accountIndex, search]
)
// Group filtered accounts by class
const groupedAccounts = useMemo(() => {
const groups: { className: string; accounts: BASAccount[] }[] = []
const groupMap = new Map<string, BASAccount[]>()
const groups: { className: string; accounts: AccountSearchItem[] }[] = []
const groupMap = new Map<string, AccountSearchItem[]>()
for (const account of filteredAccounts) {
const className = getAccountClassName(account.account_class)
@@ -163,8 +178,14 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
if (/^\d{4}$/.test(newValue)) {
onChange(newValue)
// Only treat as a commit when the value newly becomes this account, so
// editing an already-committed number doesn't keep stealing focus.
if (newValue !== value) onCommit?.(newValue)
// editing an already-committed number doesn't keep stealing focus. On
// commit, close the dropdown too — focus advances to the amount field, so
// a lingering open list would just cover the rows below.
if (newValue !== value) {
onCommit?.(newValue)
setIsOpen(false)
return
}
}
if (!isOpen) {
setIsOpen(true)
@@ -176,6 +197,9 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
}
const handleBlur = () => {
// Close the dropdown as soon as focus leaves, so it never lingers open over
// the rows below when focus advances via keyboard (Enter/Tab).
setIsOpen(false)
// Small delay to allow dropdown click to fire first. Keep any 4-digit
// numeric value even if it's not in the currently-active chart — the
// submit handler will prompt to activate it.
@@ -190,7 +214,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
return (
<div ref={containerRef} className="relative">
<Input
ref={inputRef}
ref={setInputRef}
value={search}
onChange={handleInputChange}
onFocus={handleFocus}
@@ -213,12 +237,12 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
<div className="sticky top-0 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted border-b border-input">
{group.className}
</div>
{group.accounts.map((account) => {
const flatIndex = flatList.indexOf(account)
{group.accounts.map((item) => {
const flatIndex = flatList.indexOf(item)
const isHighlighted = flatIndex === highlightedIndex
return (
<button
key={account.account_number}
key={item.account_number}
type="button"
data-highlighted={isHighlighted}
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer flex items-baseline gap-2 ${
@@ -226,12 +250,19 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
}`}
onMouseDown={(e) => {
e.preventDefault()
selectAccount(account.account_number)
selectAccount(item.account_number)
}}
onMouseEnter={() => setHighlightedIndex(flatIndex)}
>
<span className="font-mono shrink-0">{account.account_number}</span>
<span className="flex-1 min-w-0 break-words">{account.account_name}</span>
<span className={`font-mono shrink-0 ${item.isActive ? '' : 'text-muted-foreground'}`}>
{item.account_number}
</span>
<span className="flex-1 min-w-0 break-words">{item.account_name}</span>
{!item.isActive && (
<span className="shrink-0 self-center text-[11px] text-muted-foreground whitespace-nowrap">
{notActivatedLabel}
</span>
)}
</button>
)
})}
@@ -0,0 +1,159 @@
'use client'
import { useMemo, useState, useCallback } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AlertTriangle } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import OpeningBalanceRowEditor, {
type EditableRow,
type OpeningBalanceEditorState,
} from '@/components/import/OpeningBalanceRowEditor'
import type { JournalEntry, JournalEntryLine } from '@/types'
interface Props {
/** The currently-linked, posted opening-balance verifikat being corrected. */
entry: JournalEntry
open: boolean
onOpenChange: (open: boolean) => void
onCorrected: () => void
}
let seedIdCounter = 0
// Map the booked IB's lines into editable rows. account_name isn't stored on
// the line, so resolve it from BAS for display (cosmetic — only account_number
// + amounts are sent on save).
function seedRowsFromEntry(entry: JournalEntry): EditableRow[] {
const lines = ((entry.lines || []) as JournalEntryLine[])
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
return lines.map((l) => {
const bas = BAS_REFERENCE.find((a) => a.account_number === l.account_number)
return {
id: l.id || `seed_${++seedIdCounter}`,
account_number: l.account_number,
account_name: bas?.account_name ?? '',
debit_amount: Number(l.debit_amount) || 0,
credit_amount: Number(l.credit_amount) || 0,
validation_errors: [],
bas_match: bas?.account_name ?? null,
}
})
}
/**
* Inline correction of an already-booked opening-balance verifikat. The user
* edits the IB's lines directly; on save we POST to
* /api/import/opening-balance/correct, which (BFL-compliant) stornoes the old
* IB, books a corrected one, and relinks the period to it. Works regardless of
* how the IB was created (SIE import, CSV/Excel import, or year-end carry).
*/
export default function CorrectOpeningBalanceDialog({
entry,
open,
onOpenChange,
onCorrected,
}: Props) {
const { toast } = useToast()
const initialRows = useMemo(() => seedRowsFromEntry(entry), [entry])
const [state, setState] = useState<OpeningBalanceEditorState | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = useCallback(async () => {
if (!state?.canSubmit || isSubmitting) return
setIsSubmitting(true)
try {
const lines = state.rows
.filter((r) => r.debit_amount > 0 || r.credit_amount > 0)
.map((r) => ({
account_number: r.account_number,
debit_amount: r.debit_amount,
credit_amount: r.credit_amount,
}))
const res = await fetch('/api/import/opening-balance/correct', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fiscal_period_id: entry.fiscal_period_id, lines }),
})
const result = await res.json()
if (!res.ok) {
const err = new Error('Failed to correct opening balances') as Error & {
body?: unknown
status?: number
}
err.body = result
err.status = res.status
throw err
}
toast({
title: 'Ingående balanser korrigerade',
description: 'Den gamla IB-verifikationen stornades och en ny bokfördes.',
})
onOpenChange(false)
onCorrected()
} catch (err) {
const anyErr = err as { body?: unknown; status?: number }
toast({
title: 'Kunde inte korrigera ingående balanser',
description: getErrorMessage(anyErr.body ?? err, {
context: 'journal_entry',
statusCode: anyErr.status,
}),
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}, [state, isSubmitting, entry.fiscal_period_id, toast, onOpenChange, onCorrected])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Korrigera ingående balanser</DialogTitle>
<DialogDescription>
Ändra beloppen nedan och spara. Den befintliga IB-verifikationen (
{formatVoucher(entry)}) makuleras och en ny bokförs med de korrigerade beloppen.
</DialogDescription>
</DialogHeader>
{/* Storno explanation — a booked verifikat can't be edited in place */}
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
En bokförd verifikation kan inte ändras direkt (Bokföringslagen). När du sparar stornas
den gamla IB-verifikationen och en ny bokförs båda sparas som en spårbar rättelse.
</p>
</div>
<OpeningBalanceRowEditor initialRows={initialRows} onChange={setState} />
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button onClick={handleSubmit} disabled={!state?.canSubmit || isSubmitting}>
{isSubmitting ? 'Sparar...' : 'Korrigera ingående balanser'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,189 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { ExternalLink, FileText } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
/**
* Side-by-side document viewer used while booking manually, so the user can
* read the figures off a receipt/invoice while filling in the journal entry.
*
* Renders by document id through the same-origin inline proxy
* (/api/documents/:id/inline). PDFs use <object type="application/pdf"> rather
* than <iframe> — Chrome intermittently blocks PDFs in a frame even with a
* permissive CSP (see the note in AttachmentPreviewSheet), and <object>
* invokes the PDF plugin directly. Images use <img>.
*
* Unlike AttachmentPreviewSheet this is keyed off a document id, not a
* journal_entry_id — during manual booking the entry does not exist yet.
*/
function isImageType(type: string | null, fileName?: string | null): boolean {
if (type?.startsWith('image/')) return true
// Legacy uploads sometimes leave mime_type null or application/octet-stream —
// fall back to the filename extension.
if (type === null || type === 'application/octet-stream') {
return /\.(jpe?g|png|gif|webp|svg)$/i.test(fileName ?? '')
}
return false
}
function isPdfType(type: string | null, fileName?: string | null): boolean {
if (type === 'application/pdf') return true
if (type === null || type === 'application/octet-stream') {
return fileName?.toLowerCase().endsWith('.pdf') ?? false
}
return false
}
interface DocumentViewerPaneProps {
/** Document id. Bytes are served via the same-origin inline proxy. */
documentId?: string | null
/** Pre-known mime type. When omitted it's fetched from /api/documents/:id. */
mime?: string | null
/** Pre-known signed URL for the "open in new tab" link. Optional. */
downloadUrl?: string | null
/** Optional filename — used for mime sniffing on legacy/octet-stream files. */
fileName?: string | null
className?: string
}
export default function DocumentViewerPane({
documentId,
mime: mimeProp = null,
downloadUrl: downloadUrlProp = null,
fileName = null,
className,
}: DocumentViewerPaneProps) {
const t = useTranslations('document_viewer')
// Fetched metadata is tagged with the document id it belongs to, so a stale
// response for a previously-shown document is ignored rather than flashed.
const [fetched, setFetched] = useState<
{ id: string; mime: string | null; url: string | null } | null
>(null)
// Resolve mime / download_url from the documents API only when the caller did
// not supply a mime (e.g. a pre-linked transaction document). When a mime is
// provided (fresh upload, inbox preview) we skip the round-trip entirely.
// setState happens only inside the async callbacks — never synchronously in
// the effect body — to avoid cascading renders (react-hooks/set-state-in-effect).
useEffect(() => {
if (!documentId || mimeProp) return
let cancelled = false
fetch(`/api/documents/${documentId}`)
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
if (cancelled) return
setFetched({
id: documentId,
mime: body?.data?.mime_type ?? null,
url: body?.data?.download_url ?? null,
})
})
.catch(() => {
// preview is best-effort — record an empty result so we stop "loading"
if (!cancelled) setFetched({ id: documentId, mime: null, url: null })
})
return () => {
cancelled = true
}
}, [documentId, mimeProp])
const fetchedForThis = fetched?.id === documentId ? fetched : null
const mime = mimeProp ?? fetchedForThis?.mime ?? null
const downloadUrl = downloadUrlProp ?? fetchedForThis?.url ?? null
const loadingMeta = !!documentId && !mimeProp && !fetchedForThis
if (!documentId) {
return (
<div
className={cn(
'flex h-full w-full items-center justify-center rounded-lg border bg-muted/20 text-sm text-muted-foreground',
className,
)}
>
<FileText className="mr-2 h-5 w-5" />
{t('empty')}
</div>
)
}
const inlineSrc = `/api/documents/${documentId}/inline`
const newTabHref = downloadUrl ?? inlineSrc
const showAsImage = isImageType(mime, fileName)
const showAsPdf = isPdfType(mime, fileName)
return (
<div
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-lg border bg-muted/20',
className,
)}
>
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5">
<span className="truncate text-xs text-muted-foreground">
{fileName ?? t('header_label')}
</span>
<a
href={newTabHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('open_in_new_tab')}
</a>
</div>
<div className="min-h-0 flex-1 overflow-auto bg-background">
{loadingMeta ? (
<div className="p-3">
<Skeleton className="h-full min-h-[40vh] w-full rounded-md" />
</div>
) : showAsPdf ? (
<object
data={inlineSrc}
type="application/pdf"
aria-label={fileName ?? t('header_label')}
className="h-full w-full"
>
<div className="flex h-full w-full items-center justify-center p-4 text-center text-sm text-muted-foreground">
{t('not_previewable')}
{' — '}
<a
href={newTabHref}
target="_blank"
rel="noopener noreferrer"
className="ml-1 underline"
>
{t('open_in_new_tab')}
</a>
</div>
</object>
) : showAsImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={inlineSrc}
alt={fileName ?? t('header_label')}
className="mx-auto max-w-full object-contain"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
<FileText className="h-6 w-6" />
<span>{t('not_previewable')}</span>
<a
href={newTabHref}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{t('open_in_new_tab')}
</a>
</div>
)}
</div>
</div>
)
}
+53 -3
View File
@@ -16,6 +16,7 @@ import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
@@ -124,6 +125,10 @@ export default function JournalEntryForm({
const [showNoDocWarning, setShowNoDocWarning] = useState(false)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
// Full BAS catalogue (static reference data, fetched once per session). Lets
// the account picker surface standard accounts the company hasn't activated
// yet; picking one activates it at commit via the existing rail.
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
const [entryCurrency, setEntryCurrency] = useState<Currency>('SEK')
const [exchangeRate, setExchangeRate] = useState('')
const [isFetchingRate, setIsFetchingRate] = useState(false)
@@ -147,6 +152,8 @@ export default function JournalEntryForm({
// cards + desktop table); we focus whichever one is actually visible.
const desktopDebitRefs = useRef<(HTMLInputElement | null)[]>([])
const mobileDebitRefs = useRef<(HTMLInputElement | null)[]>([])
// Confirm button in the inline (bare) review, focused on open so Enter posts.
const bareConfirmRef = useRef<HTMLButtonElement>(null)
const isForeign = entryCurrency !== 'SEK'
@@ -185,6 +192,7 @@ export default function JournalEntryForm({
useEffect(() => {
fetchPeriods()
fetchAccounts()
loadBasCatalog().then(setCatalog).catch(() => {/* search degrades to the active chart */})
// Fetch default voucher series from company settings — prefer the
// per-source-type mapping when present; fall back to the legacy
// default_voucher_series, then to 'A'.
@@ -363,7 +371,11 @@ export default function JournalEntryForm({
// surprising when splitting across several lines. The balancing amount is
// now opt-in via double-clicking a debit/credit field (handleFillBalance).
if (field === 'account_number' && value) {
const account = accounts.find((a) => a.account_number === value)
// Fall back to the BAS catalogue so the description still auto-fills when
// the chosen account isn't in the active chart yet.
const account =
accounts.find((a) => a.account_number === value) ??
catalog.find((a) => a.account_number === value)
if (account) {
updated[index].line_description = account.account_name
// Fortnox-style: seed the verifikationstext from the first row's account
@@ -429,6 +441,14 @@ export default function JournalEntryForm({
})
}, [lines])
// Inline (bare) review: move focus to the confirm button when it opens so
// Enter posts — parity with the ConfirmationDialog's autoFocusConfirm.
useEffect(() => {
if (bare && showReview) {
requestAnimationFrame(() => bareConfirmRef.current?.focus())
}
}, [bare, showReview])
// Only lines with both an account and a non-zero amount end up in the submit
// payload (see the filter in handleConfirm). Compute totals and balance from
// those same lines so the enable-gate matches what the API will actually see.
@@ -530,6 +550,31 @@ export default function JournalEntryForm({
setShowReview(true)
}
// Whether an Enter should open the review — mirrors the review button's
// enable gate exactly, so Enter never submits something the button wouldn't.
const canSubmitReview = () =>
isBalanced &&
!!description &&
!!selectedPeriod &&
!periodMismatch &&
!isUploading &&
canWrite &&
!isSubmitting &&
!isSavingDraft
// Enter anywhere in the form = "Granska & skapa": opens the review exactly as
// the button does, from any field. Navigation is Tab's job. Two Enter
// exceptions stay intact: the account combobox (it calls preventDefault to
// select the highlighted account — we skip when defaultPrevented) and the
// internal-note textarea (newlines). The inline review owns its own Enter.
const handleFormKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'Enter') return
if (e.defaultPrevented || showReview) return
if ((e.target as HTMLElement).tagName === 'TEXTAREA') return
e.preventDefault()
if (canSubmitReview()) handleReview()
}
// Inner submit: builds payload, POSTs, throws a structured error on failure
// (so the activation hook can intercept ACCOUNTS_NOT_IN_CHART).
const postJournalEntry = useCallback(async () => {
@@ -829,7 +874,7 @@ export default function JournalEntryForm({
<Button variant="outline" onClick={() => setShowReview(false)} disabled={isSubmitting}>
{t('review_back')}
</Button>
<Button onClick={handleConfirm} disabled={isSubmitting}>
<Button ref={bareConfirmRef} onClick={handleConfirm} disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{/* No underlag attached → explicit acknowledgement, equivalent to the
blocking "Bokför utan underlag" dialog in the non-bare flow (BFL
@@ -843,7 +888,7 @@ export default function JournalEntryForm({
)
const formContent = (
<div className="space-y-4">
<div className="space-y-4" onKeyDown={handleFormKeyDown}>
{bare && showReview ? reviewPanel : (
<>
{/* Verifikat metadata — compact bar on top (Fortnox-style). Date, series
@@ -1041,6 +1086,8 @@ export default function JournalEntryForm({
<AccountCombobox
value={line.account_number}
accounts={accounts}
catalog={catalog}
notActivatedLabel={t('account_not_activated')}
onChange={(num) => updateLine(index, 'account_number', num)}
onCommit={() => focusDebit(index)}
onCreateAccount={(prefill) => handleOpenCreateAccount(index, prefill)}
@@ -1158,6 +1205,8 @@ export default function JournalEntryForm({
<AccountCombobox
value={line.account_number}
accounts={accounts}
catalog={catalog}
notActivatedLabel={t('account_not_activated')}
onChange={(num) => updateLine(index, 'account_number', num)}
onCommit={() => focusDebit(index)}
onCreateAccount={(prefill) => handleOpenCreateAccount(index, prefill)}
@@ -1378,6 +1427,7 @@ export default function JournalEntryForm({
onOpenChange={setShowReview}
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
autoFocusConfirm
title={
!embedded && nextVoucherNumber != null
? t('review_title_with_voucher', { voucher: formatVoucher({ voucher_series: voucherSeries, voucher_number: nextVoucherNumber }) })
@@ -1,7 +1,7 @@
'use client'
import { useTranslations } from 'next-intl'
import { Copy, Loader2 } from 'lucide-react'
import { Copy, Loader2, MessageCircle } from 'lucide-react'
import {
Dialog,
DialogContent,
@@ -9,6 +9,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import JournalEntryForm, { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
export interface CopyPrefill {
sourceId: string
@@ -42,16 +43,19 @@ export default function NewJournalEntryDialog({
isLoading,
}: Props) {
const t = useTranslations('bookkeeping')
const { openAgentSheet, identity } = useAgentSheet()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
// A half-typed verifikat must survive an accidental click on the
// backdrop (easy to do across multiple windows/screens). Closing is
// explicit — the header X or Cancel. This also stops nested popovers
// (AccountCombobox, date pickers) and the form's own confirm dialogs
// from collapsing the parent when they portal outside it.
// A half-typed verifikat must survive an accidental backdrop click or a
// stray Escape (easy to hit across multiple windows/screens, or when you
// only meant to dismiss a combobox dropdown). Closing is explicit — the
// header X. This also stops nested popovers (AccountCombobox, date
// pickers) and the form's own confirm dialogs from collapsing the parent
// when they portal outside it.
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
@@ -59,6 +63,24 @@ export default function NewJournalEntryDialog({
<DialogTitle>{t('new_entry_dialog_title')}</DialogTitle>
</DialogHeader>
{identity.isVerified && !copyPrefill && (
// Hand off to the assistant: it reads the underlag (the figures the
// user often can't see), suggests accounts, and stages a balanced
// verifikat to approve — no copy-paste. Close the modal first so its
// focus trap doesn't fight the (non-modal) agent sheet.
<button
type="button"
onClick={() => {
onOpenChange(false)
openAgentSheet({ intentId: 'verifikation.draft', contextRef: 'verifikation:new' })
}}
className="inline-flex w-fit items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<MessageCircle className="h-3.5 w-3.5" />
{t('ask_assistant_handoff')}
</button>
)}
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -18,6 +18,7 @@ import { useToast } from '@/components/ui/use-toast'
import { Loader2, Plus, Trash2, AlertTriangle, Search, Check } from 'lucide-react'
import { cn, formatCurrency } from '@/lib/utils'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
import { useCompany } from '@/contexts/CompanyContext'
@@ -71,6 +72,10 @@ interface Props {
open: boolean
onOpenChange: (v: boolean) => void
item: InboxItem
/** Signed URL + mime of the inbox document, threaded from the workspace so
the underlag can be shown beside the form without an extra round-trip. */
docUrl?: string | null
docMime?: string | null
onSuccess: () => void | Promise<void>
}
@@ -149,7 +154,7 @@ function rankBySekCloseness(
return [...rows].sort((a, b) => Math.abs(txSekAmount(a) - abs) - Math.abs(txSekAmount(b) - abs))
}
export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess }: Props) {
export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = null, docMime = null, onSuccess }: Props) {
const { toast } = useToast()
const { company } = useCompany()
@@ -530,7 +535,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bokför direkt</DialogTitle>
<DialogDescription>
@@ -538,7 +543,20 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
</DialogDescription>
</DialogHeader>
<div className="space-y-6 pt-2">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,560px)]">
{/* Document column — sticky on desktop so the underlag stays visible
while the form scrolls; stacks above the form on smaller screens. */}
<div className="h-[45vh] lg:sticky lg:top-0 lg:h-[72vh] lg:self-start">
<DocumentViewerPane
documentId={item.document_id}
mime={docMime}
downloadUrl={docUrl}
className="h-full"
/>
</div>
{/* Booking form */}
<div className="space-y-6 pt-2">
{/* Metadata row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-1.5">
@@ -887,6 +905,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
</Button>
</div>
</div>
</div>
</div>
</DialogContent>
<ActivateAccountsDialog
@@ -0,0 +1,253 @@
'use client'
import { useMemo, useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import type { InvoiceExtractionResult, VatTreatment } from '@/types'
// Minimal shape the dialog needs from the workspace's inbox items.
interface BulkBookInboxItem {
id: string
matched_transaction_id: string | null
created_journal_entry_id: string | null
created_supplier_invoice_id: string | null
extracted_data: InvoiceExtractionResult | null
}
interface Props {
open: boolean
onOpenChange: (v: boolean) => void
// The user's full checkbox selection. Non-bookable items are filtered out
// and surfaced as a "skipped" count so the user understands the outcome.
items: BulkBookInboxItem[]
onSuccess: () => void | Promise<void>
}
// Swedish category labels — mirrors lib/bookkeeping/category-mapping.ts
// (categoryLabels), ordered expenses-first since underlag are overwhelmingly
// costs. Values match TransactionCategorySchema in lib/api/schemas.ts.
const CATEGORY_OPTIONS: { value: string; label: string }[] = [
{ value: 'expense_software', label: 'Programvara/IT-tjänster' },
{ value: 'expense_office', label: 'Kontorskostnad' },
{ value: 'expense_consumables', label: 'Förbrukningsvaror' },
{ value: 'expense_equipment', label: 'Förbrukningsinventarier' },
{ value: 'expense_telecom', label: 'Telefon & internet' },
{ value: 'expense_travel', label: 'Resekostnad' },
{ value: 'expense_marketing', label: 'Marknadsföring' },
{ value: 'expense_professional_services', label: 'Konsulttjänst' },
{ value: 'expense_education', label: 'Utbildning' },
{ value: 'expense_representation', label: 'Representation' },
{ value: 'expense_vehicle', label: 'Bil & drivmedel' },
{ value: 'expense_bank_fees', label: 'Bankavgift' },
{ value: 'expense_card_fees', label: 'Kortavgift' },
{ value: 'expense_currency_exchange', label: 'Valutaväxling' },
{ value: 'expense_other', label: 'Övrig kostnad' },
{ value: 'income_services', label: 'Tjänsteförsäljning' },
{ value: 'income_products', label: 'Varuförsäljning' },
{ value: 'income_other', label: 'Övrig intäkt' },
{ value: 'private', label: 'Privat' },
]
// VAT treatment options. `value` is typed as `VatTreatment` (types/index.ts)
// so this list can never drift from what the backend accepts: the bulk-book
// route feeds the value straight into buildMappingResultFromCategory, which
// only recognises these six. The 12% and 6% reduced rates are ALREADY covered
// here by `reduced_12` / `reduced_6` — there is deliberately no `standard_12` /
// `standard_6` (no such treatment exists; the backend would reject it). Keep
// this list in sync with the union, not with rate labels.
const VAT_OPTIONS: { value: VatTreatment; label: string }[] = [
{ value: 'standard_25', label: 'Moms 25%' },
{ value: 'reduced_12', label: 'Moms 12%' },
{ value: 'reduced_6', label: 'Moms 6%' },
{ value: 'reverse_charge', label: 'Omvänd skattskyldighet (EU/utland)' },
{ value: 'export', label: 'Export (0%)' },
{ value: 'exempt', label: 'Momsfri' },
]
function isBookable(it: BulkBookInboxItem): boolean {
return Boolean(it.matched_transaction_id) && !it.created_journal_entry_id && !it.created_supplier_invoice_id
}
export default function BulkBookInboxDialog({ open, onOpenChange, items, onSuccess }: Props) {
const { toast } = useToast()
const [category, setCategory] = useState<string>('')
const [vatTreatment, setVatTreatment] = useState<VatTreatment>('standard_25')
const [isSubmitting, setIsSubmitting] = useState(false)
const bookable = useMemo(() => items.filter(isBookable), [items])
const notMatched = useMemo(
() => items.filter((it) => !it.matched_transaction_id && !it.created_journal_entry_id && !it.created_supplier_invoice_id).length,
[items],
)
const alreadyBooked = useMemo(
() => items.filter((it) => it.created_journal_entry_id || it.created_supplier_invoice_id).length,
[items],
)
// Reset to the safe default (25% svensk moms) each time the dialog opens.
// Currency is deliberately NOT used to preselect omvänd skattskyldighet: a
// foreign currency does not imply a foreign seller — a Swedish supplier can
// invoice in EUR and still debit 25% moms. Reverse charge is a property of
// the seller (utländsk, utan svenskt momsnr), never of the currency, so
// defaulting to it from currency alone would silently mis-book domestic VAT.
// The advisory rendered under the Moms picker spells this out to the user.
useEffect(() => {
if (open) setVatTreatment('standard_25')
}, [open])
const totalSek = useMemo(
() => bookable.reduce((s, it) => s + (it.extracted_data?.totals?.total ?? 0), 0),
[bookable],
)
const submit = async () => {
if (!category || bookable.length === 0) return
setIsSubmitting(true)
try {
const res = await fetch('/api/extensions/ext/invoice-inbox/items/bulk-book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item_ids: bookable.map((it) => it.id),
category,
vat_treatment: vatTreatment,
}),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(json.error ?? `HTTP ${res.status}`)
}
const bookedCount: number = json.data?.booked_count ?? 0
const skippedCount: number = json.data?.skipped_count ?? 0
const parts: string[] = []
if (bookedCount > 0) parts.push(`${bookedCount} bokförda`)
if (skippedCount > 0) parts.push(`${skippedCount} överhoppade`)
toast({
title: 'Bulkbokföring klar',
description: parts.join(' · ') || 'Inga underlag bokfördes',
variant: bookedCount === 0 ? 'destructive' : 'default',
})
onOpenChange(false)
await onSuccess()
} catch (err) {
toast({
title: 'Bokföringen misslyckades',
description: err instanceof Error ? err.message : 'Okänt fel',
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
const skippedNote: string | null = useMemo(() => {
const bits: string[] = []
if (notMatched > 0) bits.push(`${notMatched} saknar matchad transaktion`)
if (alreadyBooked > 0) bits.push(`${alreadyBooked} redan bokförda`)
return bits.length > 0 ? bits.join(' · ') : null
}, [notMatched, alreadyBooked])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Bokför {bookable.length} underlag</DialogTitle>
<DialogDescription>
Varje underlag bokförs mot sin matchade banktransaktion med samma kategori och momsbehandling.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bulk-category">Kategori</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="bulk-category">
<SelectValue placeholder="Välj kategori" />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Label htmlFor="bulk-vat">Moms</Label>
<InfoTooltip
content={
<>
Välj <strong>Omvänd skattskyldighet</strong> för köp från en utländsk säljare utan
svenskt momsnummer (t.ex. EU-tjänster som moln/mjukvara). Svenska fakturor med moms:
välj den sats kvittot visar valutan avgör inte.
</>
}
/>
</div>
<Select value={vatTreatment} onValueChange={(v) => setVatTreatment(v as VatTreatment)}>
<SelectTrigger id="bulk-vat">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VAT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{vatTreatment === 'reverse_charge' && (
<div className="rounded-md border border-border bg-secondary/40 p-3 text-xs text-muted-foreground">
<strong className="font-medium text-foreground">Kontrollera säljaren.</strong>{' '}
Omvänd skattskyldighet gäller bara köp från en <strong className="font-medium text-foreground">utländsk
säljare utan svenskt momsregistreringsnummer</strong> t.ex. EU-tjänster, EU-varor,
byggtjänster eller viss elektronik. Valutan avgör inte: en svensk säljare kan fakturera i
EUR och ändå debitera 25% moms. Är säljaren svensk och momsen står kvittot, välj i
stället rätt momssats ovan.
</div>
)}
</div>
{totalSek > 0 && (
<p className="text-xs text-muted-foreground tabular-nums">
Underlagens summa: {formatCurrency(totalSek)}
</p>
)}
{skippedNote && (
<p className="text-xs text-muted-foreground">
Hoppas över: {skippedNote}.
</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button onClick={submit} disabled={isSubmitting || !category || bookable.length === 0}>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Bokför {bookable.length} underlag
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -42,6 +42,7 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import type { InvoiceExtractionResult } from '@/types'
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog'
import TransactionMatchPicker from '@/components/inbox/TransactionMatchPicker'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
@@ -233,6 +234,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const [isRotating, setIsRotating] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [bookDirectOpen, setBookDirectOpen] = useState(false)
// Bulk-book selected underlag (Modell B) — the "Bokför valda" selection-bar
// action. The dialog filters the selection to bookable items itself.
const [bulkBookOpen, setBulkBookOpen] = useState(false)
// Match-to-bank-transaction picker (opens when user clicks "Matcha mot
// transaktion" on an unmatched inbox item).
const [matchPickerOpen, setMatchPickerOpen] = useState(false)
@@ -605,6 +609,21 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
// The selected rows, and how many of them can actually be bulk-booked
// (matched to a transaction and not yet booked). Drives the "Bokför valda"
// button enabled-state and feeds the bulk-book dialog.
const selectedItems = useMemo(
() => items.filter((it) => selectedIds.has(it.id)),
[items, selectedIds],
)
const bookableSelectedCount = useMemo(
() =>
selectedItems.filter(
(it) => it.matched_transaction_id && !it.created_journal_entry_id && !it.created_supplier_invoice_id,
).length,
[selectedItems],
)
const handleBulkDelete = useCallback(async () => {
if (selectedIds.size === 0) return
if (!confirm(`Ta bort ${selectedIds.size} poster ur inkorgen?`)) return
@@ -822,29 +841,62 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
</div>
)}
{selectedIds.size > 0 && (
<div className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b bg-background/95 backdrop-blur px-3 py-2 text-xs">
<span className="font-medium">{selectedIds.size} valda</span>
<div className="flex items-center gap-1">
<div className="sticky top-0 z-10 flex flex-col gap-3 border-b bg-background/95 backdrop-blur px-4 py-3">
{/* Count */}
<span className="text-xs text-muted-foreground tabular-nums">
<span className="font-medium text-foreground">{selectedIds.size}</span>{' '}
{selectedIds.size === 1 ? 'markerad' : 'markerade'}
</span>
{/* Primary action — the one solid button */}
<Button
variant="default"
size="sm"
className="h-8 w-full text-xs"
onClick={() => setBulkBookOpen(true)}
disabled={isBulkDeleting || bookableSelectedCount === 0}
title={
bookableSelectedCount === 0
? 'Inget av de valda underlagen är matchat mot en banktransaktion'
: undefined
}
>
<Check className="h-3.5 w-3.5 mr-1.5" />
Bokför valda
</Button>
{/* Secondary actions — outlined, so they read as buttons */}
<div className="flex items-center gap-2">
{identity.isVerified && (
<Button
variant="outline"
size="sm"
className="h-8 flex-1 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() =>
openAgentSheet({
intentId: 'inbox.bulk-book',
intentArgs: { item_ids: Array.from(selectedIds) },
contextRef: 'inbox:bulk',
})
}
disabled={isBulkDeleting}
>
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
Fråga assistenten
</Button>
)}
<Button
variant="ghost"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={clearSelection}
disabled={isBulkDeleting}
>
Avmarkera
</Button>
<Button
variant="destructive"
size="sm"
className="h-7 text-xs"
className={cn(
'h-8 px-2 text-xs text-muted-foreground hover:text-destructive hover:border-destructive/40',
identity.isVerified ? 'flex-none' : 'flex-1'
)}
onClick={handleBulkDelete}
disabled={isBulkDeleting}
>
{isBulkDeleting ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Trash2 className="h-3 w-3 mr-1" />
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
)}
Ta bort
</Button>
@@ -1014,11 +1066,22 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
open={bookDirectOpen}
onOpenChange={setBookDirectOpen}
item={selected}
docUrl={docUrl}
docMime={docMime}
onSuccess={async () => {
await Promise.all([fetchItems(), handleSelect(selected.id)])
}}
/>
)}
<BulkBookInboxDialog
open={bulkBookOpen}
onOpenChange={setBulkBookOpen}
items={selectedItems}
onSuccess={async () => {
clearSelection()
await fetchItems()
}}
/>
{selected && (
<TransactionMatchPicker
open={matchPickerOpen}
+15 -371
View File
@@ -1,24 +1,13 @@
'use client'
import { useState, useMemo, useCallback, useRef } from 'react'
import Fuse from 'fuse.js'
import { useMemo, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2, AlertTriangle, Scale } from 'lucide-react'
import { cn } from '@/lib/utils'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import type { ParsedOpeningBalanceRow } from '@/lib/import/opening-balance/types'
interface EditableRow {
id: string
account_number: string
account_name: string
debit_amount: number
credit_amount: number
validation_errors: string[]
bas_match: string | null
}
import OpeningBalanceRowEditor, {
type EditableRow,
type OpeningBalanceEditorState,
} from './OpeningBalanceRowEditor'
interface OpeningBalanceEditStepProps {
rows: ParsedOpeningBalanceRow[]
@@ -26,37 +15,6 @@ interface OpeningBalanceEditStepProps {
onBack: () => void
}
// Filter BAS reference to balance sheet accounts only (class 1-2) for primary suggestions
const BALANCE_SHEET_ACCOUNTS = BAS_REFERENCE.filter(
(a) => a.account_class === 1 || a.account_class === 2,
)
const ALL_BAS_ACCOUNTS = BAS_REFERENCE
let fuseInstance: Fuse<typeof BAS_REFERENCE[0]> | null = null
function getFuse() {
if (!fuseInstance) {
fuseInstance = new Fuse(ALL_BAS_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
}
return fuseInstance
}
let balanceFuseInstance: Fuse<typeof BAS_REFERENCE[0]> | null = null
function getBalanceFuse() {
if (!balanceFuseInstance) {
balanceFuseInstance = new Fuse(BALANCE_SHEET_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
}
return balanceFuseInstance
}
let idCounter = 0
function generateId() {
return `row_${++idCounter}_${Date.now()}`
@@ -67,28 +25,9 @@ export default function OpeningBalanceEditStep({
onContinue,
onBack,
}: OpeningBalanceEditStepProps) {
const [rows, setRows] = useState<EditableRow[]>(() => {
// Defense-in-depth dedup: if the parser ever leaks duplicates by
// account_number, collapse them here before the user sees them. Union
// validation_errors so a warning surfaced only on the later row isn't
// silently dropped during the merge.
const byAccount = new Map<string, EditableRow>()
for (const r of initialRows) {
const key = r.account_number.replace(/\D/g, '')
const existing = byAccount.get(key)
if (existing) {
existing.debit_amount = Math.round((existing.debit_amount + r.debit_amount) * 100) / 100
existing.credit_amount = Math.round((existing.credit_amount + r.credit_amount) * 100) / 100
if (!existing.account_name && r.account_name) existing.account_name = r.account_name
if (r.validation_errors?.length) {
const seen = new Set(existing.validation_errors)
for (const err of r.validation_errors) {
if (!seen.has(err)) existing.validation_errors.push(err)
}
}
continue
}
byAccount.set(key, {
const seedRows = useMemo<EditableRow[]>(
() =>
initialRows.map((r) => ({
id: generateId(),
account_number: r.account_number,
account_name: r.account_name,
@@ -96,323 +35,28 @@ export default function OpeningBalanceEditStep({
credit_amount: r.credit_amount,
validation_errors: [...r.validation_errors],
bas_match: r.bas_match,
})
}
return Array.from(byAccount.values())
})
const [activeAutocomplete, setActiveAutocomplete] = useState<string | null>(null)
const [autocompleteQuery, setAutocompleteQuery] = useState('')
const autocompleteRef = useRef<HTMLDivElement>(null)
// Compute totals
const totals = useMemo(() => {
let debit = 0
let credit = 0
for (const row of rows) {
debit = Math.round((debit + row.debit_amount) * 100) / 100
credit = Math.round((credit + row.credit_amount) * 100) / 100
}
const diff = Math.round((debit - credit) * 100) / 100
return { debit, credit, diff, isBalanced: Math.abs(diff) < 0.01 }
}, [rows])
// Validation
const hasErrors = useMemo(() => {
return rows.some((r) => {
if (!/^\d{4}$/.test(r.account_number)) return true
if (r.debit_amount === 0 && r.credit_amount === 0) return true
if (r.validation_errors.length > 0) return true
return false
})
}, [rows])
const canContinue = totals.isBalanced && !hasErrors && rows.length >= 2
// Autocomplete results
const autocompleteResults = useMemo(() => {
if (!autocompleteQuery || autocompleteQuery.length < 1) return []
// If the query is numeric, search all accounts; otherwise prefer balance sheet
const isNumeric = /^\d+$/.test(autocompleteQuery)
const fuse = isNumeric ? getFuse() : getBalanceFuse()
return fuse.search(autocompleteQuery, { limit: 8 }).map((r) => r.item)
}, [autocompleteQuery])
const updateRow = useCallback((id: string, updates: Partial<EditableRow>) => {
setRows((prev) =>
prev.map((r) => {
if (r.id !== id) return r
const updated = { ...r, ...updates }
// Re-validate
const errors: string[] = []
if (!/^\d{4}$/.test(updated.account_number)) {
errors.push('Ogiltigt kontonummer')
}
const cls = parseInt(updated.account_number.charAt(0), 10)
if (cls >= 3 && cls <= 8) {
errors.push(`Resultatkonto (klass ${cls})`)
}
updated.validation_errors = errors
return updated
}),
)
}, [])
const deleteRow = useCallback((id: string) => {
setRows((prev) => prev.filter((r) => r.id !== id))
}, [])
const addRow = useCallback(() => {
setRows((prev) => [
...prev,
{
id: generateId(),
account_number: '',
account_name: '',
debit_amount: 0,
credit_amount: 0,
validation_errors: ['Ogiltigt kontonummer'],
bas_match: null,
},
])
}, [])
const selectAutocompleteItem = useCallback(
(rowId: string, account: (typeof BAS_REFERENCE)[0]) => {
updateRow(rowId, {
account_number: account.account_number,
account_name: account.account_name,
bas_match: account.account_name,
})
setActiveAutocomplete(null)
setAutocompleteQuery('')
},
[updateRow],
})),
[initialRows],
)
const handleAutoBalance = useCallback(() => {
if (Math.abs(totals.diff) > 1) return // Only auto-balance ≤ 1 SEK
if (totals.isBalanced) return
const adjustmentRow: EditableRow = {
id: generateId(),
account_number: '2099',
account_name: 'Årets resultat',
debit_amount: totals.diff > 0 ? 0 : Math.abs(totals.diff),
credit_amount: totals.diff > 0 ? totals.diff : 0,
validation_errors: [],
bas_match: 'Årets resultat',
}
setRows((prev) => [...prev, adjustmentRow])
}, [totals])
const [state, setState] = useState<OpeningBalanceEditorState | null>(null)
return (
<Card>
<CardHeader>
<CardTitle>Granska och redigera</CardTitle>
<CardDescription>
Kontrollera att kontonummer och belopp stämmer. Du kan lägga till, ta bort
och ändra rader. Debet och kredit måste balansera innan du kan fortsätta.
Kontrollera att kontonummer och belopp stämmer. Du kan lägga till, ta bort och ändra
rader. Debet och kredit måste balansera innan du kan fortsätta.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Table */}
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="px-3 py-2 text-left w-28">Konto</th>
<th className="px-3 py-2 text-left">Kontonamn</th>
<th className="px-3 py-2 text-right w-32">Debet</th>
<th className="px-3 py-2 text-right w-32">Kredit</th>
<th className="px-3 py-2 w-10" />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
className={cn(
'border-b last:border-0',
row.validation_errors.length > 0 && 'bg-destructive/5',
)}
>
<td className="px-3 py-1.5 relative">
<Input
value={row.account_number}
onChange={(e) => {
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 4)
updateRow(row.id, { account_number: val })
setActiveAutocomplete(row.id)
setAutocompleteQuery(val)
}}
onFocus={() => {
setActiveAutocomplete(row.id)
setAutocompleteQuery(row.account_number)
}}
onBlur={() => {
// Delay to allow click on autocomplete items
setTimeout(() => setActiveAutocomplete(null), 200)
}}
placeholder="1930"
className="h-8 font-mono tabular-nums w-20"
maxLength={4}
/>
{/* Autocomplete dropdown */}
{activeAutocomplete === row.id &&
autocompleteResults.length > 0 && (
<div
ref={autocompleteRef}
className="absolute z-50 top-full left-3 mt-1 w-72 max-h-48 overflow-y-auto rounded-md border bg-popover shadow-md"
>
{autocompleteResults.map((item) => (
<button
key={item.account_number}
className="flex items-center gap-2 w-full px-3 py-1.5 text-left text-sm hover:bg-accent transition-colors"
onMouseDown={(e) => {
e.preventDefault()
selectAutocompleteItem(row.id, item)
}}
>
<span className="font-mono text-muted-foreground tabular-nums">
{item.account_number}
</span>
<span className="truncate">{item.account_name}</span>
</button>
))}
</div>
)}
</td>
<td className="px-3 py-1.5">
<div className="flex items-center gap-2">
<span className="text-sm truncate max-w-xs">
{row.account_name}
</span>
{row.validation_errors.length > 0 && (
<span
className="text-destructive shrink-0"
title={row.validation_errors.join(', ')}
>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
)}
</div>
</td>
<td className="px-3 py-1.5">
<Input
type="number"
value={row.debit_amount || ''}
onChange={(e) =>
updateRow(row.id, {
debit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100,
})
}
placeholder="0,00"
className="h-8 text-right tabular-nums w-28"
min={0}
step={0.01}
/>
</td>
<td className="px-3 py-1.5">
<Input
type="number"
value={row.credit_amount || ''}
onChange={(e) =>
updateRow(row.id, {
credit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100,
})
}
placeholder="0,00"
className="h-8 text-right tabular-nums w-28"
min={0}
step={0.01}
/>
</td>
<td className="px-3 py-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => deleteRow(row.id)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-medium">
<td className="px-3 py-2" colSpan={2}>
Summa
</td>
<td className="px-3 py-2 text-right tabular-nums">
{totals.debit.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{totals.credit.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td />
</tr>
{!totals.isBalanced && (
<tr className="text-destructive">
<td className="px-3 py-1 text-sm" colSpan={2}>
Differens
</td>
<td className="px-3 py-1 text-right tabular-nums text-sm" colSpan={2}>
{totals.diff.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
SEK
</td>
<td />
</tr>
)}
</tfoot>
</table>
</div>
<OpeningBalanceRowEditor initialRows={seedRows} onChange={setState} />
{/* Actions row */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={addRow}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Lägg till rad
</Button>
{!totals.isBalanced && Math.abs(totals.diff) <= 1 && Math.abs(totals.diff) >= 0.01 && (
<Button variant="outline" size="sm" onClick={handleAutoBalance}>
<Scale className="h-3.5 w-3.5 mr-1.5" />
Avrunda ({totals.diff > 0 ? '+' : ''}{totals.diff.toFixed(2)} till 2099)
</Button>
)}
</div>
{/* Warnings */}
{!totals.isBalanced && Math.abs(totals.diff) > 1 && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
Debet och kredit balanserar inte. Differens:{' '}
{totals.diff.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK.
Kontrollera beloppen innan du fortsätter.
</p>
</div>
)}
{/* Navigation */}
<div className="flex justify-between pt-2">
<Button variant="ghost" onClick={onBack}>
Tillbaka
</Button>
<Button onClick={() => onContinue(rows)} disabled={!canContinue}>
<Button onClick={() => state && onContinue(state.rows)} disabled={!state?.canSubmit}>
Fortsätt
</Button>
</div>
+19 -14
View File
@@ -18,7 +18,9 @@ interface EditableRow {
interface OpeningBalancePeriodStepProps {
rows: EditableRow[]
onExecute: (fiscalPeriodId: string) => void
/** `replace` is true when the selected period already has opening balances
* (the existing IB verifikat will be stornoed and replaced). */
onExecute: (fiscalPeriodId: string, replace: boolean) => void
onBack: () => void
isLoading: boolean
error: string | null
@@ -71,22 +73,23 @@ export default function OpeningBalancePeriodStep({
}, [])
const selectedPeriod = periods.find((p) => p.id === selectedPeriodId)
const periodHasOB = selectedPeriod?.opening_balances_set
const periodHasOB = !!selectedPeriod?.opening_balances_set
const periodIsClosed = selectedPeriod?.is_closed
const periodIsLocked = !!selectedPeriod?.locked_at
// A period that already has IB can still be corrected, as long as it is open
// and unlocked — the existing IB verifikat is stornoed and replaced.
const canExecute =
selectedPeriodId &&
!periodHasOB &&
!!selectedPeriodId &&
!periodIsClosed &&
!periodIsLocked &&
!isLoading
const handleExecute = useCallback(() => {
if (canExecute) {
onExecute(selectedPeriodId)
onExecute(selectedPeriodId, periodHasOB)
}
}, [canExecute, selectedPeriodId, onExecute])
}, [canExecute, selectedPeriodId, periodHasOB, onExecute])
return (
<Card>
@@ -131,13 +134,13 @@ export default function OpeningBalancePeriodStep({
)}
</div>
{/* Warnings */}
{periodHasOB && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">
Denna period har redan ingående balanser. Ta bort den befintliga verifikationen
(via stornering) innan du importerar nya.
{/* Replace notice — selecting a period that already has IB corrects it */}
{periodHasOB && !periodIsClosed && !periodIsLocked && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertCircle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
Denna period har redan ingående balanser. Om du fortsätter makuleras (stornas) den
befintliga IB-verifikationen och en ny bokförs med beloppen nedan.
</p>
</div>
)}
@@ -180,8 +183,10 @@ export default function OpeningBalancePeriodStep({
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Bokför...
{periodHasOB ? 'Ersätter...' : 'Bokför...'}
</>
) : periodHasOB ? (
'Ersätt ingående balanser'
) : (
'Bokför ingående balanser'
)}
@@ -15,6 +15,7 @@ export default function OpeningBalanceResultStep({
result,
onNewImport,
}: OpeningBalanceResultStepProps) {
const isCorrection = !!result.reversed_entry_id
return (
<Card>
<CardHeader>
@@ -26,7 +27,9 @@ export default function OpeningBalanceResultStep({
)}
<CardTitle>
{result.success
? 'Ingående balanser bokförda'
? isCorrection
? 'Ingående balanser korrigerade'
: 'Ingående balanser bokförda'
: 'Importen misslyckades'}
</CardTitle>
</div>
@@ -0,0 +1,429 @@
'use client'
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
import Fuse from 'fuse.js'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2, AlertTriangle, Scale } from 'lucide-react'
import { cn } from '@/lib/utils'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
export interface EditableRow {
id: string
account_number: string
account_name: string
debit_amount: number
credit_amount: number
validation_errors: string[]
bas_match: string | null
}
export interface OpeningBalanceEditorState {
rows: EditableRow[]
totals: { debit: number; credit: number; diff: number; isBalanced: boolean }
/** Balanced, no row errors, and at least two lines — safe to book/correct. */
canSubmit: boolean
}
interface OpeningBalanceRowEditorProps {
/** Seed rows. Read once on mount (later prop changes are ignored remount
* via `key` or a conditional render to reset the grid). */
initialRows: EditableRow[]
/** Fires whenever rows / totals / validity change. Held in a ref internally,
* so it does NOT need to be referentially stable passing an inline arrow
* is safe and will not cause an update-loop. */
onChange: (state: OpeningBalanceEditorState) => void
}
// Balance-sheet accounts (class 1-2) drive the primary suggestions; numeric
// queries fall back to the full chart.
const BALANCE_SHEET_ACCOUNTS = BAS_REFERENCE.filter(
(a) => a.account_class === 1 || a.account_class === 2,
)
const ALL_BAS_ACCOUNTS = BAS_REFERENCE
let fuseInstance: Fuse<(typeof BAS_REFERENCE)[0]> | null = null
function getFuse() {
if (!fuseInstance) {
fuseInstance = new Fuse(ALL_BAS_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
}
return fuseInstance
}
let balanceFuseInstance: Fuse<(typeof BAS_REFERENCE)[0]> | null = null
function getBalanceFuse() {
if (!balanceFuseInstance) {
balanceFuseInstance = new Fuse(BALANCE_SHEET_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
}
return balanceFuseInstance
}
let idCounter = 0
function generateId() {
return `row_${++idCounter}_${Date.now()}`
}
// Defense-in-depth dedup: if a seed source ever leaks duplicate accounts,
// collapse them before the user sees them. Union validation_errors so a
// warning surfaced only on the later row isn't silently dropped on merge.
function dedupeRows(initialRows: EditableRow[]): EditableRow[] {
const byAccount = new Map<string, EditableRow>()
for (const r of initialRows) {
const key = r.account_number.replace(/\D/g, '')
const existing = byAccount.get(key)
if (existing) {
existing.debit_amount = Math.round((existing.debit_amount + r.debit_amount) * 100) / 100
existing.credit_amount = Math.round((existing.credit_amount + r.credit_amount) * 100) / 100
if (!existing.account_name && r.account_name) existing.account_name = r.account_name
if (r.validation_errors?.length) {
const seen = new Set(existing.validation_errors)
for (const err of r.validation_errors) {
if (!seen.has(err)) existing.validation_errors.push(err)
}
}
continue
}
byAccount.set(key, {
id: r.id || generateId(),
account_number: r.account_number,
account_name: r.account_name,
debit_amount: r.debit_amount,
credit_amount: r.credit_amount,
validation_errors: [...r.validation_errors],
bas_match: r.bas_match,
})
}
return Array.from(byAccount.values())
}
/**
* The opening-balance line grid: editable account / debet / kredit rows with
* BAS autocomplete, a live debit-credit balance check, blocking of resultat-
* konton (class 3-8), and a "round to 2099" auto-balance for sub-1-SEK drift.
*
* Stateless about WHY it's editing the wizard seeds it from a parsed file,
* the verifikat correction dialog seeds it from the booked IB's lines. Both
* read the current rows + validity through `onChange`.
*/
export default function OpeningBalanceRowEditor({
initialRows,
onChange,
}: OpeningBalanceRowEditorProps) {
const [rows, setRows] = useState<EditableRow[]>(() => dedupeRows(initialRows))
const [activeAutocomplete, setActiveAutocomplete] = useState<string | null>(null)
const [autocompleteQuery, setAutocompleteQuery] = useState('')
const autocompleteRef = useRef<HTMLDivElement>(null)
// Hold onChange in a ref so an unstable (inline) callback identity can't
// retrigger the notifying effect below. Synced after each commit rather than
// during render (react-hooks/refs: refs must not be written while rendering).
const onChangeRef = useRef(onChange)
useEffect(() => {
onChangeRef.current = onChange
})
const totals = useMemo(() => {
let debit = 0
let credit = 0
for (const row of rows) {
debit = Math.round((debit + row.debit_amount) * 100) / 100
credit = Math.round((credit + row.credit_amount) * 100) / 100
}
const diff = Math.round((debit - credit) * 100) / 100
return { debit, credit, diff, isBalanced: Math.abs(diff) < 0.01 }
}, [rows])
const hasErrors = useMemo(() => {
return rows.some((r) => {
if (!/^\d{4}$/.test(r.account_number)) return true
if (r.debit_amount === 0 && r.credit_amount === 0) return true
if (r.validation_errors.length > 0) return true
return false
})
}, [rows])
const canSubmit = totals.isBalanced && !hasErrors && rows.length >= 2
// Push state up via the ref so an unstable `onChange` identity can't retrigger
// this effect. Memoised `totals` means it only fires when the user actually
// edits a row, never in a loop.
useEffect(() => {
onChangeRef.current({ rows, totals, canSubmit })
}, [rows, totals, canSubmit])
const autocompleteResults = useMemo(() => {
if (!autocompleteQuery || autocompleteQuery.length < 1) return []
const isNumeric = /^\d+$/.test(autocompleteQuery)
const fuse = isNumeric ? getFuse() : getBalanceFuse()
return fuse.search(autocompleteQuery, { limit: 8 }).map((r) => r.item)
}, [autocompleteQuery])
const updateRow = useCallback((id: string, updates: Partial<EditableRow>) => {
setRows((prev) =>
prev.map((r) => {
if (r.id !== id) return r
const updated = { ...r, ...updates }
// Re-validate
const errors: string[] = []
if (!/^\d{4}$/.test(updated.account_number)) {
errors.push('Ogiltigt kontonummer')
}
const cls = parseInt(updated.account_number.charAt(0), 10)
if (cls >= 3 && cls <= 8) {
errors.push(`Resultatkonto (klass ${cls})`)
}
updated.validation_errors = errors
return updated
}),
)
}, [])
const deleteRow = useCallback((id: string) => {
setRows((prev) => prev.filter((r) => r.id !== id))
}, [])
const addRow = useCallback(() => {
setRows((prev) => [
...prev,
{
id: generateId(),
account_number: '',
account_name: '',
debit_amount: 0,
credit_amount: 0,
validation_errors: ['Ogiltigt kontonummer'],
bas_match: null,
},
])
}, [])
const selectAutocompleteItem = useCallback(
(rowId: string, account: (typeof BAS_REFERENCE)[0]) => {
updateRow(rowId, {
account_number: account.account_number,
account_name: account.account_name,
bas_match: account.account_name,
})
setActiveAutocomplete(null)
setAutocompleteQuery('')
},
[updateRow],
)
const handleAutoBalance = useCallback(() => {
if (Math.abs(totals.diff) > 1) return // Only auto-balance ≤ 1 SEK
if (totals.isBalanced) return
const adjustmentRow: EditableRow = {
id: generateId(),
account_number: '2099',
account_name: 'Årets resultat',
debit_amount: totals.diff > 0 ? 0 : Math.abs(totals.diff),
credit_amount: totals.diff > 0 ? totals.diff : 0,
validation_errors: [],
bas_match: 'Årets resultat',
}
setRows((prev) => [...prev, adjustmentRow])
}, [totals])
return (
<div className="space-y-4">
{/* Table */}
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="px-3 py-2 text-left w-28">Konto</th>
<th className="px-3 py-2 text-left">Kontonamn</th>
<th className="px-3 py-2 text-right w-32">Debet</th>
<th className="px-3 py-2 text-right w-32">Kredit</th>
<th className="px-3 py-2 w-10" />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
className={cn(
'border-b last:border-0',
row.validation_errors.length > 0 && 'bg-destructive/5',
)}
>
<td className="px-3 py-1.5 relative">
<Input
value={row.account_number}
onChange={(e) => {
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 4)
updateRow(row.id, { account_number: val })
setActiveAutocomplete(row.id)
setAutocompleteQuery(val)
}}
onFocus={() => {
setActiveAutocomplete(row.id)
setAutocompleteQuery(row.account_number)
}}
onBlur={() => {
// Delay to allow click on autocomplete items
setTimeout(() => setActiveAutocomplete(null), 200)
}}
placeholder="1930"
className="h-8 font-mono tabular-nums w-20"
maxLength={4}
/>
{/* Autocomplete dropdown */}
{activeAutocomplete === row.id && autocompleteResults.length > 0 && (
<div
ref={autocompleteRef}
className="absolute z-50 top-full left-3 mt-1 w-72 max-h-48 overflow-y-auto rounded-md border bg-popover shadow-md"
>
{autocompleteResults.map((item) => (
<button
key={item.account_number}
className="flex items-center gap-2 w-full px-3 py-1.5 text-left text-sm hover:bg-accent transition-colors"
onMouseDown={(e) => {
e.preventDefault()
selectAutocompleteItem(row.id, item)
}}
>
<span className="font-mono text-muted-foreground tabular-nums">
{item.account_number}
</span>
<span className="truncate">{item.account_name}</span>
</button>
))}
</div>
)}
</td>
<td className="px-3 py-1.5">
<div className="flex items-center gap-2">
<span className="text-sm truncate max-w-xs">{row.account_name}</span>
{row.validation_errors.length > 0 && (
<span
className="text-destructive shrink-0"
title={row.validation_errors.join(', ')}
>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
)}
</div>
</td>
<td className="px-3 py-1.5">
<Input
type="number"
value={row.debit_amount || ''}
onChange={(e) =>
updateRow(row.id, {
debit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100,
})
}
placeholder="0,00"
className="h-8 text-right tabular-nums w-28"
min={0}
step={0.01}
/>
</td>
<td className="px-3 py-1.5">
<Input
type="number"
value={row.credit_amount || ''}
onChange={(e) =>
updateRow(row.id, {
credit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100,
})
}
placeholder="0,00"
className="h-8 text-right tabular-nums w-28"
min={0}
step={0.01}
/>
</td>
<td className="px-3 py-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => deleteRow(row.id)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-medium">
<td className="px-3 py-2" colSpan={2}>
Summa
</td>
<td className="px-3 py-2 text-right tabular-nums">
{totals.debit.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{totals.credit.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td />
</tr>
{!totals.isBalanced && (
<tr className="text-destructive">
<td className="px-3 py-1 text-sm" colSpan={2}>
Differens
</td>
<td className="px-3 py-1 text-right tabular-nums text-sm" colSpan={2}>
{totals.diff.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
SEK
</td>
<td />
</tr>
)}
</tfoot>
</table>
</div>
{/* Actions row */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={addRow}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Lägg till rad
</Button>
{!totals.isBalanced && Math.abs(totals.diff) <= 1 && Math.abs(totals.diff) >= 0.01 && (
<Button variant="outline" size="sm" onClick={handleAutoBalance}>
<Scale className="h-3.5 w-3.5 mr-1.5" />
Avrunda ({totals.diff > 0 ? '+' : ''}
{totals.diff.toFixed(2)} till 2099)
</Button>
)}
</div>
{/* Warnings */}
{!totals.isBalanced && Math.abs(totals.diff) > 1 && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
Debet och kredit balanserar inte. Differens:{' '}
{totals.diff.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK. Kontrollera
beloppen innan du fortsätter.
</p>
</div>
)}
</div>
)
}
+47 -3
View File
@@ -1,8 +1,10 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { formatCurrency } from '@/lib/utils'
import {
Building2,
@@ -39,9 +41,20 @@ export default function SIEPreviewStep({
const errors = issues.filter((i) => i.severity === 'error')
const warnings = issues.filter((i) => i.severity === 'warning')
// Opening-balance imbalance. The importer plugs any diff > 0.01 to 2099, but a
// diff under ~1 SEK is genuine öresavrundning. Anything larger is a real
// imbalance (incomplete export — missing liabilities / unappropriated prior-year
// result) that would silently book a bogus amount to 2099. Mirrors the importer's
// own `fileImbalance > 1.00` "serious" threshold (lib/import/sie-import.ts).
const ibDiff = Math.round((preview.trialBalance.totalDebit - preview.trialBalance.totalCredit) * 100) / 100
const significantImbalance = !preview.trialBalance.isBalanced && Math.abs(ibDiff) > 1
const [ackImbalance, setAckImbalance] = useState(false)
// Only block on actual parsing errors, not unmapped accounts
// (users need to proceed to mapping step to fix unmapped accounts)
// (users need to proceed to mapping step to fix unmapped accounts).
// A significant IB imbalance is a soft block — the user must acknowledge it.
const hasBlockingErrors = errors.length > 0
const blockContinue = hasBlockingErrors || (significantImbalance && !ackImbalance)
return (
<div className="space-y-6">
@@ -164,11 +177,42 @@ export default function SIEPreviewStep({
<Badge variant="success">Balanserar</Badge>
) : (
<Badge variant="secondary">
Diff: {formatCurrency(preview.trialBalance.totalDebit - preview.trialBalance.totalCredit)}
Diff: {formatCurrency(ibDiff)}
</Badge>
)}
</div>
</div>
{/* Significant imbalance — explain + require acknowledgement before continuing */}
{significantImbalance && (
<div className="mt-4 space-y-3 rounded-lg border border-warning/40 bg-warning/5 px-4 py-3">
<div className="flex items-start gap-2 text-sm">
<AlertCircle className="h-4 w-4 text-warning mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="font-medium text-warning">
Ingående balanser balanserar inte ({formatCurrency(Math.abs(ibDiff))})
</p>
<p className="text-muted-foreground">
Det betyder oftast att exporten från ditt bokföringssystem är ofullständig
t.ex. att skulder saknas eller att föregående års resultat inte är disponerat.
Om du fortsätter bokförs differensen konto 2099 (Årets resultat), vilket
nästan alltid blir fel. Vi rekommenderar att du rättar exporten i källsystemet
och laddar upp filen nytt.
</p>
</div>
</div>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={ackImbalance}
onCheckedChange={(v) => setAckImbalance(v === true)}
className="mt-0.5"
/>
<span className="text-muted-foreground">
Jag förstår att differensen bokförs 2099 och vill fortsätta ändå.
</span>
</label>
</div>
)}
</CardContent>
</Card>
@@ -343,7 +387,7 @@ export default function SIEPreviewStep({
<Button variant="outline" className="min-h-11" onClick={onBack}>
Tillbaka
</Button>
<Button className="min-h-11" onClick={onContinue} disabled={hasBlockingErrors}>
<Button className="min-h-11" onClick={onContinue} disabled={blockContinue}>
{preview.mappingStatus.lowConfidence > 0 || preview.mappingStatus.unmapped > 0
? 'Granska mappningar'
: 'Fortsätt'}
+83 -3
View File
@@ -1,36 +1,56 @@
'use client'
import { useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { CompanyProfileView } from '@/components/settings/CompanyProfileView'
import { refreshCompanyProfileAction } from '@/lib/company/tic-refresh'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
type Snapshot = Parameters<typeof CompanyProfileView>[0]['snapshot']
const ERROR_MESSAGES: Record<string, string> = {
org_number_invalid: 'Ogiltigt organisations- eller personnummer.',
not_found: 'Inga bolagsuppgifter hittades för det numret.',
unauthorized: 'Du har inte behörighet att hämta uppgifter.',
persist_failed: 'Något gick fel. Försök igen.',
}
// Företagsprofil — the cached TIC company snapshot (Bolagsuppgifter), rendered
// as a read-only section on the Företag tab. Fetched client-side (low-traffic
// settings) so it sits alongside the client-rendered company form. RLS scopes
// the read to the user's own company.
// the read to the user's own company. The "Hämta" form lets the user (re)fetch
// live when the snapshot is missing or wrong — the recovery path for an enskild
// firma whose personnummer previously resolved to the wrong entity.
export function CompanyProfileSection() {
const { company } = useCompany()
const [snapshot, setSnapshot] = useState<Snapshot>(null)
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [orgInput, setOrgInput] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!company?.id) return
const supabase = createClient()
let cancelled = false
supabase
.from('companies')
.select('tic_snapshot, tic_snapshot_fetched_at')
.select('tic_snapshot, tic_snapshot_fetched_at, org_number')
.eq('id', company.id)
.maybeSingle()
.then(({ data }) => {
if (cancelled) return
setSnapshot((data?.tic_snapshot as Snapshot) ?? null)
setFetchedAt((data?.tic_snapshot_fetched_at as string | null) ?? null)
setOrgInput((data?.org_number as string | null) ?? '')
setLoading(false)
})
return () => {
@@ -38,7 +58,67 @@ export function CompanyProfileSection() {
}
}, [company?.id])
async function handleFetch(e: React.FormEvent) {
e.preventDefault()
if (!company?.id || submitting) return
setSubmitting(true)
setError(null)
const result = await refreshCompanyProfileAction(company.id, orgInput)
if (result.ok) {
setSnapshot((result.snapshot as Snapshot) ?? null)
setFetchedAt(result.fetchedAt ?? null)
} else {
setError(ERROR_MESSAGES[result.error ?? ''] ?? ERROR_MESSAGES.persist_failed)
}
setSubmitting(false)
}
if (loading) return <Skeleton className="h-48 w-full rounded-lg" />
return <CompanyProfileView snapshot={snapshot} fetchedAt={fetchedAt} />
return (
<div className="space-y-4">
<CompanyProfileView snapshot={snapshot} fetchedAt={fetchedAt} />
<Card>
<CardHeader>
<CardTitle className="text-base">
{snapshot ? 'Uppdatera bolagsuppgifter' : 'Hämta bolagsuppgifter'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleFetch} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tic_org_number">Organisationsnummer eller personnummer</Label>
<div className="flex gap-2">
<Input
id="tic_org_number"
value={orgInput}
onChange={(e) => setOrgInput(e.target.value)}
placeholder="XXXXXX-XXXX"
inputMode="numeric"
autoComplete="off"
className="max-w-xs tabular-nums"
/>
<Button type="submit" disabled={submitting || !orgInput.trim()}>
{submitting ? (
<>
<Loader2 className="animate-spin" />
Hämtar
</>
) : (
'Hämta'
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Uppgifterna hämtas från Bolagsverket. För enskild firma anges
personnumret.
</p>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
</form>
</CardContent>
</Card>
</div>
)
}
+102 -2
View File
@@ -5,7 +5,13 @@ import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Plus } from 'lucide-react'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext'
import { Plus, Lock, Unlock, Loader2 } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import type { FiscalPeriod } from '@/types'
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
@@ -26,10 +32,18 @@ const STATUS_VARIANT: Record<'closed' | 'locked' | 'open', 'secondary' | 'warnin
export function FiscalYearsManager() {
const t = useTranslations('settings_bookkeeping')
const { toast } = useToast()
const { role } = useCompany()
const { dialogProps, confirm } = useDestructiveConfirm()
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [mutatingId, setMutatingId] = useState<string | null>(null)
// Only owners/admins may change a period's lock state. The API enforces this
// too (requireWrite); this just hides controls a viewer/member can't use.
const canManage = role === 'owner' || role === 'admin'
const fetchPeriods = useCallback(async () => {
try {
@@ -50,6 +64,53 @@ export function FiscalYearsManager() {
// Newest first — matches the API's ordering and reads most-recent-at-top.
const sorted = [...periods].sort((a, b) => b.period_start.localeCompare(a.period_start))
async function runLockAction(period: FiscalPeriod, action: 'lock' | 'unlock') {
setMutatingId(period.id)
try {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${period.id}/${action}`, {
method: 'POST',
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
// Surface the backend's message verbatim — e.g. "X affärstransaktion(er)
// saknar bokföring", which tells the user exactly what to fix first.
throw new Error(body?.error?.message || t('fy_action_error'))
}
toast({ title: action === 'lock' ? t('fy_lock_success') : t('fy_unlock_success') })
await fetchPeriods()
} catch (err) {
toast({
title: t('fy_action_error'),
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
} finally {
setMutatingId(null)
}
}
async function handleLock(period: FiscalPeriod) {
const ok = await confirm({
title: t('fy_lock_confirm_title'),
description: t('fy_lock_confirm_body', { name: period.name }),
confirmLabel: t('fy_action_lock'),
cancelLabel: t('fy_confirm_cancel'),
variant: 'warning',
})
if (ok) await runLockAction(period, 'lock')
}
async function handleUnlock(period: FiscalPeriod) {
const ok = await confirm({
title: t('fy_unlock_confirm_title'),
description: t('fy_unlock_confirm_body', { name: period.name }),
confirmLabel: t('fy_action_unlock'),
cancelLabel: t('fy_confirm_cancel'),
variant: 'warning',
})
if (ok) await runLockAction(period, 'unlock')
}
return (
<section className="space-y-4">
<div className="flex items-center justify-between gap-4">
@@ -82,6 +143,7 @@ export function FiscalYearsManager() {
<div className="divide-y divide-border">
{sorted.map((p) => {
const status = periodStatus(p)
const isMutating = mutatingId === p.id
return (
<div key={p.id} className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
@@ -90,7 +152,43 @@ export function FiscalYearsManager() {
{formatDate(p.period_start)} {formatDate(p.period_end)}
</span>
</div>
<Badge variant={STATUS_VARIANT[status]}>{t(`fy_status_${status}`)}</Badge>
<div className="flex items-center gap-3 shrink-0">
<Badge variant={STATUS_VARIANT[status]}>{t(`fy_status_${status}`)}</Badge>
{canManage && status === 'open' && (
<Button
variant="outline"
size="sm"
disabled={isMutating}
onClick={() => handleLock(p)}
>
{isMutating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Lock className="mr-1.5 h-4 w-4" />
{t('fy_action_lock')}
</>
)}
</Button>
)}
{canManage && status === 'locked' && (
<Button
variant="ghost"
size="sm"
disabled={isMutating}
onClick={() => handleUnlock(p)}
>
{isMutating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Unlock className="mr-1.5 h-4 w-4" />
{t('fy_action_unlock')}
</>
)}
</Button>
)}
</div>
</div>
)
})}
@@ -104,6 +202,8 @@ export function FiscalYearsManager() {
periods={periods}
onCreated={fetchPeriods}
/>
<DestructiveConfirmDialog {...dialogProps} />
</section>
)
}
+10 -1
View File
@@ -1,6 +1,6 @@
'use client'
import { useRouter } from 'next/navigation'
import { usePathname, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext'
import {
@@ -22,6 +22,7 @@ import { SettingsShell } from './SettingsShell'
*/
export function SettingsModal({ sectionId }: { sectionId?: string }) {
const router = useRouter()
const pathname = usePathname()
const { company } = useCompany()
const t = useTranslations('settings_modal')
@@ -38,6 +39,14 @@ export function SettingsModal({ sectionId }: { sectionId?: string }) {
if (!open) router.back()
}
// Parallel-route safety net. This modal lives in the @settingsModal slot and
// should only ever show on /settings/* routes. On a soft navigation to a
// non-settings route (e.g. a cross-link inside the modal like "Kontoplan"),
// Next.js can keep this intercepted slot mounted over the new page. Once the
// URL is no longer a settings route, render nothing so those links actually
// leave the modal instead of appearing to do nothing.
if (!pathname.startsWith('/settings')) return null
return (
<Dialog open onOpenChange={onOpenChange}>
<DialogContent
@@ -172,14 +172,7 @@ export function BookkeepingSettingsContent() {
</h2>
<div className="flex flex-col gap-2">
<Link
href="/bookkeeping"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('related_fiscal_year')}
</Link>
<Link
href="/bookkeeping"
href="/bookkeeping?tab=accounts"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
+18 -41
View File
@@ -14,6 +14,7 @@ import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import VatTreatmentSelect from './VatTreatmentSelect'
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
@@ -68,7 +69,6 @@ export default function QuickReviewDialog({
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [showUploadZone, setShowUploadZone] = useState(false)
const [showVatDropdown, setShowVatDropdown] = useState(false)
const [isOpeningDoc, setIsOpeningDoc] = useState(false)
// Mirror of `transaction` so we can patch in a freshly-fetched SEK conversion
// before the user confirms — the verifikation must always be in SEK and the
// engine reads these fields straight off the transaction row.
@@ -78,24 +78,6 @@ export default function QuickReviewDialog({
const preAttachedDocumentId = transaction?.document_id ?? null
const handleOpenAttachedDoc = useCallback(async () => {
if (!preAttachedDocumentId || isOpeningDoc) return
setIsOpeningDoc(true)
try {
const res = await fetch(`/api/documents/${preAttachedDocumentId}`)
if (!res.ok) {
toast({ title: t('open_attached_failed'), variant: 'destructive' })
return
}
const { data } = await res.json()
if (data?.download_url) {
window.open(data.download_url, '_blank', 'noopener,noreferrer')
}
} finally {
setIsOpeningDoc(false)
}
}, [preAttachedDocumentId, isOpeningDoc, toast, t])
// Handle account changes — clear VAT for liability/equity accounts (class 2)
const handleAccountChange = useCallback((account: string) => {
setAccountOverride(account)
@@ -248,7 +230,7 @@ export default function QuickReviewDialog({
}
onOpenChange(o)
}}>
<DialogContent className="max-w-md sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogContent className={preAttachedDocumentId ? 'max-w-6xl max-h-[90vh] overflow-y-auto' : 'max-w-md sm:max-w-lg max-h-[85vh] overflow-y-auto'}>
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>
@@ -256,6 +238,17 @@ export default function QuickReviewDialog({
</DialogDescription>
</DialogHeader>
{/* When a document is pre-attached, show it side-by-side (receipt left,
review right). With no document the wrappers use display:contents so
the dialog collapses to the original single-column layout. */}
<div className={preAttachedDocumentId ? 'grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,520px)]' : 'contents'}>
{preAttachedDocumentId && (
<div className="h-[45vh] lg:sticky lg:top-0 lg:h-[72vh] lg:self-start">
<DocumentViewerPane documentId={preAttachedDocumentId} className="h-full" />
</div>
)}
<div className={preAttachedDocumentId ? 'space-y-4' : 'contents'}>
{/* Transaction summary */}
<div className="flex items-center gap-3 rounded-lg border p-3">
<div
@@ -433,27 +426,9 @@ export default function QuickReviewDialog({
</>
)}
{/* Document either show the doc the inbox attached pre-categorize,
or let the user upload one if none is attached yet. */}
{preAttachedDocumentId ? (
<div className="rounded-lg border flex items-center justify-between px-3 py-2.5 text-sm">
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="font-medium">{t('attached_doc_label')}</span>
<span className="text-xs text-muted-foreground truncate">
{t('attached_doc_source')}
</span>
</div>
<button
type="button"
onClick={handleOpenAttachedDoc}
disabled={isOpeningDoc}
className="text-xs text-primary hover:underline shrink-0"
>
{isOpeningDoc ? t('opening') : t('view')}
</button>
</div>
) : (
{/* No pre-attached document let the user upload one. (When a document
IS pre-attached it's shown in the left preview column instead.) */}
{!preAttachedDocumentId && (
<div className="rounded-lg border">
<button
type="button"
@@ -517,6 +492,8 @@ export default function QuickReviewDialog({
{isProcessing ? t('booking') : rateLoading ? t('fetching_rate') : t('book')}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
@@ -4,11 +4,11 @@ import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, ChevronDown, ChevronUp, FileText, Inbox, Paperclip, X } from 'lucide-react'
import { ArrowUpRight, ArrowDownRight, FileText, Inbox, X } from 'lucide-react'
import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm'
import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker'
@@ -115,7 +115,6 @@ export default function TransactionBookingDialog({
const { toast } = useToast()
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [pickedInboxDocs, setPickedInboxDocs] = useState<AvailableInboxDoc[]>([])
const [showUploadZone, setShowUploadZone] = useState(false)
const [inboxPickerOpen, setInboxPickerOpen] = useState(false)
const [bankAccount, setBankAccount] = useState<string | null>(null)
@@ -215,24 +214,32 @@ export default function TransactionBookingDialog({
setUploadedFiles([])
setPickedInboxDocs([])
setShowUploadZone(false)
onBooked(transactionId, journalEntryId, pinnedDocId)
}
const attachedCount =
uploadedFiles.filter((f) => f.status === 'uploaded').length + pickedInboxDocs.length
// The receipt to show beside the form. A transaction may arrive with a
// pre-linked document; otherwise the user attaches one in-dialog (upload or
// inbox pick) and it appears here as soon as it's available.
const uploadedDoc = uploadedFiles.find((f) => f.status === 'uploaded' && f.id)
const pickedDoc = pickedInboxDocs[0]
const preexistingDocId = transaction.document_id ?? null
const inDialogDocId = uploadedDoc?.id ?? pickedDoc?.document_id ?? null
const currentDocId = preexistingDocId ?? inDialogDocId
const currentDocMime = preexistingDocId ? null : uploadedDoc?.file.type ?? null
const currentDocName = preexistingDocId
? null
: uploadedDoc?.fileName ?? pickedDoc?.file_name ?? null
return (
<Dialog open={open} onOpenChange={(o) => {
if (!o) {
setUploadedFiles([])
setPickedInboxDocs([])
setShowUploadZone(false)
setInboxPickerOpen(false)
}
onOpenChange(o)
}}>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>
@@ -265,99 +272,108 @@ export default function TransactionBookingDialog({
</p>
</div>
{/* Document upload section */}
<div className="rounded-lg border">
<button
type="button"
onClick={() => setShowUploadZone(!showUploadZone)}
className="flex items-center justify-between w-full px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{t('doc_label')}</span>
{attachedCount > 0 && (
<span className="text-xs text-muted-foreground">
{t('doc_attached_count', { count: attachedCount })}
</span>
)}
</div>
{showUploadZone ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{showUploadZone && (
<div className="px-3 pb-3 space-y-2">
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
compact
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,520px)]">
{/* Document column sticky on desktop so the receipt stays visible
while the form scrolls; stacks above the form on smaller screens. */}
<div className="flex h-[45vh] flex-col gap-3 lg:sticky lg:top-0 lg:h-[72vh] lg:self-start">
{currentDocId ? (
<DocumentViewerPane
documentId={currentDocId}
mime={currentDocMime}
fileName={currentDocName}
className="min-h-0 flex-1"
/>
{pickedInboxDocs.length > 0 && (
<div className="space-y-1">
{pickedInboxDocs.map((doc) => (
<div
key={doc.document_id}
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50"
>
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate flex-1">
{doc.supplier_name ?? doc.file_name}
</span>
{doc.amount != null && (
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{formatCurrency(doc.amount, doc.currency ?? 'SEK')}
</span>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
aria-label={t('doc_picked_remove')}
onClick={() =>
setPickedInboxDocs((prev) =>
prev.filter((d) => d.document_id !== doc.document_id),
)
}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setInboxPickerOpen(true)}
>
<Inbox className="h-4 w-4 mr-2" />
{t('doc_pick_existing')}
</Button>
</div>
)}
</div>
) : (
<div className="min-h-0 flex-1">
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
/>
</div>
)}
{bankAccount !== null && (
<JournalEntryForm
key={`${transaction.id}-${preselectedTemplate?.id ?? 'default'}-${bankAccount}`}
embedded
initialLines={
preselectedTemplate
? buildInitialLinesFromTemplate(transaction, preselectedTemplate, bankAccount)
: buildInitialLines(transaction, t('bank_line_description'), bankAccount)
}
initialDate={transaction.date}
initialDescription={transaction.description}
submitUrl={`/api/transactions/${transaction.id}/book`}
sourceType="bank_transaction"
sourceId={transaction.id}
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
/>
)}
{/* Attach controls only when the transaction has no pre-linked
document (a pre-linked one is already the verifikat's underlag). */}
{!preexistingDocId && (
<div className="shrink-0 space-y-2">
{pickedInboxDocs.length > 0 && (
<div className="space-y-1">
{pickedInboxDocs.map((doc) => (
<div
key={doc.document_id}
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50"
>
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate flex-1">
{doc.supplier_name ?? doc.file_name}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
aria-label={t('doc_picked_remove')}
onClick={() =>
setPickedInboxDocs((prev) =>
prev.filter((d) => d.document_id !== doc.document_id),
)
}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setInboxPickerOpen(true)}
>
<Inbox className="h-4 w-4 mr-2" />
{t('doc_pick_existing')}
</Button>
{inDialogDocId && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setUploadedFiles([])
setPickedInboxDocs([])
}}
>
<X className="h-3.5 w-3.5 mr-1.5" />
{t('doc_clear')}
</Button>
)}
</div>
</div>
)}
</div>
{/* Booking form */}
<div className="space-y-4">
{bankAccount !== null && (
<JournalEntryForm
key={`${transaction.id}-${preselectedTemplate?.id ?? 'default'}-${bankAccount}`}
embedded
initialLines={
preselectedTemplate
? buildInitialLinesFromTemplate(transaction, preselectedTemplate, bankAccount)
: buildInitialLines(transaction, t('bank_line_description'), bankAccount)
}
initialDate={transaction.date}
initialDescription={transaction.description}
submitUrl={`/api/transactions/${transaction.id}/book`}
sourceType="bank_transaction"
sourceId={transaction.id}
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
/>
)}
</div>
</div>
<InboxDocumentPicker
open={inboxPickerOpen}
+14 -3
View File
@@ -1,6 +1,6 @@
'use client'
import { ReactNode } from 'react'
import { ReactNode, useRef } from 'react'
import {
Dialog,
DialogContent,
@@ -22,6 +22,9 @@ interface ConfirmationDialogProps {
confirmLabel?: string
extraActions?: ReactNode
children: ReactNode
// When true, initial focus lands on the confirm button so Enter fires the
// primary action. Opt-in: never arm Enter on unrelated/destructive dialogs.
autoFocusConfirm?: boolean
}
export function ConfirmationDialog({
@@ -34,10 +37,18 @@ export function ConfirmationDialog({
confirmLabel = 'Bekräfta & skapa',
extraActions,
children,
autoFocusConfirm,
}: ConfirmationDialogProps) {
const confirmRef = useRef<HTMLButtonElement>(null)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl border-t-2 border-primary p-0 gap-0 max-h-[95dvh] sm:max-h-[90dvh] flex flex-col">
<DialogContent
className="sm:max-w-2xl border-t-2 border-primary p-0 gap-0 max-h-[95dvh] sm:max-h-[90dvh] flex flex-col"
onOpenAutoFocus={autoFocusConfirm ? (e) => {
e.preventDefault()
confirmRef.current?.focus()
} : undefined}
>
<DialogHeader className="px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 shrink-0">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 shrink-0">
@@ -72,7 +83,7 @@ export function ConfirmationDialog({
Tillbaka
</Button>
{extraActions}
<Button onClick={onConfirm} disabled={isSubmitting} className="min-h-11 w-full sm:w-auto">
<Button ref={confirmRef} onClick={onConfirm} disabled={isSubmitting} className="min-h-11 w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -18,6 +18,8 @@ import {
isSessionExpiredResponse,
SessionExpiredError,
getAllTransactionsWithRaw,
getPreferredAuthMethod,
startAuthorization,
} from '../lib/api-client'
import { enableBankingExtension } from '../index'
import { syncAccountTransactions } from '../lib/sync'
@@ -369,3 +371,86 @@ describe('POST /connect (enable-banking) — psu_type persistence', () => {
expect(insertSpy.mock.calls[0][0]).toMatchObject({ psu_type: 'personal' })
})
})
describe('auth_method selection (Handelsbanken Mobile BankID)', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
function stubAspsps(aspsps: unknown[]) {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ aspsps }),
text: async () => '',
}))
)
}
it('picks the DECOUPLED (Mobile BankID) method when the bank exposes one', async () => {
// Handelsbanken's real shape: BankID is decoupled + hidden, Redirect is the
// visible default. We must pin BankID or corporate PSUs fail after BankID.
stubAspsps([
{
name: 'Handelsbanken',
country: 'SE',
bic: 'HANDSESS',
auth_methods: [
{ name: 'BANKID', approach: 'DECOUPLED', hidden_method: true, title: 'Bank ID' },
{ name: 'REDIRECT', approach: 'REDIRECT', hidden_method: false, title: 'Redirect' },
],
},
])
expect(await getPreferredAuthMethod('Handelsbanken', 'SE', 'business')).toBe('BANKID')
})
it('returns undefined (ASPSP default) when the bank has no decoupled method', async () => {
stubAspsps([
{ name: 'Nordea', country: 'SE', auth_methods: [{ name: 'REDIRECT', approach: 'REDIRECT' }] },
])
expect(await getPreferredAuthMethod('Nordea', 'SE', 'personal')).toBeUndefined()
})
it('returns undefined when the bank is not found in the ASPSP list', async () => {
stubAspsps([])
expect(await getPreferredAuthMethod('Handelsbanken', 'SE', 'business')).toBeUndefined()
})
it('startAuthorization sends auth_method in the request body when provided', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ url: 'https://bank.example/auth', authorization_id: 'auth-1' }),
text: async () => '',
}))
vi.stubGlobal('fetch', fetchMock)
await startAuthorization('Handelsbanken', 'SE', 'https://app/cb', 'state-1', 'business', 'BANKID')
const body = JSON.parse((fetchMock.mock.calls[0][1] as { body: string }).body)
expect(body.auth_method).toBe('BANKID')
expect(body.psu_type).toBe('business')
})
it('startAuthorization omits auth_method when none is provided', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ url: 'https://bank.example/auth', authorization_id: 'auth-1' }),
text: async () => '',
}))
vi.stubGlobal('fetch', fetchMock)
await startAuthorization('Nordea', 'SE', 'https://app/cb', 'state-1', 'personal')
const body = JSON.parse((fetchMock.mock.calls[0][1] as { body: string }).body)
expect('auth_method' in body).toBe(false)
})
})
+18 -2
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import {
startAuthorization,
getASPSPs,
getPreferredAuthMethod,
deleteSession,
isSandboxMode,
SessionExpiredError,
@@ -178,11 +179,24 @@ export const enableBankingExtension: Extension = {
}
}
// Resolve the bank's preferred auth method. Handelsbanken (and some
// other Swedish banks) expose Mobile BankID only as a hidden DECOUPLED
// method; without this, Enable Banking defaults to the REDIRECT method,
// which for Handelsbanken *corporate* PSUs cannot complete with Mobile
// BankID — the user approves in the app and then hits an error. Returns
// undefined for banks with no decoupled method, leaving them untouched.
const authMethod = await getPreferredAuthMethod(
resolvedAspspName,
resolvedAspspCountry,
psuType
)
log.info('[enable-banking] Starting bank connection', {
user_id: user.id,
bank: resolvedAspspName,
country: resolvedAspspCountry,
psu_type: psuType,
auth_method: authMethod ?? '(aspsp default)',
reconnect: isReconnect,
})
@@ -293,7 +307,8 @@ export const enableBankingExtension: Extension = {
resolvedAspspCountry,
redirectUrl,
oauthState,
psuType
psuType,
authMethod
)
// Record the bank's authorization_id for audit/traceability. The
@@ -325,7 +340,8 @@ export const enableBankingExtension: Extension = {
resolvedAspspCountry,
redirectUrl,
oauthState,
psuType
psuType,
authMethod
)
const { data: connection, error } = await supabase
@@ -31,12 +31,19 @@ export interface ASPSP {
bic?: string
beta?: boolean
max_consent_validity?: number
available_auth_methods?: AuthMethod[]
// Enable Banking returns this field as `auth_methods` on the ASPSP object.
auth_methods?: AuthMethod[]
}
export interface AuthMethod {
name: string
title?: string
// How the SCA is performed. Mobile BankID at several Swedish banks is a
// DECOUPLED method; the visible default is often a REDIRECT method.
approach?: 'REDIRECT' | 'DECOUPLED' | 'EMBEDDED'
// When true, Enable Banking only uses this method if it is requested
// explicitly via auth_method (it is not the implicit default).
hidden_method?: boolean
psu_types?: ('personal' | 'business')[]
}
@@ -333,6 +340,40 @@ export async function getASPSPs(country: string = 'SE', psuType?: 'personal' | '
return data.aspsps || []
}
/**
* Resolve the auth_method we should request for a given bank, or undefined to
* let Enable Banking use the ASPSP's visible default.
*
* Why: several Swedish ASPSPs (notably Handelsbanken) expose Mobile BankID only
* as a DECOUPLED method flagged hidden_method=true. When we send no auth_method,
* Enable Banking falls back to the visible REDIRECT method which for
* Handelsbanken *corporate* PSUs does not support Mobile BankID, so the consent
* fails right after the user approves in the BankID app ("fel efter BankID").
* Pinning the decoupled (Mobile BankID) method makes the flow work for both
* business and personal PSUs. We return undefined when the bank exposes no
* decoupled method or the lookup fails, so banks that already work are untouched.
*/
export async function getPreferredAuthMethod(
aspspName: string,
country: string,
psuType: 'personal' | 'business'
): Promise<string | undefined> {
try {
const aspsps = await getASPSPs(country, psuType)
const aspsp = aspsps.find((a) => a.name === aspspName)
const decoupled = aspsp?.auth_methods?.find((m) => m.approach === 'DECOUPLED')
return decoupled?.name
} catch (error) {
console.error('[enable-banking] getPreferredAuthMethod failed; using ASPSP default', {
aspspName,
country,
psuType,
error: error instanceof Error ? error.message : String(error),
})
return undefined
}
}
/**
* Get list of supported banks (legacy format for backward compatibility)
*/
@@ -367,19 +408,30 @@ export async function getSupportedBanks(): Promise<Bank[]> {
* @param redirectUrl - URL to redirect user after bank authorization
* @param state - State parameter returned in callback (e.g., user ID)
* @param psuType - Type of user: 'personal' or 'business'
* @param authMethod - Optional Enable Banking auth_method name. When omitted,
* Enable Banking uses the ASPSP's visible default method. See
* getPreferredAuthMethod for why we pin Mobile BankID at some banks.
*/
export async function startAuthorization(
aspspName: string,
aspspCountry: string,
redirectUrl: string,
state: string,
psuType: 'personal' | 'business' = 'personal'
psuType: 'personal' | 'business' = 'personal',
authMethod?: string
): Promise<AuthResponse> {
// Calculate consent validity (90 days)
const validUntil = new Date()
validUntil.setDate(validUntil.getDate() + 90)
const requestBody = {
const requestBody: {
access: { valid_until: string }
aspsp: { name: string; country: string }
state: string
redirect_url: string
psu_type: 'personal' | 'business'
auth_method?: string
} = {
access: {
valid_until: validUntil.toISOString()
},
@@ -391,6 +443,9 @@ export async function startAuthorization(
redirect_url: redirectUrl,
psu_type: psuType
}
if (authMethod) {
requestBody.auth_method = authMethod
}
const response = await authenticatedFetch('/auth', {
method: 'POST',
+45 -1
View File
@@ -24,7 +24,8 @@ import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { CreateSupplierInvoiceSchema, BookInboxItemDirectlySchema } from '@/lib/api/schemas'
import { CreateSupplierInvoiceSchema, BookInboxItemDirectlySchema, BulkBookInboxSchema } from '@/lib/api/schemas'
import { bulkBookMatchedInboxItems } from '@/lib/transactions/categorize-core'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
import { simpleParser } from 'mailparser'
@@ -2140,6 +2141,49 @@ export const invoiceInboxExtension: Extension = {
})
},
},
// ── Bulk-book selected inbox items (Modell B) ─────────────
// "Bokför valda" in the Underlag selection bar. Each selected item is
// booked against its matched bank transaction (which already carries the
// SEK amount) using one shared category + VAT treatment — individual
// verifikat, not a samlingsverifikation. Unmatched / already-booked /
// supplier-invoice-linked items are skipped, not errored, so the batch is
// resilient. Reuses the same categorize core as the single-item agent flow,
// so reverse-charge moms on foreign services is handled correctly.
{
method: 'POST',
path: '/items/bulk-book',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
let body: z.infer<typeof BulkBookInboxSchema>
try {
const json = await request.json()
body = BulkBookInboxSchema.parse(json)
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Invalid request body' },
{ status: 400 }
)
}
const { booked, skipped } = await bulkBookMatchedInboxItems(
ctx.supabase,
ctx.userId,
ctx.companyId,
body,
)
return NextResponse.json({
data: {
booked_count: booked.length,
skipped_count: skipped.length,
booked,
skipped,
},
})
},
},
],
}
@@ -59,9 +59,14 @@ describe('tools/list payload size guard', () => {
// which have a pre-flight; gnubok_get_agent_briefing also gained a `company`
// identity block in its outputSchema. This is wire data the agent depends
// on, not trimmable prose — hence a bump rather than a description trim.
// * 38K → 40K as the catalog grew from 92 to 103 tools (gnubok_link_document_
// to_voucher #804, gnubok_bulk_book_inbox_items, the categorize-core additions,
// plus per-line supplier-invoice overrides). Each new tool carries its
// inputSchema + staging _meta; the growth is genuine wire data, not prose,
// so descriptions are already at their trimmed floor (~180220 chars).
// Long-term answer to growth is leaning harder on gnubok_search_tools — if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(38_000)
expect(approxTokens).toBeLessThan(40_000)
})
})
+120
View File
@@ -5415,6 +5415,126 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_bulk_book_inbox_items',
title: 'Bulk-Book Underlag',
description: 'Bulk-book N selected Underlag (Dokumentinkorgen) against their matched bank transactions with one shared category + VAT treatment. Set reverse_charge for foreign SaaS. Unmatched/booked items are skipped. Stages one approval.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
item_ids: {
type: 'array',
minItems: 1,
maxItems: 200,
items: { type: 'string' },
description: "Inbox item UUIDs to book — the user's selection in the Underlag view.",
},
category: { type: 'string', description: 'Shared transaction category applied to every item', enum: [...VALID_CATEGORIES] },
vat_treatment: { type: 'string', description: 'Shared VAT treatment. Set reverse_charge for foreign services (omvänd skattskyldighet) where the seller did NOT charge VAT — typical for USD/EUR SaaS subscriptions like Cursor/Anysphere. Defaults to standard_25.', enum: [...VALID_VAT_TREATMENTS] },
vat_amount: { type: 'number', exclusiveMinimum: 0, description: "The underlag's exact moms override; only valid with a rate-based vat_treatment. Rarely needed in bulk — all items share one value." },
notes: { type: 'string', description: 'Audit-trail note appended to every verifikation. Keep under 200 chars.' },
allow_duplicate: { type: 'boolean', description: 'Override the per-item duplicate-booking guard (default false). Set true only after the user confirms these bank lines are genuinely separate events.' },
},
required: ['item_ids', 'category'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const itemIds = args.item_ids as string[]
if (!Array.isArray(itemIds) || itemIds.length === 0) throw new Error('item_ids is required (non-empty)')
const vatAmount = typeof args.vat_amount === 'number' && Number.isFinite(args.vat_amount)
? args.vat_amount
: undefined
const notes = typeof args.notes === 'string' && args.notes.trim().length > 0
? args.notes.trim()
: undefined
// Pre-flight: classify the selection so the preview (and the agent) sees
// the real shape before staging. Tenant isolation via company_id.
const { data: items, error } = await supabase
.from('invoice_inbox_items')
.select('id, matched_transaction_id, created_journal_entry_id, created_supplier_invoice_id')
.in('id', itemIds)
.eq('company_id', companyId)
if (error) throw new Error(`Kunde inte läsa underlagen: ${error.message}`)
const found = new Set((items ?? []).map((it) => it.id as string))
const resolved = (items ?? []).filter((it) => it.created_journal_entry_id || it.created_supplier_invoice_id)
const bookable = (items ?? []).filter(
(it) => it.matched_transaction_id && !it.created_journal_entry_id && !it.created_supplier_invoice_id,
)
const notMatched = (items ?? []).filter(
(it) => !it.matched_transaction_id && !it.created_journal_entry_id && !it.created_supplier_invoice_id,
).length
const alreadyBooked = resolved.length
const notFound = itemIds.filter((id) => !found.has(id)).length
if (bookable.length === 0) {
throw new Error(
`Inga av de ${itemIds.length} valda underlagen kan bokföras: ${notMatched} saknar matchad banktransaktion, ` +
`${alreadyBooked} är redan bokförda, ${notFound} hittades inte. Matcha underlagen mot en banktransaktion först ` +
`(gnubok_match_transaction_to_invoice eller "Matcha mot transaktion" i Dokumentinkorgen).`,
)
}
// Resolve matched-tx dates/amounts for the period envelope + an aggregate
// total. preview_data carries only aggregate counts + sum — no per-item
// PII (GDPR Art.25), same rationale as gnubok_bulk_book_transactions.
const txIds = bookable.map((it) => it.matched_transaction_id as string)
const { data: txs } = await supabase
.from('transactions')
.select('id, date, amount, currency, amount_sek, exchange_rate')
.in('id', txIds)
.eq('company_id', companyId)
const txDates = (txs ?? []).map((t) => t.date as string).filter(Boolean).sort()
const earliestDate = txDates[0]
const totalSek = (txs ?? []).reduce((s, t) => {
const cur = String(t.currency ?? 'SEK').toUpperCase()
const sek = cur === 'SEK'
? Math.abs(Number(t.amount))
: Math.abs(Number(t.amount_sek ?? Number(t.amount) * Number(t.exchange_rate ?? 1)))
return s + (Number.isFinite(sek) ? sek : 0)
}, 0)
return stagePendingOperation(supabase, companyId, userId, 'bulk_book_inbox_items',
`Bulkbokför ${bookable.length} underlag`,
{
// Stage only the bookable items — the executor re-checks each and
// skips any that changed state between staging and approval.
item_ids: bookable.map((it) => it.id as string),
category: args.category,
vat_treatment: args.vat_treatment ?? null,
vat_amount: vatAmount ?? null,
notes: notes ?? null,
allow_duplicate: args.allow_duplicate === true,
},
{
item_count: itemIds.length,
bookable_count: bookable.length,
will_skip_count: notMatched + alreadyBooked + notFound,
not_matched: notMatched,
already_booked: alreadyBooked,
not_found: notFound,
total_sek: Math.round(totalSek * 100) / 100,
category: args.category,
vat_treatment: args.vat_treatment ?? null,
},
actor,
{
description: 'After approval each underlag is booked against its matched transaction. Verify with gnubok_list_inbox_items or gnubok_query_journal.',
tool: 'gnubok_list_inbox_items',
},
earliestDate ? { dateForPeriodCheck: earliestDate } : {},
)
},
},
{
name: 'gnubok_find_voucher_candidates_for_invoice',
title: 'Find Voucher Candidates (Invoice)',
@@ -122,6 +122,31 @@ describe('tic-client', () => {
expect(calledUrl).toContain('q%3D5560360793')
})
// Enskild firma: Lens only resolves the 12-digit (century-prefixed) form,
// so a 10-digit personnummer must be expanded before the query. Björn's
// 860224-5618 → born 1986 → prefix 19.
it('expands a 10-digit personnummer to the 12-digit form before querying', async () => {
const mockFetch = vi.mocked(fetch)
mockFetch.mockResolvedValue(new Response(JSON.stringify({ found: 0, hits: [] })))
await searchCompanyByOrgNumber('860224-5618')
const calledUrl = mockFetch.mock.calls[0][0] as string
expect(calledUrl).toContain('q%3D198602245618')
})
// An organisationsnummer (3rd digit >= 2) must NOT be century-prefixed —
// Lens resolves an AB from its bare 10-digit number.
it('does not expand an organisationsnummer', async () => {
const mockFetch = vi.mocked(fetch)
mockFetch.mockResolvedValue(new Response(JSON.stringify({ found: 0, hits: [] })))
await searchCompanyByOrgNumber('5595719864')
const calledUrl = mockFetch.mock.calls[0][0] as string
expect(calledUrl).toContain('q%3D5595719864')
})
it('returns null when no hits', async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ found: 0, hits: [], facet_counts: [] }))
@@ -130,6 +155,75 @@ describe('tic-client', () => {
const result = await searchCompanyByOrgNumber('000000-0000')
expect(result).toBeNull()
})
// TIC v2 is a Typesense fuzzy search: an unindexed number (e.g. an
// enskild firma's personnummer that Bolagsverket never registered as a
// company) comes back as the closest lookalike — a different, unrelated
// entity. We must reject it rather than return a stranger's company.
it('rejects a fuzzy near-miss whose registrationNumber differs from the query', async () => {
const lookalike = {
companyId: 3610062,
registrationNumber: '8024245618', // digit-shuffle of the requested number
names: [{ nameOrIdentifier: 'A FOUNDATION', companyNamingType: 'name' }],
legalEntityType: 'Annan stiftelse',
registrationDate: 0,
isCeased: false,
}
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ found: 1, hits: [{ document: lookalike }], facet_counts: [] }))
)
const result = await searchCompanyByOrgNumber('8602245618')
expect(result).toBeNull()
})
// Lens stores an enskild firma under a 16-digit registration number that
// embeds the 10-digit personnummer. Containment must be accepted, or every
// correctly-resolved sole trader would be wrongly rejected.
it('accepts a 16-digit enskild-firma number that embeds the requested personnummer', async () => {
const soleTrader = {
companyId: 6704455,
registrationNumber: '2002011732750001', // contains 0201173275
names: [{ nameOrIdentifier: 'Sole Trader', companyNamingType: 'name' }],
legalEntityType: 'Enskild näringsidkare',
registrationDate: 0,
isCeased: false,
}
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ found: 1, hits: [{ document: soleTrader }], facet_counts: [] }))
)
const result = await searchCompanyByOrgNumber('0201173275')
expect(result).toEqual(soleTrader)
})
// A real match may not always rank first; accept it wherever it appears.
it('accepts an exact match even when it is not the top-ranked hit', async () => {
const nearMiss = {
companyId: 1,
registrationNumber: '5560360799',
names: [{ nameOrIdentifier: 'Near Miss AB', companyNamingType: 'name' }],
legalEntityType: 'AB',
registrationDate: 0,
isCeased: false,
}
const exact = {
companyId: 2,
registrationNumber: '5560360793',
names: [{ nameOrIdentifier: 'Exact AB', companyNamingType: 'name' }],
legalEntityType: 'AB',
registrationDate: 0,
isCeased: false,
}
vi.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({ found: 2, hits: [{ document: nearMiss }, { document: exact }], facet_counts: [] })
)
)
const result = await searchCompanyByOrgNumber('556036-0793')
expect(result).toEqual(exact)
})
})
describe('getBankAccounts', () => {
+65 -4
View File
@@ -121,20 +121,81 @@ export async function ticApiFetch<T>(endpoint: string): Promise<T | null> {
}
}
/** Search for a company by org number. Returns the first matching document or null. */
/**
* Expand a 10-digit personnummer to the 12-digit (century-prefixed) form that
* Lens requires to resolve an enskild firma. Lens stores a sole trader under a
* 16-digit registration number derived from the 12-digit personnummer; the bare
* 10-digit form only ever fuzzy-matches, which is how a personnummer once
* resolved to an unrelated foundation.
*
* Detection is unambiguous: a Swedish organisationsnummer always has a 3rd digit
* >= 2, whereas a personnummer's 3rd+4th digits are the birth month (01-12). So
* a 10-digit number whose 3rd digit is 0/1 and whose month reads 01-12 is a
* personnummer and gets the century prefix; AB / förening / handelsbolag numbers
* pass through untouched. Century (19 vs 20) uses the same heuristic as
* `formatRedovisare`: a two-digit year greater than the current one is 1900s.
*/
function toLensQueryNumber(cleaned: string): string {
if (!/^\d{10}$/.test(cleaned)) return cleaned
const month = parseInt(cleaned.substring(2, 4), 10)
const isPersonnummer = cleaned[2] <= '1' && month >= 1 && month <= 12
if (!isPersonnummer) return cleaned
const yearDigits = parseInt(cleaned.substring(0, 2), 10)
const currentTwoDigitYear = new Date().getFullYear() % 100
const prefix = yearDigits > currentTwoDigitYear ? '19' : '20'
return `${prefix}${cleaned}`
}
/**
* Search for a company by org number. Returns the matching document or null.
*
* TIC v2 is a Typesense index and `query_by=registrationNumber` is a
* typo-tolerant full-text search: it returns ranked *near-misses*, not only
* exact hits. An identifier that isn't in the index therefore comes back as
* the closest lookalike number a completely unrelated entity (this is how
* an enskild firma's personnummer once resolved to a random foundation).
*
* We validate the returned `registrationNumber` against the requested number
* before accepting a hit. The check is *containment*, not strict equality,
* because Lens stores an enskild firma under a 16-digit registration number
* that embeds the 10-digit personnummer (e.g. request `0201173275`
* Lens `2002011732750001`). Requiring exact equality would wrongly reject
* every correctly-resolved sole trader. A genuine mismatch (Björn's case)
* has neither number containing the other, so it is still discarded and the
* caller sees a clean "not found" instead of a stranger's company.
*
* The upstream request is intentionally unchanged the fuzzy `q=` call is
* what every working lookup already uses; we only tighten which hit we accept.
*/
export async function searchCompanyByOrgNumber(
orgNumber: string
): Promise<TICCompanyDocument | null> {
const cleaned = orgNumber.replace(/[\s-]/g, '')
const data = await ticApiFetch<TICCompanyResponse>(
`/search-public/companies?q=${cleaned}&query_by=registrationNumber`
`/search-public/companies?q=${toLensQueryNumber(cleaned)}&query_by=registrationNumber`
)
if (!data || data.found === 0 || !data.hits?.[0]) {
if (!data || data.found === 0 || !data.hits?.length) {
return null
}
return data.hits[0].document
// A real match either equals the requested number or embeds it (16-digit
// enskild-firma number containing the 10-digit personnummer). Guard the
// containment branch with a length floor so a short/garbage query can't
// coincidentally substring-match an unrelated number — all real Swedish
// identifiers are >= 10 digits.
const numbersRelated = (returned: string): boolean => {
if (returned === cleaned) return true
if (cleaned.length < 10 || returned.length < 10) return false
return returned.includes(cleaned) || cleaned.includes(returned)
}
const match = data.hits.find((hit) => {
const returned = hit.document?.registrationNumber?.replace(/[\s-]/g, '') ?? ''
return returned.length > 0 && numbersRelated(returned)
})
return match?.document ?? null
}
/**
@@ -0,0 +1,139 @@
import { describe, it, expect } from 'vitest'
import { verifikationDraft } from '../verifikation-draft'
// verifikation.draft is the assistant entry point on the manual bookkeeping
// surfaces (Bokföring → "Skapa med assistent", the Ny verifikat-dialog handoff,
// and a draft verifikat's own page). These tests lock in the two things that
// make it actually useful:
// 1. it carries the underlag-reading tools its ground rules already reference
// (the intent shipped without them — instructions for tools it couldn't
// call), and
// 2. the prompt drives "read the underlag → suggest accounts → stage a
// voucher", while guarding against duplicating an existing draft (there's
// no MCP edit-draft tool, so for an existing draft the agent must advise,
// not stage a second verifikat).
type Captured = Parameters<typeof verifikationDraft.promptTemplate>[0]['captured']
function baseCaptured(overrides: Partial<Captured> = {}): Captured {
return {
entry: null,
current_lines: [],
period_status: null,
description_hint: null,
underlag: [],
...overrides,
}
}
function renderPrompt(overrides: Partial<Captured> = {}, profileSummary: string | null = null): string {
return verifikationDraft.promptTemplate({
captured: baseCaptured(overrides),
profileSummary,
activeMemory: [],
})
}
describe('verifikation.draft tool scope', () => {
it('carries the underlag-reading tools its ground rules reference', () => {
// shared-rules.ts tells the agent to call gnubok_list_inbox_items /
// gnubok_get_document_content before proposing a booking. The intent
// originally omitted them, so those instructions were dead. Lock them in.
expect(verifikationDraft.tools).toContain('gnubok_get_document_content')
expect(verifikationDraft.tools).toContain('gnubok_list_inbox_items')
expect(verifikationDraft.tools).toContain('gnubok_get_inbox_item')
expect(verifikationDraft.tools).toContain('gnubok_list_unmatched_documents')
})
it('can still stage the voucher', () => {
expect(verifikationDraft.tools).toContain('gnubok_create_voucher')
})
})
describe('verifikation.draft prompt template', () => {
it('renders the shared ground rules (underlag-first discipline)', () => {
const out = renderPrompt()
expect(out).toContain('UNDERLAG FÖRST')
})
it('tells the agent to read the underlag before proposing accounts', () => {
const out = renderPrompt()
expect(out).toContain('UNDERLAG FÖRST.')
expect(out).toContain('gnubok_list_inbox_items')
expect(out).toContain('gnubok_get_document_content')
})
it('stages a new voucher and links the inbox underlag to it', () => {
const out = renderPrompt()
expect(out).toContain('gnubok_create_voucher')
// The kvitto must follow the booking — create_voucher takes inbox_item_id
// and attaches the OCR document on commit.
expect(out).toContain('inbox_item_id')
})
it('guards against duplicating an existing draft', () => {
// No MCP tool edits a draft in place, so for an existing draft the agent
// must advise (suggest accounts / check balance) rather than stage a
// second verifikat — otherwise "help me finish this draft" creates a dupe.
const out = renderPrompt({
entry: { id: 'e1', entry_date: '2026-05-01', description: 'Utkast', status: 'draft' },
})
expect(out).toContain('Staga INTE en ny verifikation för ett utkast som redan finns')
})
it('surfaces extracted underlag fields so the agent does not re-ask', () => {
const out = renderPrompt({
entry: { id: 'e1', entry_date: '2026-05-01', description: 'Inköp', status: 'draft' },
underlag: [
{
document_id: 'doc-1',
file_name: 'kvitto.pdf',
merchant_name: 'Clas Ohlson',
receipt_date: '2026-05-01',
total_amount: 499,
vat_amount: 99.8,
currency: 'SEK',
raw_extraction: null,
},
],
})
expect(out).toContain('UNDERLAG kopplat till verifikationen')
expect(out).toContain('Clas Ohlson')
expect(out).toContain('document_id=doc-1')
})
it('warns when the entry sits in a locked period', () => {
const out = renderPrompt({
entry: { id: 'e1', entry_date: '2025-12-31', description: 'Inköp', status: 'draft' },
period_status: { period_id: 'p1', status: 'locked', lock_date: '2025-12-31' },
})
expect(out).toContain('PERIODEN ÄR LÅST')
})
it('flags an unbalanced set of existing lines', () => {
const out = renderPrompt({
entry: { id: 'e1', entry_date: '2026-05-01', description: 'Inköp', status: 'draft' },
current_lines: [
{ account_number: '5410', debit_amount: 500, credit_amount: null, description: 'Förbrukning' },
{ account_number: '1930', debit_amount: null, credit_amount: 400, description: 'Bank' },
],
})
expect(out).toContain('debet ≠ kredit')
})
})
describe('verifikation.draft capture', () => {
it('returns an empty draft (with an underlag array) when no entry id is given', async () => {
// The fresh-start path (Bokföring → "Skapa med assistent") passes no
// journal_entry_id and must not touch the database — the agent discovers
// underlag itself via the inbox tools.
const captured = await verifikationDraft.capture(
{ description: 'Köp av router' },
{ supabase: {} as never, userId: 'u1', companyId: 'c1' },
)
expect(captured.entry).toBeNull()
expect(captured.current_lines).toEqual([])
expect(captured.underlag).toEqual([])
expect(captured.description_hint).toBe('Köp av router')
})
})
+197
View File
@@ -0,0 +1,197 @@
import { defineAgentIntent } from './types'
import { SONNET_MODEL, THINKING_BUDGET_STANDARD } from '@/lib/agent/composer/client'
// inbox.bulk-book — "Fråga assistenten" on a multi-selection in the Underlag
// view (Dokumentinkorgen). Unlike transaction.categorization (which keys off the
// single previewed item), this intent receives the user's CHECKBOX selection
// (selectedIds) so Lena acts on exactly what the user marked — not whatever
// happens to be open in the preview pane.
//
// Booking model (Modell B): each selected item is booked against its matched
// bank transaction with one shared category + VAT treatment via
// gnubok_bulk_book_inbox_items (which stages one approval). The agent groups the
// selection by vendor/kind and books each homogeneous group, detecting
// reverse-charge for foreign services.
interface InboxBulkBookArgs {
item_ids: string[]
}
interface CapturedInboxItem {
item_id: string
// bookable = matched to a tx and not yet booked; not_matched = needs a bank
// match first; already_booked = resolved (skip).
status: 'bookable' | 'not_matched' | 'already_booked'
merchant_name: string | null
invoice_date: string | null
total: number | null
vat_amount: number | null
currency: string | null
tx_date: string | null
tx_amount_sek: number | null
tx_description: string | null
}
interface CapturedInboxBulk {
items: CapturedInboxItem[]
bookable_count: number
}
// SEK magnitude of a (usually-SEK) bank transaction. Foreign rows are
// normalised via their stored amount_sek/exchange_rate.
function txSek(tx: {
amount: number | null
currency: string | null
amount_sek: number | null
exchange_rate: number | null
}): number | null {
if (tx.amount == null) return null
const cur = String(tx.currency ?? 'SEK').toUpperCase()
if (cur === 'SEK') return Math.abs(Number(tx.amount))
const sek = tx.amount_sek ?? Number(tx.amount) * Number(tx.exchange_rate ?? 1)
return Number.isFinite(sek) ? Math.abs(Number(sek)) : null
}
export const inboxBulkBook = defineAgentIntent<InboxBulkBookArgs, CapturedInboxBulk>({
id: 'inbox.bulk-book',
buttonLabel: 'Fråga assistenten',
sheetTitle: 'Bulkbokför underlag',
atoms: {
mode: 'declarative',
horizontal: ['swedish-vat', 'swedish-accounting-compliance', 'swedish-invoice-compliance'],
includeCompanyVertical: true,
includeCompanyModifiers: true,
},
tools: [
'gnubok_bulk_book_inbox_items',
'gnubok_categorize_transaction',
'gnubok_query_journal',
'gnubok_get_document_content',
'gnubok_list_inbox_items',
'gnubok_load_skill',
'gnubok_search_tools',
'gnubok_remember_fact',
'gnubok_forget_fact',
],
model: SONNET_MODEL,
// Reason before proposing — group the selection and work out category + VAT
// treatment in the thinking channel, so the visible reply is one short
// motivation, not a play-by-play.
thinking: { budgetTokens: THINKING_BUDGET_STANDARD },
capture: async ({ item_ids }, { supabase, companyId }) => {
const ids = Array.isArray(item_ids) ? item_ids.filter((x): x is string => typeof x === 'string') : []
if (ids.length === 0) return { items: [], bookable_count: 0 }
const { data: rows } = await supabase
.from('invoice_inbox_items')
.select('id, matched_transaction_id, created_journal_entry_id, created_supplier_invoice_id, extracted_data')
.eq('company_id', companyId)
.in('id', ids)
const txIds = Array.from(
new Set((rows ?? []).map((r) => r.matched_transaction_id).filter(Boolean) as string[]),
)
interface TxRow {
id: string
date: string | null
amount: number | null
currency: string | null
amount_sek: number | null
exchange_rate: number | null
description: string | null
}
const txById = new Map<string, TxRow>()
if (txIds.length > 0) {
const { data: txs } = await supabase
.from('transactions')
.select('id, date, amount, currency, amount_sek, exchange_rate, description')
.eq('company_id', companyId)
.in('id', txIds)
for (const t of ((txs ?? []) as TxRow[])) txById.set(t.id, t)
}
const items: CapturedInboxItem[] = (rows ?? []).map((r) => {
const ex = (r.extracted_data ?? {}) as {
supplier?: { name?: string | null }
invoice?: { invoiceDate?: string | null; currency?: string | null }
totals?: { total?: number | null; vatAmount?: number | null }
}
const tx = r.matched_transaction_id ? txById.get(r.matched_transaction_id as string) ?? null : null
const status: CapturedInboxItem['status'] =
r.created_journal_entry_id || r.created_supplier_invoice_id
? 'already_booked'
: r.matched_transaction_id
? 'bookable'
: 'not_matched'
return {
item_id: r.id as string,
status,
merchant_name: ex.supplier?.name ?? null,
invoice_date: ex.invoice?.invoiceDate ?? null,
total: ex.totals?.total ?? null,
vat_amount: ex.totals?.vatAmount ?? null,
currency: ex.invoice?.currency ?? null,
tx_date: tx?.date ?? null,
tx_amount_sek: tx ? txSek(tx) : null,
tx_description: tx?.description ?? null,
}
})
return { items, bookable_count: items.filter((i) => i.status === 'bookable').length }
},
promptTemplate: ({ captured, profileSummary }) => {
const lines: string[] = []
if (profileSummary) lines.push(`Företagets profil: ${profileSummary}`, '')
if (captured.items.length === 0) {
return [
'Användaren öppnade hjälpfönstret från en markering i Dokumentinkorgen, men inga underlag kunde läsas.',
'Be användaren markera underlagen igen och försök på nytt.',
].join(' ')
}
const bookable = captured.items.filter((i) => i.status === 'bookable')
const notMatched = captured.items.filter((i) => i.status === 'not_matched')
const alreadyBooked = captured.items.filter((i) => i.status === 'already_booked')
lines.push(`Användaren har markerat ${captured.items.length} underlag i Dokumentinkorgen och vill bulkbokföra dem.`)
lines.push('')
lines.push(
`MARKERADE UNDERLAG (${bookable.length} bokförbara, ${notMatched.length} saknar matchad transaktion, ${alreadyBooked.length} redan bokförda):`,
)
for (const it of bookable) {
const parts: string[] = [`item_id=${it.item_id}`]
if (it.merchant_name) parts.push(`leverantör=${it.merchant_name}`)
if (it.total != null) parts.push(`belopp=${it.total.toLocaleString('sv-SE')} ${it.currency ?? 'SEK'}`)
if (it.vat_amount != null) parts.push(`moms=${it.vat_amount.toLocaleString('sv-SE')} ${it.currency ?? 'SEK'}`)
if (it.tx_amount_sek != null) parts.push(`bank=${it.tx_amount_sek.toLocaleString('sv-SE')} SEK`)
if (it.tx_date) parts.push(`datum=${it.tx_date}`)
lines.push(`${parts.join(', ')}`)
}
if (notMatched.length > 0) {
lines.push('')
lines.push('EJ MATCHADE (kan inte bulkbokföras förrän de matchats mot en banktransaktion):')
for (const it of notMatched) {
const label = it.merchant_name ?? it.tx_description ?? it.item_id
lines.push(`${label}${it.total != null ? ` (${it.total.toLocaleString('sv-SE')} ${it.currency ?? 'SEK'})` : ''}`)
}
}
lines.push('')
lines.push('Arbetssätt:')
lines.push('- Boka via banktransaktionen (Modell B): verktyget bokför varje underlag mot dess matchade banktransaktion, som redan bär SEK-beloppet. Du behöver inte räkna om valuta.')
lines.push('- GRUPPERA de bokförbara underlagen efter leverantör/typ. Samma slags kostnad → samma kategori + momsbehandling. För varje homogen grupp anropar du gnubok_bulk_book_inbox_items med gruppens item_ids, en kategori (enum) och vat_treatment.')
lines.push('- MOMS: en utländsk tjänst (t.ex. USD/EUR-prenumeration som Cursor/Anysphere där säljaren INTE debiterat svensk moms) är omvänd skattskyldighet → vat_treatment="reverse_charge". En svensk faktura med debiterad moms → standard_25 (eller den sats kvittot visar). Gissa aldrig — utgå från valuta + om underlaget visar moms.')
lines.push('- KOLLA HUR MOTPARTEN BOKFÖRTS FÖRUT med gnubok_query_journal({ text: "<leverantör>", limit: 5 }) innan du väljer kategori. Följ ett tydligt tidigare mönster om inte underlaget motsäger det.')
lines.push('- HOPPA ÖVER ej matchade underlag: be användaren matcha dem mot en banktransaktion först ("Matcha mot transaktion" i Dokumentinkorgen), så kan de bulkbokföras i nästa runda. Bokför ALDRIG ett underlag utan matchad transaktion via det här flödet.')
lines.push('- Förklara kort på svenska VARFÖR du valde kategori + momsbehandling — använd kategori-namn (t.ex. "Programvara/IT-tjänster"), aldrig ett BAS-kontonummer. Godkännandekortet visar antal, konto och moms; upprepa inte de siffrorna och säg inte att operationen är "stagead".')
lines.push('')
lines.push('Svara på svenska och var direkt.')
return lines.join('\n')
},
})
+2
View File
@@ -1,6 +1,7 @@
import type { AgentIntent } from './types'
import { generalHelp } from './general-help'
import { transactionCategorization } from './transaction-categorization'
import { inboxBulkBook } from './inbox-bulk-book'
import { invoiceDraft } from './invoice-draft'
import { supplierInvoiceReview } from './supplier-invoice-review'
import { vatReview } from './vat-review'
@@ -23,6 +24,7 @@ import { onboardingIntake } from './onboarding-intake'
const INTENTS: AgentIntent<any, any>[] = [
generalHelp,
transactionCategorization,
inboxBulkBook,
invoiceDraft,
supplierInvoiceReview,
vatReview,
+1
View File
@@ -44,6 +44,7 @@ export const AGENT_GROUND_RULES: string[] = [
// standard-BAS account backfill in the engine/storno service.
'- RÄTTA FEL I BOKFÖRDA VERIFIKATIONER — så fungerar det i Accounted (beskriv aldrig andra vägar än dessa):',
' • En bokförd verifikation kan aldrig redigeras direkt (Bokföringslagen). Rättelse görs från verifikationens egen sida: Bokföring → öppna verifikationen → knappen "Rätta". "Rätta rader" skapar automatiskt en storno som nollställer originalet plus en ny rättelseverifikation med de rätta raderna, båda i originalets period. "Rätta datum" flyttar verifikationen till rätt datum/år (storno + ombokning under huven). Hela kedjan original → storno → rättelse länkas och visas på verifikationssidan.',
' • INGÅENDE BALANSER (IB) rättas på sitt eget sätt — INTE via "Rätta rader". Gå till Bokföring, öppna IB-verifikationen (beskrivning "Ingående balanser", serie A) och klicka "Korrigera ingående balanser". Då öppnas IB-raderna så att beloppen kan ändras direkt; när man sparar stornas den gamla IB-verifikationen och en korrigerad bokförs, och periodens ingående balans pekas om till den nya. Detta gäller oavsett om IB kom från SIE-import, CSV/Excel-import eller föregående års bokslut. IB finns alltså INTE under Inställningar eller Kontoplan — korrigeringen görs på själva verifikationen.',
' • Är verifikationen den SENASTE i sin serie kan den även raderas helt ("Radera verifikat") — då återanvänds löpnumret och ingen lucka uppstår.',
' • Konton som finns i BAS-kontoplanen men saknas i företagets kontoplan läggs till AUTOMATISKT vid bokföring och rättelse. Be aldrig användaren registrera standardkonton manuellt innan de bokför — bara okända kontonummer eller avaktiverade konton stoppar.',
' • När en bokning makuleras (storno utan rättelse) släpps den kopplade banktransaktionen och blir bokföringsbar igen i transaktionsvyn — användaren kan alltid klicka på transaktionen och bokföra om. Vid en rättelse följer transaktionen och underlaget med till rättelseverifikationen.',
+93 -9
View File
@@ -2,12 +2,16 @@ import { defineAgentIntent } from './types'
import { SONNET_MODEL, THINKING_BUDGET_STANDARD } from '@/lib/agent/composer/client'
import { renderAgentGroundRules } from './shared-rules'
// verifikation.draft — "Fråga [namn]" on the journal entry creation form.
// verifikation.draft — "Fråga om denna verifikation" on the journal entry
// creation/draft surfaces (Bokföring → "Skapa med assistent", the Ny
// verifikat-dialog, and a draft verifikat's own page).
//
// Helps the user construct a balanced verifikation: pick the right BAS
// accounts, handle VAT splits, and detect when a transaction should instead
// be matched to an invoice or supplier invoice (rather than booked from
// scratch). Reads any in-progress draft state passed via intent_args.
// Helps the user construct a balanced verifikation end to end: read the
// underlag (kvitto/faktura) the user often can't see themselves and pull the
// figures from it, pick the right BAS accounts, handle VAT splits, and detect
// when a transaction should instead be matched to an invoice or supplier
// invoice (rather than booked from scratch). Reads any in-progress draft state
// + linked underlag passed via intent_args.
interface VerifikationDraftArgs {
// Optional id when the user is editing an existing draft. null for /new.
@@ -36,6 +40,21 @@ interface CapturedVerifikationDraft {
lock_date: string | null
} | null
description_hint: string | null
// Underlag already linked to the entry (when editing a draft). Flattened
// from document_attachments.extracted_data the same way
// transaction.categorization does, so the agent can read the figures
// without a round-trip. Empty for a brand-new verifikation — there the
// agent discovers underlag via gnubok_list_inbox_items.
underlag: {
document_id: string | null
file_name: string | null
merchant_name: string | null
receipt_date: string | null
total_amount: number | null
vat_amount: number | null
currency: string | null
raw_extraction: Record<string, unknown> | null
}[]
}
export const verifikationDraft = defineAgentIntent<
@@ -57,6 +76,13 @@ export const verifikationDraft = defineAgentIntent<
'gnubok_get_trial_balance',
'gnubok_query_journal',
'gnubok_create_voucher',
// Underlag reading — the ground rules (shared-rules.ts) already instruct
// the agent to look in the inbox and read the underlag before proposing a
// booking; these are the tools that make those instructions callable.
'gnubok_get_document_content',
'gnubok_list_inbox_items',
'gnubok_list_unmatched_documents',
'gnubok_get_inbox_item',
'gnubok_load_skill',
'gnubok_search_tools',
'gnubok_remember_fact',
@@ -76,6 +102,7 @@ export const verifikationDraft = defineAgentIntent<
let entry: CapturedVerifikationDraft['entry'] = null
let lines: CapturedVerifikationDraft['current_lines'] = []
let periodStatus: CapturedVerifikationDraft['period_status'] = null
const underlag: CapturedVerifikationDraft['underlag'] = []
if (journal_entry_id) {
const { data: e } = await supabase
@@ -114,6 +141,37 @@ export const verifikationDraft = defineAgentIntent<
}
}
}
// Underlag already linked to this draft — surface the extracted fields
// so the agent suggests accounts from what's on the kvitto without
// re-asking. Mirrors transaction.categorization's document_attachments
// read (same table, same extracted_data shape).
const { data: docs } = await supabase
.from('document_attachments')
.select('id, file_name, extracted_data')
.eq('journal_entry_id', journal_entry_id)
.eq('company_id', companyId)
.eq('is_current_version', true)
for (const d of (docs ?? []) as {
id: string
file_name: string | null
extracted_data: Record<string, unknown> | null
}[]) {
const ex = d.extracted_data ?? null
const supplier = (ex?.supplier as { name?: string | null } | undefined) ?? null
const invoice = (ex?.invoice as { invoiceDate?: string | null; currency?: string | null } | undefined) ?? null
const totals = (ex?.totals as { total?: number | null; vatAmount?: number | null } | undefined) ?? null
underlag.push({
document_id: d.id,
file_name: d.file_name,
merchant_name: supplier?.name ?? null,
receipt_date: invoice?.invoiceDate ?? null,
total_amount: totals?.total ?? null,
vat_amount: totals?.vatAmount ?? null,
currency: invoice?.currency ?? null,
raw_extraction: ex,
})
}
}
}
@@ -122,6 +180,7 @@ export const verifikationDraft = defineAgentIntent<
current_lines: lines,
period_status: periodStatus,
description_hint: description ?? null,
underlag,
}
},
@@ -164,6 +223,28 @@ export const verifikationDraft = defineAgentIntent<
}
}
if (captured.underlag.length > 0) {
lines.push('')
lines.push(`UNDERLAG kopplat till verifikationen: ${captured.underlag.length} st. Extraherade fält:`)
for (const u of captured.underlag) {
const parts: string[] = []
if (u.document_id) parts.push(`document_id=${u.document_id}`)
if (u.merchant_name) parts.push(`leverantör=${u.merchant_name}`)
if (u.receipt_date) parts.push(`datum=${u.receipt_date}`)
if (u.total_amount != null) {
parts.push(`total=${u.total_amount.toLocaleString('sv-SE')} ${u.currency ?? 'SEK'}`)
}
if (u.vat_amount != null) {
parts.push(`moms=${u.vat_amount.toLocaleString('sv-SE')} ${u.currency ?? 'SEK'}`)
}
lines.push(
`${parts.join(', ') || `${u.file_name ?? 'underlag'} (ingen extraherad data — läs med gnubok_get_document_content)`}`,
)
}
lines.push('')
lines.push('Extraktionen ovan är det vi REDAN VET — fråga inte om leverantör/belopp som står där. Räcker den inte (t.ex. saknar momsbelopp), läs underlaget med gnubok_get_document_content(document_id=…).')
}
if (captured.period_status) {
lines.push('')
lines.push(
@@ -177,10 +258,13 @@ export const verifikationDraft = defineAgentIntent<
}
lines.push('')
lines.push('Arbetssätt:')
lines.push('1. Föreslå rätt BAS-konton baserat på beskrivningen. Syns en motpart i beskrivningen — kolla historiken med gnubok_query_journal({ text: "<motpartens namn>", limit: 5 }).')
lines.push('2. Säkerställ att debet = kredit. Förklara varje rad kort.')
lines.push('3. Om transaktionen i själva verket är en faktura/leverantörsfaktura/bankrad — be användaren matcha det istället. Direktbokning skapar dubbletter.')
lines.push('4. Staga via gnubok_create_voucher när allt stämmer.')
lines.push('1. UNDERLAG FÖRST. Saknas underlaget i sammanhanget ovan: leta i Dokumentinkorgen med gnubok_list_inbox_items (och gnubok_list_unmatched_documents). Läs det relevanta underlaget med gnubok_get_inbox_item / gnubok_get_document_content och dra fram datum, belopp, moms och motpart INNAN du föreslår konton. Användaren ser ofta inte underlagets innehåll själv — det är just det du hjälper till med.')
lines.push('2. Föreslå rätt BAS-konton utifrån underlaget och beskrivningen. Syns en motpart — kolla historiken med gnubok_query_journal({ text: "<motpartens namn>", limit: 5 }) och följ tidigare mönster.')
lines.push('3. Säkerställ att debet = kredit. Förklara varje rad kort (i kategori-/kontonamn, inte kontonummer).')
lines.push('4. Är detta egentligen en kund-/leverantörsfaktura eller en bankrad? Be användaren matcha den istället — direktbokning skapar dubbletter.')
lines.push('5. Skapa verifikationen:')
lines.push(' • NY verifikation (inget utkast visas ovan): staga via gnubok_create_voucher när allt stämmer. Ligger underlaget i Dokumentinkorgen — skicka med inbox_item_id så kvittot kopplas till verifikationen automatiskt vid godkännande.')
lines.push(' • BEFINTLIGT utkast (visas ovan): föreslå konton/moms och kontrollera balansen så att användaren kan färdigställa utkastet i formuläret. Staga INTE en ny verifikation för ett utkast som redan finns — det skapar en dubblett.')
lines.push('')
lines.push('Svara på svenska, kort och konkret.')
return lines.join('\n')
+29
View File
@@ -797,6 +797,35 @@ export const BookInboxItemDirectlySchema = z.object({
transaction_id: uuid.optional(),
})
/**
* Bulk-book selected Underlag (Dokumentinkorgen) against their matched bank
* transactions. One shared category + VAT treatment is applied to every
* selected item; each item is booked against its own matched transaction (which
* carries the SEK amount), so the verifikat are individual not a
* samlingsverifikation. Items without a matched transaction, already booked, or
* already linked to a leverantörsfaktura are skipped server-side.
*
* Used both as the UI route body (POST /items/bulk-book) and as the
* pending-operation params for `bulk_book_inbox_items` (Lena-driven flow).
*/
export const BulkBookInboxSchema = z.object({
item_ids: z.array(uuid).min(1, 'Minst ett underlag krävs').max(200, 'Högst 200 underlag per bokföring'),
category: TransactionCategorySchema,
// Optional fields are `.nullish()` (not just `.optional()`) because the
// `bulk_book_inbox_items` pending operation persists absent optionals as
// explicit JSON `null` (stagePendingOperation in mcp-server/server.ts). When
// the executor re-parses those params on approval, a bare `.optional()` would
// reject the stored `null`. `.transform` normalizes `null → undefined` so the
// executor and categorizeMatchedTransaction never receive `null`.
vat_treatment: VatTreatmentSchema.nullish().transform((v) => v ?? undefined),
// The underlag's actual moms when it differs from rate × belopp (e.g. dricks).
// Only valid with a rate-based vat_treatment; rejected otherwise downstream.
vat_amount: z.number().positive().nullish().transform((v) => v ?? undefined),
notes: z.string().max(2000).nullish().transform((v) => v ?? undefined),
allow_duplicate: z.boolean().nullish().transform((v) => v ?? undefined),
})
export type BulkBookInboxInput = z.infer<typeof BulkBookInboxSchema>
export const MatchInvoiceSchema = z
.object({
invoice_id: uuid,
+1
View File
@@ -170,6 +170,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_link_transaction_to_journal_entry: 'transactions:write',
gnubok_match_batch_allocate: 'transactions:write',
gnubok_bulk_book_transactions: 'transactions:write',
gnubok_bulk_book_inbox_items: 'transactions:write',
gnubok_auto_match_period: 'transactions:write',
// Customers
gnubok_list_customers: 'customers:read',
@@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest'
import {
foldText,
buildAccountIndex,
searchAccounts,
type SearchableAccount,
} from '../account-search'
// Synthetic fixtures — `active` is a minimal chart, `catalog` is the full BAS
// superset (and includes the active rows, as the real catalog does).
const active: SearchableAccount[] = [
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1, description: 'Företagets huvudsakliga bankkonto.' },
{ account_number: '5420', account_name: 'Programvaror', account_class: 5, description: 'Kostnader för mjukvara, prenumerationer och licenser.' },
{ account_number: '7010', account_name: 'Lönekostnader tjänstemän', account_class: 7, description: 'Bruttolöner till anställda tjänstemän.' },
]
const catalog: SearchableAccount[] = [
...active,
{ account_number: '6540', account_name: 'IT-tjänster', account_class: 6, description: 'Kostnader för extern IT-support, konsultation och drifttjänster.' },
{ account_number: '6550', account_name: 'Konsultarvoden', account_class: 6, description: 'Arvode till externa konsulter för rådgivning.' },
{ account_number: '6230', account_name: 'Datakommunikation', account_class: 6, description: 'Internet, bredband och fast uppkoppling.' },
{ account_number: '6570', account_name: 'Bankkostnader', account_class: 6, description: 'Avgifter för banktjänster och konsultation.' },
]
const idx = buildAccountIndex({ active, catalog })
const numbers = (items: { account_number: string }[]) => items.map((i) => i.account_number)
describe('foldText', () => {
it('lowercases and strips Swedish diacritics', () => {
expect(foldText('Lön')).toBe('lon')
expect(foldText('Intäkter')).toBe('intakter')
expect(foldText('IT-tjänster')).toBe('it-tjanster')
expect(foldText('Ränta')).toBe('ranta')
})
})
describe('searchAccounts', () => {
it('returns the active chart (only) for an empty query', () => {
const r = searchAccounts(idx, '')
expect(numbers(r)).toEqual(['1930', '5420', '7010'])
expect(r.every((i) => i.isActive)).toBe(true)
})
it('finds a catalog-only account by name even when it is not in the chart (the "IT" case)', () => {
const r = searchAccounts(idx, 'IT')
expect(numbers(r)).toContain('6540')
expect(r.find((i) => i.account_number === '6540')?.isActive).toBe(false)
})
it('matches words that are not the leading word of the name', () => {
expect(numbers(searchAccounts(idx, 'kommunikation'))).toContain('6230')
})
it('matches words found only in the description', () => {
// "drifttjänster" appears only in 6540's description, not its name.
expect(numbers(searchAccounts(idx, 'drifttjänster'))).toEqual(['6540'])
})
it('is diacritic-insensitive (query typed without å/ä/ö)', () => {
expect(numbers(searchAccounts(idx, 'lonekostnader'))).toContain('7010')
expect(numbers(searchAccounts(idx, 'lon'))).toContain('7010')
})
it('requires every token to match (token-AND), regardless of order or hyphen', () => {
// Both tokens live in 6540 (one in the name, one in the description).
expect(numbers(searchAccounts(idx, 'drift it'))).toEqual(['6540'])
// "extern konsultation": 6540 has both in its description; 6550/6570 miss one.
expect(numbers(searchAccounts(idx, 'extern konsultation'))).toEqual(['6540'])
})
it('prefix-matches account numbers across the full catalog', () => {
const r = searchAccounts(idx, '65')
expect(numbers(r).sort()).toEqual(['6540', '6550', '6570'])
expect(r.every((i) => !i.isActive)).toBe(true)
})
it('dedupes an account present in both active and catalog, preferring the active row', () => {
const r = searchAccounts(idx, '1930')
expect(r).toHaveLength(1)
expect(r[0].isActive).toBe(true)
})
it('ranks active accounts before catalog-only ones', () => {
const r = searchAccounts(idx, 'kostnad')
const firstCatalog = r.findIndex((i) => !i.isActive)
const lastActive = r.map((i) => i.isActive).lastIndexOf(true)
expect(lastActive).toBeLessThan(firstCatalog)
// Within active, a name hit outranks a description-only hit.
expect(r[0].account_number).toBe('7010')
})
it('ranks a name "starts-with" hit first', () => {
// "konsult": 6550 "Konsultarvoden" (name starts) over 6570 (description only).
const r = searchAccounts(idx, 'konsult')
expect(r[0].account_number).toBe('6550')
})
it('returns nothing for a query that matches no account', () => {
expect(searchAccounts(idx, 'zzzxyq')).toEqual([])
})
it('honours the result limit', () => {
expect(searchAccounts(idx, '', 2)).toHaveLength(2)
expect(searchAccounts(idx, '6', 2)).toHaveLength(2)
})
})
+151
View File
@@ -0,0 +1,151 @@
/**
* Account search for the manual bookkeeping flow (AccountCombobox).
*
* Two problems this solves over a plain `account_name.includes(query)`:
*
* 1. Coverage the combobox is fed two sources: the company's *active* chart
* and (optionally) the full BAS 2026 catalog. A user who types "IT" should
* find 6540 "IT-tjänster" even if it was never added to their chart yet.
* Active accounts always rank first; selecting a catalog-only account is
* handled by the existing activate-on-commit rail.
*
* 2. Matching names are terse and statutory, so the everyday word the user
* reaches for is often in the description, mid-name, or typed without
* diacritics. We fold diacritics (so "lon" matches "Lön"), search
* number + name + description, and require every token to match (so word
* order and the hyphen in "IT-tjänster" stop mattering).
*
* Build the index once per (active, catalog) pair with buildAccountIndex, then
* call searchAccounts per keystroke the per-keystroke work is just substring
* checks over pre-folded haystacks.
*/
/** Minimal shape both an active BASAccount and a catalog row satisfy. */
export interface SearchableAccount {
account_number: string
account_name: string
account_class: number
description?: string | null
}
/** A single result row the combobox renders. */
export interface AccountSearchItem {
account_number: string
account_name: string
account_class: number
/** true = already in the company's chart; false = catalog-only (activates on commit). */
isActive: boolean
}
export interface AccountIndexEntry {
item: AccountSearchItem
/** Folded "number name description" — the text every token is matched against. */
haystack: string
/** Folded name only — used for "starts with" / name-hit ranking. */
nameFolded: string
}
const DEFAULT_LIMIT = 50
/**
* Lowercase + strip diacritics so a query typed without Swedish characters
* still matches: "lon" "lön", "intakter" "intäkter", "ranta" "ränta".
*/
export function foldText(input: string): string {
return input
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
}
/**
* Build the searchable index. Active accounts are added first so that, on a
* duplicate account number, the active row wins and catalog duplicates are
* dropped.
*/
export function buildAccountIndex(opts: {
active: SearchableAccount[]
catalog?: SearchableAccount[]
}): AccountIndexEntry[] {
const seen = new Set<string>()
const entries: AccountIndexEntry[] = []
const add = (acc: SearchableAccount, isActive: boolean) => {
if (seen.has(acc.account_number)) return
seen.add(acc.account_number)
const description = acc.description ?? ''
entries.push({
item: {
account_number: acc.account_number,
account_name: acc.account_name,
account_class: acc.account_class,
isActive,
},
haystack: foldText(`${acc.account_number} ${acc.account_name} ${description}`),
nameFolded: foldText(acc.account_name),
})
}
for (const a of opts.active) add(a, true)
for (const c of opts.catalog ?? []) add(c, false)
return entries
}
/**
* Search the index. Returns ranked items (active first), capped at `limit`.
*
* - Empty query the active chart (what the dropdown shows when first opened).
* - All-digit query prefix match on the account number, spanning the catalog
* so "65" browses every 65xx account, not just the active ones.
* - Otherwise token-AND substring match over number + name + description.
*/
export function searchAccounts(
index: AccountIndexEntry[],
query: string,
limit: number = DEFAULT_LIMIT,
): AccountSearchItem[] {
const trimmed = query.trim()
if (!trimmed) {
const out: AccountSearchItem[] = []
for (const e of index) {
if (!e.item.isActive) continue
out.push(e.item)
if (out.length >= limit) break
}
return out
}
if (/^\d+$/.test(trimmed)) {
const hits = index.filter((e) => e.item.account_number.startsWith(trimmed))
return rank(hits, [trimmed], limit)
}
const tokens = foldText(trimmed).split(/[\s-]+/).filter(Boolean)
if (tokens.length === 0) return []
const hits = index.filter((e) => tokens.every((t) => e.haystack.includes(t)))
return rank(hits, tokens, limit)
}
/**
* Rank: active before catalog name starts with the first token all tokens
* present in the name (vs only reachable via the description) account number.
*/
function rank(entries: AccountIndexEntry[], tokens: string[], limit: number): AccountSearchItem[] {
const firstToken = tokens[0] ?? ''
const scored = entries.map((e) => {
let score = 0
if (e.item.isActive) score += 1000
if (firstToken && e.nameFolded.startsWith(firstToken)) score += 100
if (tokens.every((t) => e.nameFolded.includes(t))) score += 50
return { e, score }
})
scored.sort((a, b) =>
b.score !== a.score
? b.score - a.score
: a.e.item.account_number.localeCompare(b.e.item.account_number),
)
return scored.slice(0, limit).map((s) => s.e.item)
}
+37
View File
@@ -0,0 +1,37 @@
'use client'
import type { SearchableAccount } from '@/lib/bookkeeping/account-search'
/**
* Client-side loader for the full BAS catalogue used by AccountCombobox.
*
* The catalogue is static reference data, identical for every company, so we
* fetch it once per session and share the in-flight promise across every
* combobox instance and form mount. A failed fetch clears the cache so the
* next caller retries rather than being stuck with an empty list.
*/
export interface CatalogAccount extends SearchableAccount {
account_number: string
account_name: string
account_class: number
account_group: string
description: string | null
}
let cache: Promise<CatalogAccount[]> | null = null
export function loadBasCatalog(): Promise<CatalogAccount[]> {
if (!cache) {
cache = fetch('/api/bookkeeping/accounts/bas-catalog')
.then((res) => {
if (!res.ok) throw new Error(`bas-catalog ${res.status}`)
return res.json()
})
.then((body) => (body?.data as CatalogAccount[]) ?? [])
.catch(() => {
cache = null // allow a retry on the next call
return []
})
}
return cache
}
+88
View File
@@ -0,0 +1,88 @@
'use server'
import { cookies, headers } from 'next/headers'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { ensureTicSnapshot } from '@/lib/agent/composer/tic-fetch'
export interface RefreshCompanyProfileResult {
ok?: true
snapshot?: Record<string, unknown> | null
fetchedAt?: string
// Error *codes*, translated by the caller (same pattern as company/actions.ts):
// unauthorized | org_number_invalid | persist_failed | not_found
error?: string
}
/**
* Fetch Bolagsuppgifter on demand from the settings Företag panel.
*
* The panel normally shows the cached `companies.tic_snapshot`. This action
* lets the user (re)fetch it live by submitting an org number / personnummer
* the path that recovers a company whose cached snapshot is missing or wrong
* (e.g. an enskild firma whose 10-digit personnummer previously fuzzy-matched
* the wrong entity; `searchCompanyByOrgNumber` now expands it to the 12-digit
* form so Lens resolves it exactly).
*
* We persist the (normalized) number and clear `tic_snapshot_fetched_at` to
* force `ensureTicSnapshot` past its 7-day cache, then let it do the live
* /profile fetch + write. All writes are RLS-scoped to the caller's company.
*/
export async function refreshCompanyProfileAction(
companyId: string,
orgNumberRaw: string,
): Promise<RefreshCompanyProfileResult> {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'unauthorized' }
// Refuse malformed input at the boundary rather than storing a value that
// would later break SIE/SRU exports (same rule as createCompanyFromOnboarding).
const cleaned = normalizeOrgNumber(orgNumberRaw)
if (!cleaned) return { error: 'org_number_invalid' }
// Persist the (possibly corrected) number and force staleness so
// ensureTicSnapshot re-fetches instead of returning the poisoned cache.
const { error: updateError } = await supabase
.from('companies')
.update({ org_number: cleaned, tic_snapshot_fetched_at: null })
.eq('id', companyId)
if (updateError) return { error: 'persist_failed' }
// Keep the settings form (which reads company_settings.org_number) in sync —
// best-effort; the TIC fetch reads companies.org_number, updated above.
await supabase
.from('company_settings')
.update({ org_number: cleaned })
.eq('company_id', companyId)
// Self-fetch needs the caller's session cookie and the current origin so it
// reaches this same instance (dev / preview / prod) — see ensureTicSnapshot.
const cookieStore = await cookies()
const cookieHeader = cookieStore.getAll().map((c) => `${c.name}=${c.value}`).join('; ')
const hdrs = await headers()
const host = hdrs.get('host')
const proto = hdrs.get('x-forwarded-proto') ?? 'https'
const origin = host ? `${proto}://${host}` : undefined
const { snapshot, source } = await ensureTicSnapshot({
supabase,
companyId,
cookieHeader,
origin,
// The user is watching a spinner; give the ~7-13 call Lens fan-out room to
// finish (the 5s default aborted every fetch during the May quota incident).
timeoutMs: 10_000,
})
// 'fetched' = a fresh live fetch was persisted. 'fallback' = TIC returned
// nothing / errored — surface it and leave the existing snapshot untouched
// rather than blanking a good panel on a transient outage.
if (source !== 'fetched' || !snapshot) {
return { error: 'not_found' }
}
revalidatePath('/settings')
return { ok: true, snapshot, fetchedAt: new Date().toISOString() }
}
+19 -2
View File
@@ -23,6 +23,23 @@ function isSelfHosted(): boolean {
return process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
}
/**
* Local development is all-on so every gated feature is testable without a
* subscription. Two triggers, both fail-safe for prod:
* - NODE_ENV === 'development' (i.e. `npm run dev`). NOT 'test' the
* entitlement suite must still exercise the real gate and NOT
* 'production'.
* - DISABLE_PAYWALL === 'true' explicit escape hatch for a local
* production build. Never set this in a hosted environment.
*/
function isPaywallBypassed(): boolean {
return (
isSelfHosted() ||
process.env.NODE_ENV === 'development' ||
process.env.DISABLE_PAYWALL === 'true'
)
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/**
* Only server-resolved UUIDs may be interpolated into the PostgREST `.or()`
@@ -38,7 +55,7 @@ export async function hasCapability(
companyId: string,
key: CapabilityKey,
): Promise<boolean> {
if (isSelfHosted()) return true
if (isPaywallBypassed()) return true
if (!isUuid(companyId)) return false // fail-closed: never interpolate a non-UUID
// Resolve the company's firm/team (firm-scoped grants cascade to clients).
@@ -152,7 +169,7 @@ export async function getCompanyCapabilities(
supabase: SupabaseClient,
companyId: string,
): Promise<CapabilityKey[]> {
if (isSelfHosted()) return [...PAID_CAPABILITIES]
if (isPaywallBypassed()) return [...PAID_CAPABILITIES]
if (!isUuid(companyId)) return [] // fail-closed: never interpolate a non-UUID
const { data: company } = await supabase
+27
View File
@@ -915,6 +915,16 @@ const PERIOD: Record<string, StructuredErrorEntry> = {
message_sv: 'Perioden är redan låst.',
message_en: 'Period is already locked.',
},
PERIOD_UNLOCK_NOT_LOCKED: {
httpStatus: 409,
message_sv: 'Perioden är inte låst.',
message_en: 'Period is not locked.',
},
PERIOD_UNLOCK_CLOSED: {
httpStatus: 409,
message_sv: 'Ett stängt räkenskapsår kan inte låsas upp.',
message_en: 'A closed fiscal year cannot be unlocked.',
},
// Forward-chaining a new räkenskapsår is blocked while a prior period is
// still fully open (not locked, not closed, not covered by the company-wide
// lock-through date). BFL 6 kap allows löpande bokföring of the new year in
@@ -1272,6 +1282,23 @@ const OPENING_BALANCE_IMPORT: Record<string, StructuredErrorEntry> = {
message_sv: 'Importen misslyckades.',
message_en: 'Opening balance import failed.',
},
OB_CORRECT_NO_EXISTING: {
httpStatus: 409,
message_sv: 'Perioden har inga ingående balanser att korrigera. Bokför dem först.',
message_en: 'The period has no opening balances to correct. Book them first.',
},
OB_CORRECT_YEAR_END_EXISTS: {
httpStatus: 409,
message_sv:
'Perioden har ett bokslut. Återför bokslutet och öppna perioden innan ingående balanser kan korrigeras.',
message_en:
'The period has a year-end close. Reverse the close and reopen the period before opening balances can be corrected.',
},
OB_CORRECT_FAILED: {
httpStatus: 500,
message_sv: 'Korrigeringen av ingående balanser misslyckades.',
message_en: 'Opening balance correction failed.',
},
}
const REGISTER_IMPORT: Record<string, StructuredErrorEntry> = {
@@ -368,6 +368,27 @@ describe('sie_imports: partial unique index + replace flow', () => {
expect(untouched.rows[0]?.journal_entry_id).toBe(manualEntry)
})
it('replace_sie_import and undo_sie_import carry a raised statement_timeout', async () => {
// Regression for the 8s-timeout cancellation (migration 20260629160000):
// these RPCs run on the service-role REST client, which still inherits the
// authenticator login role's 8s statement_timeout (service_role.rolconfig
// is NULL). A large import's delete exceeded that and was cancelled, so the
// functions now set a function-local statement_timeout well above 8s.
const { rows } = await getPool().query<{ proname: string; proconfig: string[] | null }>(
`SELECT proname, proconfig
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public'
AND proname IN ('replace_sie_import', 'undo_sie_import')`,
)
expect(rows.length).toBe(2)
for (const fn of rows) {
const timeout = (fn.proconfig ?? []).find(c => c.startsWith('statement_timeout='))
expect(timeout, `${fn.proname} should set statement_timeout`).toBeTruthy()
const seconds = Number(/statement_timeout=(\d+)s/.exec(timeout!)?.[1] ?? 0)
expect(seconds).toBeGreaterThan(8)
}
})
it('replace_sie_import on an already-replaced import raises', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
@@ -0,0 +1,162 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
/**
* Shared helpers for booking opening balances.
*
* Used by both the first-time import (`opening-balance/execute`) and the
* correction flow (`opening-balance/correct`), which validate lines and
* auto-activate accounts identically and differ only in what they do with
* the resulting journal entry (set vs. storno + relink).
*/
export interface OpeningBalanceLine {
account_number: string
debit_amount: number
credit_amount: number
}
export type OpeningBalanceValidation =
| {
ok: true
validLines: OpeningBalanceLine[]
totalDebit: number
totalCredit: number
}
| { ok: false; code: 'OB_TOO_FEW_LINES' }
| { ok: false; code: 'OB_PNL_ACCOUNT'; accounts: string[] }
| { ok: false; code: 'OB_UNBALANCED'; totalDebit: number; totalCredit: number; diff: number }
/**
* Validate opening-balance lines: drop zero-amount rows, require 2 lines,
* reject P&L accounts (class 38), and verify debits equal credits.
*/
export function validateOpeningBalanceLines(
lines: OpeningBalanceLine[],
): OpeningBalanceValidation {
const validLines = lines.filter((l) => l.debit_amount > 0 || l.credit_amount > 0)
if (validLines.length < 2) {
return { ok: false, code: 'OB_TOO_FEW_LINES' }
}
const pnlAccounts = validLines
.map((l) => l.account_number)
.filter((num) => {
const cls = parseInt(num.charAt(0), 10)
return cls >= 3 && cls <= 8
})
if (pnlAccounts.length > 0) {
return { ok: false, code: 'OB_PNL_ACCOUNT', accounts: pnlAccounts.slice(0, 5) }
}
let totalDebit = 0
let totalCredit = 0
for (const line of validLines) {
totalDebit = Math.round((totalDebit + line.debit_amount) * 100) / 100
totalCredit = Math.round((totalCredit + line.credit_amount) * 100) / 100
}
const diff = Math.round((totalDebit - totalCredit) * 100) / 100
if (Math.abs(diff) >= 0.01) {
return { ok: false, code: 'OB_UNBALANCED', totalDebit, totalCredit, diff }
}
return { ok: true, validLines, totalDebit, totalCredit }
}
/**
* Auto-activate any BAS accounts referenced by the lines that are not yet in
* the company's chart of accounts. Mirrors the behaviour of the first-time
* import so a corrected file can reference accounts the original did not.
*/
export async function activateMissingAccounts(
supabase: SupabaseClient,
companyId: string,
userId: string,
accountNumbers: string[],
): Promise<{ ok: true } | { ok: false; reason: string }> {
const existingAccounts = await fetchAllRows<{ account_number: string }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.range(from, to),
)
const existingNumbers = new Set(existingAccounts.map((a) => a.account_number))
const accountsToActivate = accountNumbers
.filter((num) => !existingNumbers.has(num))
.map((num) => {
const ref = getBASReference(num)
if (ref) {
return {
user_id: userId,
company_id: companyId,
account_number: ref.account_number,
account_name: ref.account_name,
account_class: ref.account_class,
account_group: ref.account_group,
account_type: ref.account_type,
normal_balance: ref.normal_balance,
plan_type: 'full_bas' as const,
is_active: true,
is_system_account: false,
description: ref.description,
sru_code: ref.sru_code,
sort_order: parseInt(ref.account_number),
}
}
const accountClass = parseInt(num.charAt(0), 10)
const accountGroup = num.substring(0, 2)
const accountType =
accountClass === 1 ? 'asset'
: accountClass === 2 ? 'liability'
: accountClass === 3 ? 'revenue'
: 'expense'
const normalBalance = accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit'
return {
user_id: userId,
company_id: companyId,
account_number: num,
account_name: `Konto ${num}`,
account_class: accountClass,
account_group: accountGroup,
account_type: accountType,
normal_balance: normalBalance,
plan_type: 'full_bas' as const,
is_active: true,
is_system_account: false,
description: `Konto ${num}`,
sru_code: null,
sort_order: parseInt(num),
}
})
if (accountsToActivate.length > 0) {
const { error: activateError } = await supabase
.from('chart_of_accounts')
.insert(accountsToActivate)
if (activateError) {
return { ok: false, reason: activateError.message }
}
}
return { ok: true }
}
/** Map validated lines to journal entry line inputs. */
export function buildOpeningBalanceEntryLines(validLines: OpeningBalanceLine[]) {
return validLines.map((line) => ({
account_number: line.account_number,
debit_amount: line.debit_amount,
credit_amount: line.credit_amount,
line_description: `IB ${line.account_number}`,
}))
}
+2
View File
@@ -65,4 +65,6 @@ export interface OpeningBalanceExecuteResult {
total_debit: number
total_credit: number
error?: string
/** Set when this was a correction: the stornoed previous IB entry id. */
reversed_entry_id?: string | null
}
+60 -262
View File
@@ -15,9 +15,7 @@
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
import { bulkBookMatchedInboxItems, categorizeMatchedTransaction } from '@/lib/transactions/categorize-core'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { validateVatNumber } from '@/lib/vat/vies-client'
@@ -44,7 +42,6 @@ import {
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching'
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
import { linkSupplierInvoiceToVoucher } from '@/lib/invoices/supplier-voucher-matching'
import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry'
@@ -74,9 +71,9 @@ import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { createLogger } from '@/lib/logger'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { roundOre } from '@/lib/money'
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
import { CreateArticleParamsSchema, UpdateArticleParamsSchema } from '@/lib/pending-operations/schemas/article'
import { BulkBookInboxSchema } from '@/lib/api/schemas'
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
import { z } from 'zod'
@@ -147,67 +144,9 @@ export interface CommitOptions {
actor?: CommitActor
}
// ── Helper: ensure fiscal period covers the date ──────────────────
async function ensureFiscalPeriod(
supabase: SupabaseClient,
userId: string,
companyId: string,
date: string,
fiscalYearStartMonth: number = 1
): Promise<boolean> {
const { data: existing } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.lte('period_start', date)
.gte('period_end', date)
.eq('is_closed', false)
.limit(1)
if (existing && existing.length > 0) return true
const txDate = new Date(date)
const txMonth = txDate.getMonth() + 1
const txYear = txDate.getFullYear()
let periodStartYear: number
if (fiscalYearStartMonth === 1) {
periodStartYear = txYear
} else if (txMonth >= fiscalYearStartMonth) {
periodStartYear = txYear
} else {
periodStartYear = txYear - 1
}
const startMonth = String(fiscalYearStartMonth).padStart(2, '0')
const periodStart = `${periodStartYear}-${startMonth}-01`
const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
const lastDay = new Date(endYear, endMonth, 0).getDate()
const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
const periodName = fiscalYearStartMonth === 1
? `Räkenskapsår ${periodStartYear}`
: `Räkenskapsår ${periodStartYear}/${endYear}`
const { error } = await supabase
.from('fiscal_periods')
.upsert({
user_id: userId,
company_id: companyId,
name: periodName,
period_start: periodStart,
period_end: periodEnd,
}, { onConflict: 'user_id,period_start,period_end' })
if (error) {
log.error('Failed to create fiscal period:', error)
return false
}
return true
}
// ensureFiscalPeriod moved to lib/transactions/categorize-core.ts (imported
// above) so the bulk-book-inbox path and the single-categorize path share one
// implementation.
async function recordSkippedInvoiceJournalEntry(
invoiceId: string,
@@ -270,203 +209,17 @@ async function commitCategorizeTransaction(
? params.vat_amount
: undefined
const { data: transaction, error: fetchError } = await supabase
.from('transactions').select('*').eq('id', txId).eq('company_id', companyId).single()
if (fetchError || !transaction) {
return { error: 'Transaction not found — it may have been deleted.', status: 404 }
}
if (transaction.journal_entry_id) {
return { error: 'Transaction already has a journal entry — it was categorized in the meantime.', status: 409 }
}
// Booking-time duplicate guard — parity with the web /categorize route, which
// the agent path otherwise bypassed entirely. Refuse to mint a second
// verifikat for an affärshändelse already in the ledger: an already-booked
// sibling transaction, OR an unlinked voucher that already books this amount
// on the bank account (invoice "markera som betald", the salary run's net-wage
// payout, a manual verifikat). The agent has no interactive "Bokför ändå", so
// it fails closed; re-stage with allow_duplicate=true after the user confirms
// in chat that the bank line is a genuinely separate event. Fail-open on a
// detection error so a transient query failure never blocks a real booking.
if (params.allow_duplicate !== true) {
let dup = null
try {
dup = await detectBookingDuplicate(supabase, companyId, {
id: txId,
date: transaction.date,
amount: transaction.amount,
cash_account_id: transaction.cash_account_id ?? null,
})
} catch (err) {
log.warn('booking-time duplicate detection failed (continuing)', err)
}
if (dup) {
const amountAbs = roundOre(Math.abs(Number(transaction.amount)))
const voucher = dup.voucher_label ? `verifikat ${dup.voucher_label}` : 'en befintlig verifikation'
return {
error:
`Möjlig dubblettbokföring: ${voucher} (${dup.entry_date}) bokför redan ${amountAbs} kr på bankkontot. ` +
`Den här affärshändelsen ser redan ut att vara bokförd — länka transaktionen till den befintliga ` +
`verifikationen i stället för att bokföra den igen. Om banktransaktionen verkligen är en separat ` +
`affärshändelse, kör om med allow_duplicate=true.`,
status: 409,
}
}
} else {
// allow_duplicate=true bypassed the guard. Booking over a possible
// double-booking is a bookkeeping act that must leave a durable
// behandlingshistorik record (BFNAR 2013:2 kap 8) — the web /book and
// /categorize routes log BankTransactionDuplicateDismissed, and the agent
// commit path must reach parity so an auditor can reconstruct why the
// duplicate was allowed. Re-detect to capture the dismissed candidate;
// best-effort, a logging failure must never block a legitimate booking.
try {
const dismissed = await detectBookingDuplicate(supabase, companyId, {
id: txId,
date: transaction.date,
amount: transaction.amount,
cash_account_id: transaction.cash_account_id ?? null,
})
if (dismissed) {
await appendProcessingHistory({
companyId,
correlationId: txId,
aggregateType: 'BankTransaction',
aggregateId: txId,
eventType: 'BankTransactionDuplicateDismissed',
payload: {
transaction_id: txId,
dismissed_transaction_id: dismissed.transaction_id,
dismissed_journal_entry_id: dismissed.journal_entry_id,
amount_ore: Math.round(dismissed.amount * 100),
entry_date: dismissed.entry_date,
via: 'allow_duplicate',
},
actor: { type: 'user', id: userId },
occurredAt: new Date(),
})
}
} catch (logErr) {
log.warn('failed to record duplicate-dismissal behandlingshistorik', logErr)
}
}
const isBusiness = category !== 'private'
const { data: settings } = await supabase
.from('company_settings').select('entity_type, fiscal_year_start_month').eq('company_id', companyId).single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1
const mappingResult = buildMappingResultFromCategory(
category, transaction as Transaction, isBusiness, entityType, vatTreatment, vatAmount
)
if (!mappingResult.debit_account || !mappingResult.credit_account) {
return { error: `No account mapping for category "${category}" with entity type "${entityType}".`, status: 400 }
}
await ensureFiscalPeriod(supabase, userId, companyId, transaction.date, fiscalYearStartMonth)
let journalEntryId: string | null = null
try {
const journalEntry = await createTransactionJournalEntry(
supabase, companyId, userId, transaction as Transaction, mappingResult, notes,
)
if (journalEntry) journalEntryId = journalEntry.id
} catch (err) {
if (isBookkeepingError(err)) throw err
log.error('Failed to create journal entry:', err)
return { error: err instanceof Error ? err.message : 'Failed to create journal entry', status: 500 }
}
const { error: updateError } = await supabase
.from('transactions')
.update({ is_business: isBusiness, category, journal_entry_id: journalEntryId })
.eq('id', txId)
if (updateError) {
log.error('Failed to update transaction:', updateError)
return { error: 'Failed to update transaction', status: 500 }
}
// Propagate the underlag from a matched invoice-inbox item onto the new
// verifikation. Without this, BFL 7 kap is violated: a verifikation
// exists with no underlag attached even though the user has explicitly
// linked an inbox item (with a document) to this transaction in the
// inbox workspace. We:
// 1. find the inbox item(s) where matched_transaction_id = txId
// 2. for each item with a document_id, set
// document_attachments.journal_entry_id = journalEntryId
// (idempotent — re-linking the same doc is a no-op write).
// 3. stamp invoice_inbox_items.created_journal_entry_id so the inbox
// row visibly moves to "Bearbetade" and shows "Öppna verifikation".
// Errors are logged but don't fail the commit — the verifikation itself
// is already posted, and the link can be repaired by re-running this
// step. A future PR can move this into a single transaction with the
// journal entry creation.
if (journalEntryId) {
try {
const { data: matchedInboxItems } = await supabase
.from('invoice_inbox_items')
.select('id, document_id')
.eq('company_id', companyId)
.eq('matched_transaction_id', txId)
.is('created_journal_entry_id', null)
for (const inbox of (matchedInboxItems ?? []) as Array<{
id: string
document_id: string | null
}>) {
if (inbox.document_id) {
try {
await linkToJournalEntry(supabase, companyId, inbox.document_id, journalEntryId)
} catch (err) {
log.error('Failed to link inbox document to journal entry', {
inbox_item_id: inbox.id,
document_id: inbox.document_id,
journal_entry_id: journalEntryId,
error: err instanceof Error ? err.message : String(err),
})
}
}
const { error: stampError } = await supabase
.from('invoice_inbox_items')
.update({ created_journal_entry_id: journalEntryId })
.eq('id', inbox.id)
.eq('company_id', companyId)
if (stampError) {
log.error('Failed to stamp inbox item created_journal_entry_id', {
inbox_item_id: inbox.id,
journal_entry_id: journalEntryId,
error: stampError.message,
})
}
}
} catch (err) {
log.error('Failed to propagate underlag from matched inbox items', err)
}
}
try {
await upsertCounterpartyTemplate(
supabase, userId, transaction as Transaction, mappingResult, 'user_approved'
)
} catch { /* non-critical */ }
await eventBus.emit({
type: 'transaction.categorized',
payload: {
transaction: transaction as Transaction,
account: mappingResult.debit_account,
taxCode: mappingResult.vat_lines[0]?.account_number || '',
userId,
companyId,
},
// Booking, the duplicate guard, VAT mapping, and matched-inbox underlag
// propagation all live in the shared core (lib/transactions/categorize-core.ts)
// so the bulk-book-inbox executor and the Underlag "Bokför valda" route reuse
// exactly this logic.
return categorizeMatchedTransaction(supabase, userId, companyId, txId, {
category,
vatTreatment,
vatAmount,
notes,
allowDuplicate: params.allow_duplicate === true,
})
return { data: { journal_entry_id: journalEntryId, category } }
}
async function commitCreateCustomer(
@@ -3578,6 +3331,48 @@ async function commitBulkBookTransactions(
return { data: result as unknown as Record<string, unknown>, status: 200 }
}
/**
* Bulk-book selected Underlag (Dokumentinkorgen) Lena-driven flow. Each
* selected inbox item is booked against its matched bank transaction using one
* shared category + VAT treatment. The booking, VAT (incl. reverse charge), and
* underlagverifikat propagation are the SAME shared core the single-item
* categorize path uses (categorizeMatchedTransaction). Items that can't be
* booked are skipped with a reason rather than failing the whole batch the
* "Bokför valda hoppar över" contract. A per-item throw (e.g. period locked,
* accounts not in chart) is caught and recorded as a skip so one bad underlag
* never blocks the rest.
*/
async function commitBulkBookInboxItems(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const parsed = BulkBookInboxSchema.safeParse(params)
if (!parsed.success) {
return { error: `Invalid bulk_book_inbox_items params: ${parsed.error.message}`, status: 400 }
}
const { booked, skipped } = await bulkBookMatchedInboxItems(supabase, userId, companyId, parsed.data)
log.info('bulk_book_inbox_items committed', {
companyId,
operationType: 'bulk_book_inbox_items',
requested: parsed.data.item_ids.length,
bookedCount: booked.length,
skippedCount: skipped.length,
})
return {
data: {
booked_count: booked.length,
skipped_count: skipped.length,
booked,
skipped,
},
}
}
async function commitLinkTransactionJournalEntry(
supabase: SupabaseClient,
userId: string,
@@ -3827,6 +3622,9 @@ async function commitPendingOperationInner(
case 'bulk_book_transactions':
result = await commitBulkBookTransactions(supabase, companyId, pendingOp.params)
break
case 'bulk_book_inbox_items':
result = await commitBulkBookInboxItems(supabase, userId, companyId, pendingOp.params)
break
case 'link_transaction_journal_entry':
result = await commitLinkTransactionJournalEntry(supabase, userId, companyId, pendingOp.params)
break
+6
View File
@@ -118,6 +118,12 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// a verifikat with caller-supplied lines (template-expanded or manual),
// the same compliance-critical surface as create_voucher. 'high'.
bulk_book_transactions: 'high',
// Bulk-book N selected Underlag (Dokumentinkorgen): one posted verifikat per
// matched bank transaction, each with VAT (incl. reverse charge) derived from
// a shared category. Posting N verifikat at once is the same compliance-
// critical surface as bulk_book_transactions, so 'high' — never auto-commit;
// approval requires confirmed=true.
bulk_book_inbox_items: 'high',
// Link a single bank tx to an already-posted verifikat (no new JE created).
// Reversible by clearing transactions.journal_entry_id and deleting any
// invoice_payments row — sits next to link_invoice_voucher semantically;
+1 -8
View File
@@ -497,6 +497,7 @@ describe('generateSIEExport', () => {
{ data: null, error: null }, // prevPeriod
{ data: [], error: null }, // accounts
{
// journal_entries (fetchAllRows) — no embedded lines; stitched below
data: [
// The OB entry itself — must be excluded from movement/VER output
{
@@ -506,10 +507,6 @@ describe('generateSIEExport', () => {
voucher_series: 'A',
description: 'IB 2024',
status: 'posted',
lines: [
{ account_number: '1933', debit_amount: 96466.59, credit_amount: 0, line_description: 'IB 1933', cost_center: null, project: null },
{ account_number: '2019', debit_amount: 0, credit_amount: 96466.59, line_description: null, cost_center: null, project: null },
],
},
// A real transaction: account 1933 swept to 1930
{
@@ -519,10 +516,6 @@ describe('generateSIEExport', () => {
voucher_series: 'A',
description: 'Stängning Bokio',
status: 'posted',
lines: [
{ account_number: '1930', debit_amount: 96466.59, credit_amount: 0, line_description: null, cost_center: null, project: null },
{ account_number: '1933', debit_amount: 0, credit_amount: 96466.59, line_description: null, cost_center: null, project: null },
],
},
],
error: null,
@@ -129,6 +129,30 @@ describe('detectBookedDuplicateTransaction', () => {
})
expect(result?.transaction_id).toBe('sib-2')
})
// ── Intra-batch exclusion (bulk-book false-positive fix) ────────────────
it('excludes a same-batch sibling whose id is in excludeTransactionIds', async () => {
const supabase = makeSupabase([sibling({ id: 'sib-batch' })])
const result = await detectBookedDuplicateTransaction(
supabase,
COMPANY,
{ id: 'self', date: '2025-12-19', amount: -1616, cash_account_id: null },
{ excludeTransactionIds: ['sib-batch'] },
)
expect(result).toBeNull()
})
it('STILL flags a pre-existing sibling not in excludeTransactionIds (invariant preserved)', async () => {
// 'sib-old' existed before the batch; only 'sib-batch' was booked this run.
const supabase = makeSupabase([sibling({ id: 'sib-old' })])
const result = await detectBookedDuplicateTransaction(
supabase,
COMPANY,
{ id: 'self', date: '2025-12-19', amount: -1616, cash_account_id: null },
{ excludeTransactionIds: ['sib-batch'] },
)
expect(result?.transaction_id).toBe('sib-old')
})
})
// ── Ledger-only voucher guard (the orphan with no sibling transaction) ───────
@@ -305,6 +329,29 @@ describe('detectLedgerDuplicateVoucher', () => {
})
expect(result).toBeNull()
})
// ── Intra-batch exclusion (bulk-book false-positive fix) ────────────────
it('excludes a same-batch voucher whose journal_entry.id is in excludeJournalEntryIds', async () => {
const supabase = makeLedgerSupabase({ lines: [jel()] }) // jel() → journal_entry.id 'je-2'
const result = await detectLedgerDuplicateVoucher(
supabase,
COMPANY,
{ id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null },
{ excludeJournalEntryIds: ['je-2'] },
)
expect(result).toBeNull()
})
it('STILL flags a pre-existing voucher not in excludeJournalEntryIds (invariant preserved)', async () => {
const supabase = makeLedgerSupabase({ lines: [jel()] })
const result = await detectLedgerDuplicateVoucher(
supabase,
COMPANY,
{ id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null },
{ excludeJournalEntryIds: ['je-booked-this-batch'] },
)
expect(result?.journal_entry_id).toBe('je-2')
})
})
describe('detectBookingDuplicate (orchestrator)', () => {
@@ -332,4 +379,20 @@ describe('detectBookingDuplicate (orchestrator)', () => {
})
expect(result).toBeNull()
})
it('propagates exclusions to BOTH the sibling scan and the ledger scan', async () => {
// A matching sibling AND a matching ledger voucher exist, but both belong to
// this same batch (excluded) → the orchestrator must report no duplicate.
const supabase = makeLedgerSupabase({
transactionRows: [sibling({ id: 'sib-batch', amount: 98565, journal_entry_id: 'je-sib' })],
lines: [jel()], // journal_entry.id 'je-2'
})
const result = await detectBookingDuplicate(
supabase,
COMPANY,
{ id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null },
{ excludeTransactionIds: ['sib-batch'], excludeJournalEntryIds: ['je-2'] },
)
expect(result).toBeNull()
})
})
@@ -0,0 +1,332 @@
/**
* Bulk-book Underlag (Modell B) core logic.
*
* `bulkBookMatchedInboxItems` is shared by the direct UI route
* (POST /items/bulk-book) and the `bulk_book_inbox_items` pending-operation
* executor. These tests pin the "Bokför valda hoppar över" contract items
* that aren't matched / already booked / linked to a leverantörsfaktura are
* SKIPPED, never errored and the happy path where a matched item is booked
* against its transaction via the shared categorize core.
*
* The single-item categorize core itself (createJE, duplicate guard, VAT
* mapping, underlag propagation) is covered by
* lib/pending-operations/__tests__/commit-duplicate-guard.test.ts and the
* inbox-link pg tests; here we mock its downstream modules and assert the
* loop's classification + collection.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockCreateJE = vi.fn()
const mockDetectDup = vi.fn()
const mockMapping = vi.fn()
const mockUpsertTemplate = vi.fn()
const mockLinkToJE = vi.fn()
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
createTransactionJournalEntry: (...args: unknown[]) => mockCreateJE(...args),
}))
vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({
detectBookingDuplicate: (...args: unknown[]) => mockDetectDup(...args),
}))
vi.mock('@/lib/bookkeeping/category-mapping', () => ({
buildMappingResultFromCategory: (...args: unknown[]) => mockMapping(...args),
}))
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
upsertCounterpartyTemplate: (...args: unknown[]) => mockUpsertTemplate(...args),
}))
vi.mock('@/lib/core/documents/document-service', () => ({
linkToJournalEntry: (...args: unknown[]) => mockLinkToJE(...args),
}))
import { bulkBookMatchedInboxItems } from '../categorize-core'
import { BulkBookInboxSchema } from '@/lib/api/schemas'
import { eventBus } from '@/lib/events/bus'
/** Queue-based supabase mock: each `from()` consumes the next queued result. */
function queuedSupabase(results: Array<{ data?: unknown; error?: unknown }>) {
const queue = [...results]
const from = vi.fn(() => {
const raw = queue.shift() ?? { data: null, error: null }
const result = { data: raw.data ?? null, error: raw.error ?? null }
const chain: object = new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(result)
return () => chain
},
},
)
return chain
})
return { from } as never
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mockDetectDup.mockResolvedValue(null)
mockMapping.mockReturnValue({
rule: null,
debit_account: '5420',
credit_account: '1930',
risk_level: 'LOW',
confidence: 1,
requires_review: false,
default_private: false,
vat_lines: [],
description: 'Programvara',
})
mockCreateJE.mockResolvedValue({ id: 'je-1' })
})
describe('BulkBookInboxSchema', () => {
it('accepts a valid payload', () => {
const r = BulkBookInboxSchema.safeParse({
item_ids: ['11111111-1111-4111-8111-111111111111'],
category: 'expense_software',
vat_treatment: 'reverse_charge',
})
expect(r.success).toBe(true)
})
// Regression: the bulk_book_inbox_items pending operation persists absent
// optionals as explicit JSON null (stagePendingOperation in server.ts). A bare
// `.optional()` rejected those on approval ("expected number, received null").
it('accepts persisted params with explicit nulls and normalizes them to undefined', () => {
const r = BulkBookInboxSchema.safeParse({
item_ids: ['11111111-1111-4111-8111-111111111111'],
category: 'expense_software',
vat_treatment: null,
vat_amount: null,
notes: null,
allow_duplicate: false,
})
expect(r.success).toBe(true)
if (r.success) {
// null must not leak downstream to categorizeMatchedTransaction.
expect(r.data.vat_treatment).toBeUndefined()
expect(r.data.vat_amount).toBeUndefined()
expect(r.data.notes).toBeUndefined()
}
})
it('rejects an empty item_ids array', () => {
const r = BulkBookInboxSchema.safeParse({ item_ids: [], category: 'expense_software' })
expect(r.success).toBe(false)
})
it('rejects a missing category', () => {
const r = BulkBookInboxSchema.safeParse({ item_ids: ['11111111-1111-1111-1111-111111111111'] })
expect(r.success).toBe(false)
})
it('rejects an invalid category', () => {
const r = BulkBookInboxSchema.safeParse({
item_ids: ['11111111-1111-1111-1111-111111111111'],
category: 'expense_unicorns',
})
expect(r.success).toBe(false)
})
it('rejects an invalid vat_treatment', () => {
const r = BulkBookInboxSchema.safeParse({
item_ids: ['11111111-1111-1111-1111-111111111111'],
category: 'expense_software',
vat_treatment: 'omvänd',
})
expect(r.success).toBe(false)
})
it('rejects more than 200 items', () => {
const ids = Array.from({ length: 201 }, (_, i) => `id-${i}`)
const r = BulkBookInboxSchema.safeParse({ item_ids: ids, category: 'expense_software' })
expect(r.success).toBe(false)
})
})
describe('bulkBookMatchedInboxItems — skip classification (never errors)', () => {
const base = { category: 'expense_software' as const }
it('skips an item that is not found', async () => {
const supabase = queuedSupabase([{ data: null }])
const { booked, skipped } = await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', {
...base,
item_ids: ['missing'],
})
expect(booked).toEqual([])
expect(skipped).toEqual([{ item_id: 'missing', reason: 'not_found' }])
expect(mockCreateJE).not.toHaveBeenCalled()
})
it('skips an item already booked (created_journal_entry_id)', async () => {
const supabase = queuedSupabase([
{ data: { id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: 'je-x', created_supplier_invoice_id: null } },
])
const { booked, skipped } = await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', { ...base, item_ids: ['i1'] })
expect(booked).toEqual([])
expect(skipped).toEqual([{ item_id: 'i1', reason: 'already_booked' }])
expect(mockCreateJE).not.toHaveBeenCalled()
})
it('skips an item linked to a supplier invoice', async () => {
const supabase = queuedSupabase([
{ data: { id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: 'si-x' } },
])
const { booked, skipped } = await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', { ...base, item_ids: ['i1'] })
expect(booked).toEqual([])
expect(skipped).toEqual([{ item_id: 'i1', reason: 'is_supplier_invoice' }])
expect(mockCreateJE).not.toHaveBeenCalled()
})
it('skips an item without a matched transaction', async () => {
const supabase = queuedSupabase([
{ data: { id: 'i1', matched_transaction_id: null, created_journal_entry_id: null, created_supplier_invoice_id: null } },
])
const { booked, skipped } = await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', { ...base, item_ids: ['i1'] })
expect(booked).toEqual([])
expect(skipped).toEqual([{ item_id: 'i1', reason: 'not_matched' }])
expect(mockCreateJE).not.toHaveBeenCalled()
})
})
describe('bulkBookMatchedInboxItems — booking', () => {
it('books a matched, unbooked item against its transaction', async () => {
const supabase = queuedSupabase([
// 1. inbox item fetch → bookable
{ data: { id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: null } },
// 2. transactions fetch (categorize core)
{ data: { id: 'tx-1', date: '2026-06-01', amount: -700.28, currency: 'SEK', cash_account_id: null, journal_entry_id: null } },
// 3. company_settings
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
// 4. ensureFiscalPeriod → existing period
{ data: [{ id: 'fp-1' }] },
// 5. transactions update (mark booked)
{ error: null },
// 6. propagation select (no matched inbox rows to stamp in this mock)
{ data: [] },
])
const { booked, skipped } = await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', {
item_ids: ['i1'],
category: 'expense_software',
vat_treatment: 'reverse_charge',
})
expect(skipped).toEqual([])
expect(booked).toEqual([{ item_id: 'i1', transaction_id: 'tx-1', journal_entry_id: 'je-1' }])
expect(mockCreateJE).toHaveBeenCalledTimes(1)
// The shared core received the chosen category + reverse-charge treatment.
expect(mockMapping).toHaveBeenCalledWith(
'expense_software',
expect.objectContaining({ id: 'tx-1' }),
true,
'aktiebolag',
'reverse_charge',
undefined,
)
})
it('books the matched item and skips the unmatched one in a mixed batch', async () => {
const supabase = queuedSupabase([
// item i1 → not matched (1 from())
{ data: { id: 'i1', matched_transaction_id: null, created_journal_entry_id: null, created_supplier_invoice_id: null } },
// item i2 → bookable, then its categorize chain
{ data: { id: 'i2', matched_transaction_id: 'tx-2', created_journal_entry_id: null, created_supplier_invoice_id: null } },
{ data: { id: 'tx-2', date: '2026-06-02', amount: -25, currency: 'SEK', cash_account_id: null, journal_entry_id: null } },
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
{ data: [{ id: 'fp-1' }] },
{ error: null },
{ data: [] },
])
const { booked, skipped } = await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', {
item_ids: ['i1', 'i2'],
category: 'expense_software',
})
expect(skipped).toEqual([{ item_id: 'i1', reason: 'not_matched' }])
expect(booked).toEqual([{ item_id: 'i2', transaction_id: 'tx-2', journal_entry_id: 'je-1' }])
expect(mockCreateJE).toHaveBeenCalledTimes(1)
})
})
describe('bulkBookMatchedInboxItems — intra-batch duplicate handling', () => {
/** Six queued from() results for one successfully-booked item. */
const bookableItem = (itemId: string, txId: string, amount: number) => [
{ data: { id: itemId, matched_transaction_id: txId, created_journal_entry_id: null, created_supplier_invoice_id: null } },
{ data: { id: txId, date: '2026-06-01', amount, currency: 'SEK', cash_account_id: null, journal_entry_id: null } },
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
{ data: [{ id: 'fp-1' }] },
{ error: null },
{ data: [] },
]
it('books BOTH distinct transactions that share (date, amount) in one bulk run', async () => {
// Model the reviewer-reported bug: the guard WOULD flag the second tx as a
// duplicate of the first tx's freshly-created verifikat — but only when the
// first tx is NOT excluded as a same-batch sibling. The fix must pass tx-1
// in as an exclusion so tx-2 books instead of being skipped 409.
mockDetectDup.mockImplementation(
(_sb: unknown, _co: unknown, target: { id: string }, exclude?: { excludeTransactionIds?: string[] }) => {
if (target.id === 'tx-2' && !(exclude?.excludeTransactionIds ?? []).includes('tx-1')) {
return Promise.resolve({
transaction_id: 'tx-1', journal_entry_id: 'je-1', voucher_label: 'A1',
entry_date: '2026-06-01', description: null, amount: 700.28,
})
}
return Promise.resolve(null)
},
)
const supabase = queuedSupabase([
...bookableItem('i1', 'tx-1', -700.28),
...bookableItem('i2', 'tx-2', -700.28),
])
const { booked, skipped } = await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', {
item_ids: ['i1', 'i2'],
category: 'expense_software',
})
expect(skipped).toEqual([])
expect(booked).toEqual([
{ item_id: 'i1', transaction_id: 'tx-1', journal_entry_id: 'je-1' },
{ item_id: 'i2', transaction_id: 'tx-2', journal_entry_id: 'je-1' },
])
expect(mockCreateJE).toHaveBeenCalledTimes(2)
// The SECOND booking was handed tx-1 (and its verifikat) as an intra-batch
// exclusion; the first was handed an empty set.
const firstCall = mockDetectDup.mock.calls.find((c) => (c[2] as { id: string }).id === 'tx-1')
const secondCall = mockDetectDup.mock.calls.find((c) => (c[2] as { id: string }).id === 'tx-2')
expect(firstCall?.[3]).toEqual({ excludeTransactionIds: [], excludeJournalEntryIds: [] })
expect(secondCall?.[3]).toEqual({ excludeTransactionIds: ['tx-1'], excludeJournalEntryIds: ['je-1'] })
})
it('STILL skips a pre-existing already-booked duplicate (cross-batch detection preserved)', async () => {
// The guard fires on a duplicate that existed BEFORE this batch: its ids are
// absent from the (empty) exclusion set, so the booking is refused (409) and
// the item is skipped as a possible duplicate rather than double-booked.
mockDetectDup.mockResolvedValue({
transaction_id: 'tx-preexisting', journal_entry_id: 'je-old', voucher_label: 'A9',
entry_date: '2026-06-01', description: null, amount: 700.28,
})
const supabase = queuedSupabase([
{ data: { id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: null } },
{ data: { id: 'tx-1', date: '2026-06-01', amount: -700.28, currency: 'SEK', cash_account_id: null, journal_entry_id: null } },
])
const { booked, skipped } = await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', {
item_ids: ['i1'],
category: 'expense_software',
})
expect(booked).toEqual([])
expect(skipped).toHaveLength(1)
expect(skipped[0].item_id).toBe('i1')
expect(skipped[0].reason).toBe('already_booked_or_duplicate')
expect(mockCreateJE).not.toHaveBeenCalled()
})
})
@@ -56,6 +56,28 @@ export interface BookingTarget {
cash_account_id?: string | null
}
/**
* Same-batch siblings to exclude from booking-time duplicate detection.
*
* When a bulk run books several DISTINCT bank movements that happen to share a
* (date, amount, cash account) several identical Swish transfers the user
* explicitly selected the second booking must NOT dedupe against the first
* booking's freshly-created verifikat: they are separate affärshändelser. The
* bulk driver accumulates the ids it has booked so far in THIS batch and passes
* them here so intra-batch siblings never flag one another.
*
* CRITICAL: only ids created within the current batch belong here. A duplicate
* that existed BEFORE the batch has neither its transaction id nor its voucher
* id in these lists, so it is STILL detected and skipped. Both fields are
* optional; the default (no exclusion) keeps single-booking callers unaffected.
*/
export interface BookingDuplicateExclusions {
/** Sibling transaction ids booked earlier in the same bulk run. */
excludeTransactionIds?: string[]
/** Journal-entry ids minted earlier in the same bulk run. */
excludeJournalEntryIds?: string[]
}
/**
* Find an already-booked sibling transaction sharing (date, amount, account).
* Returns the single best candidate, or null.
@@ -73,9 +95,13 @@ export async function detectBookedDuplicateTransaction(
supabase: SupabaseClient,
companyId: string,
target: BookingTarget,
opts?: BookingDuplicateExclusions,
): Promise<BookedDuplicateCandidate | null> {
const targetOre = toOre(target.amount)
if (targetOre === 0 || Number.isNaN(targetOre)) return null
// Siblings booked earlier in this same bulk run are distinct events the user
// selected, not duplicates — never flag one against another.
const excludeTransactionIds = new Set(opts?.excludeTransactionIds ?? [])
// Same company, same date, already booked, not the target row itself. The
// amount and account match is applied in JS so a numeric-string amount from
@@ -101,6 +127,7 @@ export async function detectBookedDuplicateTransaction(
}
const targetAccount = target.cash_account_id ?? null
const matches = (data as unknown as Row[]).filter((r) => {
if (excludeTransactionIds.has(r.id)) return false
if (toOre(r.amount) !== targetOre) return false
// Account guard: both-known must match; a null on either side is compatible.
if (targetAccount !== null && r.cash_account_id !== null && r.cash_account_id !== targetAccount) {
@@ -179,9 +206,13 @@ export async function detectLedgerDuplicateVoucher(
supabase: SupabaseClient,
companyId: string,
target: BookingTarget,
opts?: BookingDuplicateExclusions,
): Promise<BookedDuplicateCandidate | null> {
const targetOre = toOre(target.amount)
if (targetOre === 0 || Number.isNaN(targetOre)) return null
// Vouchers minted earlier in this same bulk run are this batch's own fresh
// bookings — a subsequent sibling must not dedupe against them.
const excludeJournalEntryIds = new Set(opts?.excludeJournalEntryIds ?? [])
const targetAmount = roundOre(Math.abs(Number(target.amount)))
const inbound = targetOre > 0
@@ -251,6 +282,8 @@ export async function detectLedgerDuplicateVoucher(
}
}
const candidates = (lines as unknown as LineRow[])
// Same-batch vouchers are this run's own fresh bookings, never duplicates.
.filter((l) => !excludeJournalEntryIds.has(l.journal_entry.id))
.filter((l) => {
const legAmount = roundOre(Number(inbound ? l.debit_amount : l.credit_amount))
return Math.abs(legAmount - targetAmount) < 0.01
@@ -309,8 +342,9 @@ export async function detectBookingDuplicate(
supabase: SupabaseClient,
companyId: string,
target: BookingTarget,
opts?: BookingDuplicateExclusions,
): Promise<BookedDuplicateCandidate | null> {
const sibling = await detectBookedDuplicateTransaction(supabase, companyId, target)
const sibling = await detectBookedDuplicateTransaction(supabase, companyId, target, opts)
if (sibling) return sibling
return detectLedgerDuplicateVoucher(supabase, companyId, target)
return detectLedgerDuplicateVoucher(supabase, companyId, target, opts)
}
+465
View File
@@ -0,0 +1,465 @@
/**
* Shared core for booking a bank transaction by category.
*
* This is the single implementation behind three callers:
* 1. The single-transaction approval executor `commitCategorizeTransaction`
* (lib/pending-operations/commit.ts) the agent / web "Kategorisera"
* flow.
* 2. The bulk-book-inbox executor `commitBulkBookInboxItems`
* (lib/pending-operations/commit.ts) Lena driving the Underlag view.
* 3. The direct UI bulk-book route (`POST /items/bulk-book` in the
* invoice-inbox extension) the "Bokför valda" button.
*
* Extracting it keeps the VAT/mapping logic, the duplicate guard, and the
* matched-inbox underlag propagation in ONE place. "Booking an underlag" in the
* Dokumentinkorgen is implemented as categorizing the bank transaction it is
* matched to: `buildMappingResultFromCategory` produces correct accounts +
* reverse-charge VAT, and the propagation step below attaches the underlag to
* the new verifikation (BFL 7 kap) and stamps the inbox item resolved.
*
* Booking is always in SEK off the bank transaction's own amount (BFL 5 kap
* 2§), so the foreign-currency underlag never needs an FX step here the bank
* already settled it.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { detectBookingDuplicate, type BookingDuplicateExclusions } from '@/lib/transactions/booking-duplicate-detection'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { roundOre } from '@/lib/money'
import { createLogger } from '@/lib/logger'
import type { Transaction, TransactionCategory, EntityType, VatTreatment } from '@/types'
const log = createLogger('transactions/categorize-core')
/** Structurally compatible with the commit.ts `ExecutorResult`. */
export interface CategorizeCoreResult {
data?: Record<string, unknown>
error?: string
status?: number
}
export interface CategorizeMatchedTransactionOpts {
category: TransactionCategory
vatTreatment?: VatTreatment
/**
* The underlag's actual VAT when it differs from rate × belopp (e.g. dricks).
* Only valid with a rate-based vat_treatment; see buildMappingResultFromCategory.
*/
vatAmount?: number
/** Audit-trail text appended to the verifikation description. */
notes?: string
/**
* Bypass the booking-time duplicate guard. Default false the guard fails
* closed when another verifikat already books this amount on the bank
* account, and the caller surfaces the skip.
*/
allowDuplicate?: boolean
}
// ── Helper: ensure a fiscal period covers the date ──────────────────
//
// Moved here from lib/pending-operations/commit.ts so the core is
// self-contained; commit.ts now imports it from this module.
export async function ensureFiscalPeriod(
supabase: SupabaseClient,
userId: string,
companyId: string,
date: string,
fiscalYearStartMonth: number = 1
): Promise<boolean> {
const { data: existing } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.lte('period_start', date)
.gte('period_end', date)
.eq('is_closed', false)
.limit(1)
if (existing && existing.length > 0) return true
const txDate = new Date(date)
const txMonth = txDate.getMonth() + 1
const txYear = txDate.getFullYear()
let periodStartYear: number
if (fiscalYearStartMonth === 1) {
periodStartYear = txYear
} else if (txMonth >= fiscalYearStartMonth) {
periodStartYear = txYear
} else {
periodStartYear = txYear - 1
}
const startMonth = String(fiscalYearStartMonth).padStart(2, '0')
const periodStart = `${periodStartYear}-${startMonth}-01`
const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
const lastDay = new Date(endYear, endMonth, 0).getDate()
const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
const periodName = fiscalYearStartMonth === 1
? `Räkenskapsår ${periodStartYear}`
: `Räkenskapsår ${periodStartYear}/${endYear}`
const { error } = await supabase
.from('fiscal_periods')
.upsert({
user_id: userId,
company_id: companyId,
name: periodName,
period_start: periodStart,
period_end: periodEnd,
}, { onConflict: 'user_id,period_start,period_end' })
if (error) {
log.error('Failed to create fiscal period:', error)
return false
}
return true
}
/**
* Book a single bank transaction by category. Creates the verifikation, marks
* the transaction booked, propagates any matched invoice-inbox underlag onto
* the new entry (stamping `created_journal_entry_id` so the inbox row moves to
* "Bearbetade"), and records the counterparty template.
*
* Returns `{ data }` on success or `{ error, status }` on a recoverable
* failure (404 missing tx, 409 already booked / possible duplicate, 400 no
* mapping, 500 DB). Throws only on AccountsNotInChartError so the caller's
* recover-and-retry path stays intact.
*/
export async function categorizeMatchedTransaction(
supabase: SupabaseClient,
userId: string,
companyId: string,
txId: string,
opts: CategorizeMatchedTransactionOpts,
/**
* Same-batch siblings to exclude from the duplicate guard. Only set by the
* bulk driver so intra-batch bookings of DISTINCT same-(date,amount) events
* never dedupe against one another. Omitted (single-booking callers) = the
* full guard runs unchanged.
*/
exclude?: BookingDuplicateExclusions,
): Promise<CategorizeCoreResult> {
const { category, vatTreatment, vatAmount, notes, allowDuplicate } = opts
const { data: transaction, error: fetchError } = await supabase
.from('transactions').select('*').eq('id', txId).eq('company_id', companyId).single()
if (fetchError || !transaction) {
return { error: 'Transaction not found — it may have been deleted.', status: 404 }
}
if (transaction.journal_entry_id) {
return { error: 'Transaction already has a journal entry — it was categorized in the meantime.', status: 409 }
}
// Booking-time duplicate guard — parity with the web /categorize route.
// Refuse to mint a second verifikat for an affärshändelse already in the
// ledger: an already-booked sibling transaction, OR an unlinked voucher that
// already books this amount on the bank account (invoice "markera som
// betald", the salary run's net-wage payout, a manual verifikat). Fail
// closed; the caller re-runs with allowDuplicate=true after the user
// confirms the bank line is a genuinely separate event. Fail-open on a
// detection error so a transient query failure never blocks a real booking.
if (allowDuplicate !== true) {
let dup = null
try {
dup = await detectBookingDuplicate(supabase, companyId, {
id: txId,
date: transaction.date,
amount: transaction.amount,
cash_account_id: transaction.cash_account_id ?? null,
}, exclude)
} catch (err) {
log.warn('booking-time duplicate detection failed (continuing)', err)
}
if (dup) {
const amountAbs = roundOre(Math.abs(Number(transaction.amount)))
const voucher = dup.voucher_label ? `verifikat ${dup.voucher_label}` : 'en befintlig verifikation'
return {
error:
`Möjlig dubblettbokföring: ${voucher} (${dup.entry_date}) bokför redan ${amountAbs} kr på bankkontot. ` +
`Den här affärshändelsen ser redan ut att vara bokförd — länka transaktionen till den befintliga ` +
`verifikationen i stället för att bokföra den igen. Om banktransaktionen verkligen är en separat ` +
`affärshändelse, kör om med allow_duplicate=true.`,
status: 409,
}
}
} else {
// allowDuplicate=true bypassed the guard. Booking over a possible
// double-booking is a bookkeeping act that must leave a durable
// behandlingshistorik record (BFNAR 2013:2 kap 8). Re-detect to capture
// the dismissed candidate; best-effort, a logging failure must never block
// a legitimate booking.
try {
const dismissed = await detectBookingDuplicate(supabase, companyId, {
id: txId,
date: transaction.date,
amount: transaction.amount,
cash_account_id: transaction.cash_account_id ?? null,
}, exclude)
if (dismissed) {
await appendProcessingHistory({
companyId,
correlationId: txId,
aggregateType: 'BankTransaction',
aggregateId: txId,
eventType: 'BankTransactionDuplicateDismissed',
payload: {
transaction_id: txId,
dismissed_transaction_id: dismissed.transaction_id,
dismissed_journal_entry_id: dismissed.journal_entry_id,
amount_ore: Math.round(dismissed.amount * 100),
entry_date: dismissed.entry_date,
via: 'allow_duplicate',
},
actor: { type: 'user', id: userId },
occurredAt: new Date(),
})
}
} catch (logErr) {
log.warn('failed to record duplicate-dismissal behandlingshistorik', logErr)
}
}
const isBusiness = category !== 'private'
const { data: settings } = await supabase
.from('company_settings').select('entity_type, fiscal_year_start_month').eq('company_id', companyId).single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1
const mappingResult = buildMappingResultFromCategory(
category, transaction as Transaction, isBusiness, entityType, vatTreatment, vatAmount
)
if (!mappingResult.debit_account || !mappingResult.credit_account) {
return { error: `No account mapping for category "${category}" with entity type "${entityType}".`, status: 400 }
}
await ensureFiscalPeriod(supabase, userId, companyId, transaction.date, fiscalYearStartMonth)
let journalEntryId: string | null = null
try {
const journalEntry = await createTransactionJournalEntry(
supabase, companyId, userId, transaction as Transaction, mappingResult, notes,
)
if (journalEntry) journalEntryId = journalEntry.id
} catch (err) {
if (isBookkeepingError(err)) throw err
log.error('Failed to create journal entry:', err)
return { error: err instanceof Error ? err.message : 'Failed to create journal entry', status: 500 }
}
const { error: updateError } = await supabase
.from('transactions')
.update({ is_business: isBusiness, category, journal_entry_id: journalEntryId })
.eq('id', txId)
if (updateError) {
log.error('Failed to update transaction:', updateError)
return { error: 'Failed to update transaction', status: 500 }
}
// Propagate the underlag from a matched invoice-inbox item onto the new
// verifikation. Without this, BFL 7 kap is violated: a verifikation exists
// with no underlag attached even though the user explicitly linked an inbox
// item (with a document) to this transaction. We:
// 1. find the inbox item(s) where matched_transaction_id = txId
// 2. for each item with a document_id, set
// document_attachments.journal_entry_id = journalEntryId (idempotent)
// 3. stamp invoice_inbox_items.created_journal_entry_id so the inbox row
// visibly moves to "Bearbetade" and shows "Öppna verifikation".
// Errors are logged but don't fail the commit — the verifikation itself is
// already posted, and the link can be repaired by re-running this step.
if (journalEntryId) {
try {
const { data: matchedInboxItems } = await supabase
.from('invoice_inbox_items')
.select('id, document_id')
.eq('company_id', companyId)
.eq('matched_transaction_id', txId)
.is('created_journal_entry_id', null)
for (const inbox of (matchedInboxItems ?? []) as Array<{
id: string
document_id: string | null
}>) {
if (inbox.document_id) {
try {
await linkToJournalEntry(supabase, companyId, inbox.document_id, journalEntryId)
} catch (err) {
log.error('Failed to link inbox document to journal entry', {
inbox_item_id: inbox.id,
document_id: inbox.document_id,
journal_entry_id: journalEntryId,
error: err instanceof Error ? err.message : String(err),
})
}
}
const { error: stampError } = await supabase
.from('invoice_inbox_items')
.update({ created_journal_entry_id: journalEntryId })
.eq('id', inbox.id)
.eq('company_id', companyId)
if (stampError) {
log.error('Failed to stamp inbox item created_journal_entry_id', {
inbox_item_id: inbox.id,
journal_entry_id: journalEntryId,
error: stampError.message,
})
}
}
} catch (err) {
log.error('Failed to propagate underlag from matched inbox items', err)
}
}
try {
await upsertCounterpartyTemplate(
supabase, userId, transaction as Transaction, mappingResult, 'user_approved'
)
} catch { /* non-critical */ }
await eventBus.emit({
type: 'transaction.categorized',
payload: {
transaction: transaction as Transaction,
account: mappingResult.debit_account,
taxCode: mappingResult.vat_lines[0]?.account_number || '',
userId,
companyId,
},
})
return { data: { journal_entry_id: journalEntryId, category } }
}
// ── Bulk: book N selected Underlag against their matched transactions ──────
export interface BulkBookInboxInput {
item_ids: string[]
category: TransactionCategory
vat_treatment?: VatTreatment
vat_amount?: number
notes?: string
allow_duplicate?: boolean
}
export interface BulkBookInboxResult {
booked: Array<{ item_id: string; transaction_id: string; journal_entry_id: string | null }>
skipped: Array<{ item_id: string; reason: string; detail?: string }>
}
/**
* Book each selected inbox item against its matched bank transaction with one
* shared category + VAT treatment. Items without a matched transaction, already
* booked, or already linked to a leverantörsfaktura are skipped never an
* error so one bad underlag never blocks the rest ("Bokför valda hoppar
* över"). A per-item throw (period locked, accounts not in chart) is caught and
* recorded as a skip with the actionable message.
*
* Shared by the direct UI route (POST /items/bulk-book) and the
* `bulk_book_inbox_items` pending-operation executor (Lena-driven flow).
*/
export async function bulkBookMatchedInboxItems(
supabase: SupabaseClient,
userId: string,
companyId: string,
input: BulkBookInboxInput,
): Promise<BulkBookInboxResult> {
const { item_ids, category, vat_treatment, vat_amount, notes, allow_duplicate } = input
const booked: BulkBookInboxResult['booked'] = []
const skipped: BulkBookInboxResult['skipped'] = []
// Ids booked so far in THIS batch. Passed as exclusions to each subsequent
// booking so two DISTINCT bank movements the user selected that share a
// (date, amount, cash account) don't dedupe against each other's freshly
// minted verifikat. Duplicates that existed BEFORE the batch are absent from
// these lists, so the guard still catches them (see BookingDuplicateExclusions).
const bookedTransactionIds: string[] = []
const bookedJournalEntryIds: string[] = []
for (const itemId of item_ids) {
const { data: item, error: itemError } = await supabase
.from('invoice_inbox_items')
.select('id, matched_transaction_id, created_journal_entry_id, created_supplier_invoice_id')
.eq('id', itemId)
.eq('company_id', companyId)
.maybeSingle()
if (itemError || !item) {
skipped.push({ item_id: itemId, reason: 'not_found' })
continue
}
if (item.created_journal_entry_id) {
skipped.push({ item_id: itemId, reason: 'already_booked' })
continue
}
if (item.created_supplier_invoice_id) {
skipped.push({ item_id: itemId, reason: 'is_supplier_invoice' })
continue
}
if (!item.matched_transaction_id) {
skipped.push({ item_id: itemId, reason: 'not_matched' })
continue
}
let result: CategorizeCoreResult
try {
result = await categorizeMatchedTransaction(
supabase,
userId,
companyId,
item.matched_transaction_id as string,
{ category, vatTreatment: vat_treatment, vatAmount: vat_amount, notes, allowDuplicate: allow_duplicate },
// Snapshot copies so the guard sees only the prior bookings of this batch.
{ excludeTransactionIds: [...bookedTransactionIds], excludeJournalEntryIds: [...bookedJournalEntryIds] },
)
} catch (err) {
// Caught per-item (incl. AccountsNotInChartError / period-lock bookkeeping
// errors) so the batch keeps going. The message carries the actionable
// detail (e.g. which BAS accounts to activate).
skipped.push({
item_id: itemId,
reason: 'error',
detail: err instanceof Error ? err.message : String(err),
})
continue
}
if (result.error) {
const reason =
result.status === 404 ? 'transaction_not_found'
: result.status === 409 ? 'already_booked_or_duplicate'
: result.status === 400 ? 'no_account_mapping'
: 'error'
skipped.push({ item_id: itemId, reason, detail: result.error })
continue
}
const bookedTxId = item.matched_transaction_id as string
const bookedJeId = (result.data?.journal_entry_id as string | null) ?? null
// Record this booking so it is excluded from the NEXT item's duplicate guard.
bookedTransactionIds.push(bookedTxId)
if (bookedJeId) bookedJournalEntryIds.push(bookedJeId)
booked.push({
item_id: itemId,
transaction_id: bookedTxId,
journal_entry_id: bookedJeId,
})
}
return { booked, skipped }
}
+21 -1
View File
@@ -1139,8 +1139,17 @@
"fy_status_open": "Open",
"fy_status_locked": "Locked",
"fy_status_closed": "Closed",
"fy_action_lock": "Lock",
"fy_action_unlock": "Unlock",
"fy_confirm_cancel": "Cancel",
"fy_lock_confirm_title": "Lock fiscal year?",
"fy_lock_confirm_body": "{name} will be locked. No new entries can be posted in this period until you unlock it again.",
"fy_unlock_confirm_title": "Unlock fiscal year?",
"fy_unlock_confirm_body": "{name} will be unlocked so entries can be posted in this period again. This action is recorded in the audit log.",
"fy_lock_success": "Fiscal year locked",
"fy_unlock_success": "Fiscal year unlocked",
"fy_action_error": "The action could not be completed",
"related_heading": "Related",
"related_fiscal_year": "Fiscal years and opening balances",
"related_chart_of_accounts": "Chart of accounts (BAS)"
},
"settings_tax": {},
@@ -1778,10 +1787,17 @@
"doc_attached_count": "{count} attached",
"doc_pick_existing": "Choose existing document",
"doc_picked_remove": "Remove document",
"doc_clear": "Remove document",
"doc_link_failed_title": "Receipt could not be attached",
"doc_link_failed_description": "{count} file(s) could not be linked to the journal entry. Try again from the bookkeeping page.",
"bank_line_description": "Business account"
},
"document_viewer": {
"empty": "No document attached",
"header_label": "Document",
"open_in_new_tab": "Open in new tab",
"not_previewable": "Can't preview"
},
"tx_attach_dialog": {
"title": "Match to document",
"description": "Attach a receipt or invoice to the transaction — pick from the inbox or upload a new file.",
@@ -3203,6 +3219,7 @@
"correct_menu": "Correct",
"correct_lines": "Correct lines",
"correct_date": "Correct date",
"correct_opening_balances": "Correct opening balances",
"reverse_action": "Reverse (storno)",
"reverse_confirm_title": "Reverse journal entry",
"reverse_confirm_label": "Create storno",
@@ -3263,6 +3280,7 @@
"delete_dialog_entry_body": "The journal entry and its lines are removed. Linked transactions and invoices keep their data but are marked as unposted. Documents (receipts, files) are kept but unlinked."
},
"journal_form": {
"account_not_activated": "Activated when you post",
"save_edit": "Save changes",
"toast_updated_title": "Draft updated",
"toast_updated_description": "Your changes to the draft were saved.",
@@ -3664,6 +3682,8 @@
"tab_new_entry": "New journal entry",
"tab_accounts": "Chart of accounts",
"new_entry_dialog_title": "New journal entry",
"create_with_assistant": "Create with assistant",
"ask_assistant_handoff": "Let the assistant fill it in?",
"loading_source_voucher": "Loading source voucher...",
"copy_failed_title": "Could not copy journal entry",
"copy_source_missing": "Source voucher not found.",
+21 -1
View File
@@ -1139,8 +1139,17 @@
"fy_status_open": "Öppet",
"fy_status_locked": "Låst",
"fy_status_closed": "Stängt",
"fy_action_lock": "Lås",
"fy_action_unlock": "Lås upp",
"fy_confirm_cancel": "Avbryt",
"fy_lock_confirm_title": "Lås räkenskapsår?",
"fy_lock_confirm_body": "{name} låses. Inga nya verifikationer kan bokföras i perioden förrän du låser upp den igen.",
"fy_unlock_confirm_title": "Lås upp räkenskapsår?",
"fy_unlock_confirm_body": "{name} låses upp så att verifikationer åter kan bokföras i perioden. Åtgärden loggas i behandlingshistoriken.",
"fy_lock_success": "Räkenskapsåret är låst",
"fy_unlock_success": "Räkenskapsåret är upplåst",
"fy_action_error": "Åtgärden kunde inte slutföras",
"related_heading": "Relaterat",
"related_fiscal_year": "Räkenskapsår och ingående balanser",
"related_chart_of_accounts": "Kontoplan (BAS)"
},
"settings_tax": {},
@@ -1778,10 +1787,17 @@
"doc_attached_count": "{count} bifogade",
"doc_pick_existing": "Välj befintligt underlag",
"doc_picked_remove": "Ta bort underlag",
"doc_clear": "Ta bort underlag",
"doc_link_failed_title": "Underlag kunde inte bifogas",
"doc_link_failed_description": "{count} fil(er) kunde inte länkas till verifikationen. Försök igen via bokföringssidan.",
"bank_line_description": "Företagskonto"
},
"document_viewer": {
"empty": "Inget underlag bifogat",
"header_label": "Underlag",
"open_in_new_tab": "Öppna i ny flik",
"not_previewable": "Kan inte förhandsvisas"
},
"tx_attach_dialog": {
"title": "Matcha mot underlag",
"description": "Koppla ett kvitto eller en faktura till transaktionen — välj från inkorgen eller ladda upp en ny fil.",
@@ -3203,6 +3219,7 @@
"correct_menu": "Rätta",
"correct_lines": "Rätta rader",
"correct_date": "Rätta datum",
"correct_opening_balances": "Korrigera ingående balanser",
"reverse_action": "Återför (storno)",
"reverse_confirm_title": "Återför verifikat",
"reverse_confirm_label": "Skapa storno",
@@ -3263,6 +3280,7 @@
"delete_dialog_entry_body": "Verifikatet och dess kontorader tas bort. Kopplade transaktioner och fakturor behåller sina uppgifter men markeras som ej bokförda. Underlag (kvitton, dokument) behålls men avlänkas."
},
"journal_form": {
"account_not_activated": "Aktiveras vid bokföring",
"save_edit": "Spara ändringar",
"toast_updated_title": "Utkast uppdaterat",
"toast_updated_description": "Ändringarna i utkastet har sparats.",
@@ -3664,6 +3682,8 @@
"tab_new_entry": "Ny verifikation",
"tab_accounts": "Kontoplan",
"new_entry_dialog_title": "Ny verifikation",
"create_with_assistant": "Skapa med assistent",
"ask_assistant_handoff": "Hellre låta assistenten fylla i?",
"loading_source_voucher": "Laddar källverifikat...",
"copy_failed_title": "Kunde inte kopiera verifikat",
"copy_source_missing": "Källverifikatet hittades inte.",
+88
View File
@@ -0,0 +1,88 @@
/**
* One-off diagnostic: dump Enable Banking ASPSP metadata for Handelsbanken,
* specifically the available auth_methods (name + approach + psu_types) for
* business vs personal. Answers: does HB expose a DECOUPLED (Mobile BankID)
* method, and which method is first/default when we omit auth_method?
*
* Run: node scripts/check-handelsbanken-aspsp.mjs
* Reads ENABLE_BANKING_* from .env (sandbox or production, whatever is set).
*/
import * as crypto from 'crypto'
import * as fs from 'fs'
// --- minimal .env parser (APP_ID, PRIVATE_KEY, API_URL) ---
const env = {}
for (const raw of fs.readFileSync('.env', 'utf-8').split('\n')) {
const line = raw.replace(/\r$/, '')
const m = line.match(/^([A-Z0-9_]+)=(.*)$/)
if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '')
}
const APP_ID = env.ENABLE_BANKING_APP_ID_PRODUCTION || env.ENABLE_BANKING_APP_ID
const PRIVATE_KEY_RAW = env.ENABLE_BANKING_PRIVATE_KEY_PRODUCTION || env.ENABLE_BANKING_PRIVATE_KEY
const API_URL =
env.ENABLE_BANKING_API_URL_PRODUCTION || env.ENABLE_BANKING_API_URL || 'https://api.enablebanking.com'
const isSandbox = API_URL.includes('tilisy')
function getPrivateKey() {
const decoded = Buffer.from(PRIVATE_KEY_RAW, 'base64').toString('utf-8')
if (decoded.startsWith('-----BEGIN')) return decoded
const lines = PRIVATE_KEY_RAW.match(/.{1,64}/g) || []
return `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----`
}
function b64url(d) {
const s = typeof d === 'string' ? d : d.toString('base64')
return s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function jwt() {
const now = Math.floor(Date.now() / 1000)
const header = b64url(Buffer.from(JSON.stringify({ typ: 'JWT', alg: 'RS256', kid: APP_ID })))
const payload = b64url(
Buffer.from(JSON.stringify({ iss: 'enablebanking.com', aud: 'api.enablebanking.com', iat: now, exp: now + 600 }))
)
const sign = crypto.createSign('RSA-SHA256')
sign.update(`${header}.${payload}`)
sign.end()
return `${header}.${payload}.${b64url(sign.sign(getPrivateKey()))}`
}
async function aspsps(psuType) {
const params = new URLSearchParams({ country: 'SE', sandbox: String(isSandbox), psu_type: psuType })
const res = await fetch(`${API_URL}/aspsps?${params}`, {
headers: { Authorization: `Bearer ${jwt()}`, 'Content-Type': 'application/json' },
})
if (!res.ok) throw new Error(`/aspsps ${psuType} -> ${res.status}: ${await res.text()}`)
return (await res.json()).aspsps || []
}
console.log(`API: ${API_URL} (sandbox=${isSandbox})\n`)
for (const psuType of ['business', 'personal']) {
console.log(`========== psu_type=${psuType} ==========`)
let list
try {
list = await aspsps(psuType)
} catch (e) {
console.log(` ERROR: ${e.message}\n`)
continue
}
const hb = list.filter((a) => /handels/i.test(a.name))
if (!hb.length) {
console.log(` (no Handelsbanken in ${list.length} SE ASPSPs for ${psuType})`)
console.log(` names: ${list.map((a) => a.name).join(', ')}\n`)
continue
}
for (const a of hb) {
console.log(`\n ${a.name} (${a.country}) bic=${a.bic ?? '-'} beta=${a.beta ?? '-'}`)
console.log(` psu_types: ${JSON.stringify(a.psu_types)}`)
console.log(` max_consent_validity: ${a.maximum_consent_validity ?? a.max_consent_validity ?? '-'}`)
const methods = a.auth_methods || a.available_auth_methods || []
console.log(` auth_methods (${methods.length}), FIRST is the default when we omit auth_method:`)
methods.forEach((m, i) =>
console.log(
` [${i}] name=${m.name} approach=${m.approach ?? '-'} psu_types=${JSON.stringify(
m.psu_types
)} title=${JSON.stringify(m.title)} hidden=${m.hidden_method ?? '-'}`
)
)
}
console.log('')
}
@@ -0,0 +1,35 @@
-- Raise statement_timeout inside the SIE bulk-delete RPCs so replacing /
-- undoing a large import does not get cancelled mid-delete.
--
-- Background: replace_sie_import / undo_sie_import hard-delete every
-- source_type='import' (and 'opening_balance') journal entry for a period.
-- Each DELETE fires write_audit_log (a JSONB old_state snapshot insert) and
-- cascades to journal_entry_lines, so a real-world migration import (e.g.
-- ~2,700 vouchers / ~12,000 lines) takes well over 8 seconds.
--
-- The original migration (20260526120000) routed these RPCs onto the
-- service-role REST client on the assumption that "the service role has no
-- statement_timeout". That assumption is wrong: pg_roles shows
-- service_role.rolconfig IS NULL, so a PostgREST request keeps the 8s
-- statement_timeout that the `authenticator` login role sets. The role
-- switch (SET ROLE service_role) does not reset the session GUC because
-- service_role carries no statement_timeout of its own. Result: the delete
-- is cancelled with "canceling statement due to statement timeout", the RPC
-- rolls back, and the route returns SIE_REPLACE_FAILED / SIE_UNDO_FAILED.
--
-- Fix: attach a function-local statement_timeout to each SECURITY DEFINER
-- RPC. A function-scoped SET re-arms the timer for the duration of the call
-- (PostgreSQL re-evaluates statement_timeout when the GUC changes) and is
-- restored on function exit. 290s sits just under the route's
-- maxDuration=300 ceiling, so the HTTP/serverless layer remains the
-- effective bound while the DB no longer cancels a legitimate cleanup.
--
-- Bodies are unchanged; only the function configuration is altered.
ALTER FUNCTION public.replace_sie_import(uuid, uuid)
SET statement_timeout = '290s';
ALTER FUNCTION public.undo_sie_import(uuid, uuid, uuid)
SET statement_timeout = '290s';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,71 @@
-- Add 'bulk_book_inbox_items' to the pending_operations operation_type CHECK
-- constraint.
--
-- The MCP tool gnubok_bulk_book_inbox_items (Lena driving the Underlag view /
-- Dokumentinkorgen) stages a pending operation that, on approval, dispatches
-- into commitBulkBookInboxItems. That executor books each selected inbox item
-- against its matched bank transaction using one shared category + VAT
-- treatment (reusing the same categorize core as gnubok_categorize_transaction,
-- so reverse-charge moms is handled correctly). Without this expansion the
-- staged INSERT would be rejected by the constraint before the commit-side code
-- ever runs, blocking the staged-operation review flow — mirrors
-- create_supplier_invoice_from_inbox / bulk_book_transactions.
--
-- Risk tier (lib/pending-operations/risk-tiers.ts): 'high' — posts N verifikat
-- with VAT in one approval, the same compliance surface as
-- bulk_book_transactions. Never auto-committed.
--
-- pg-test: covered-by — CHECK-list expansion only (no trigger/RPC/RLS/
-- DEFERRABLE change), so no *.pg.test.ts is required. Mirrors
-- 20260621120100_pending_operations_add_articles.sql.
ALTER TABLE public.pending_operations
DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
ALTER TABLE public.pending_operations
ADD CONSTRAINT pending_operations_operation_type_check
CHECK (operation_type IN (
'categorize_transaction',
'create_customer',
'create_invoice',
'mark_invoice_paid',
'send_invoice',
'mark_invoice_sent',
'match_transaction_invoice',
'close_period',
'lock_period',
'unlock_period',
'set_opening_balances',
'run_year_end',
'run_currency_revaluation',
'import_sie',
'explain_voucher_gap',
'uncategorize_transaction',
'approve_supplier_invoice',
'credit_supplier_invoice',
'credit_invoice',
'convert_invoice',
'create_transaction',
'attach_document_to_transaction',
'create_voucher',
'correct_entry',
'reverse_entry',
'create_supplier',
'create_supplier_invoice_from_inbox',
'post_annual_depreciation',
'link_invoice_voucher',
'undo_sie_import',
'match_batch_allocate',
'bulk_book_transactions',
'create_salary_run',
'generate_agi',
'link_transaction_journal_entry',
'link_supplier_invoice_voucher',
'submit_vat_declaration',
'submit_agi',
'create_article',
'update_article',
'bulk_book_inbox_items' -- N matched Underlag → N verifikat (one shared category)
));
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,138 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import {
insertAuthUser,
insertCompany,
insertCompanyMember,
insertFiscalPeriod,
} from '@/tests/pg/fixtures'
import { getPool, withUserContext } from '@/tests/pg/setup'
/**
* Covers replace_period_opening_balance_link (20260528120200), the RPC the
* opening-balance correction flow (app/api/import/opening-balance/correct) uses
* to repoint fiscal_periods.opening_balance_entry_id from the stornoed IB to the
* corrected one.
*
* The critical property: enforce_opening_balance_immutability blocks any direct
* UPDATE that changes opening_balance_entry_id while opening_balances_set is
* true. The RPC's two-step (flip the flag off, change the FK, flip it on) must
* therefore be the sanctioned path a plain UPDATE must still be rejected.
*/
async function insertPostedOpeningBalance(params: {
userId: string
companyId: string
fiscalPeriodId: string
voucherNumber: number
lines?: Array<{ account: string; debit: number; credit: number }>
}): Promise<string> {
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, status)
VALUES ($1, $2, $3, $4, $5, 'A', '2026-01-01', 'Ingående balanser', 'opening_balance', 'draft')`,
[id, params.userId, params.companyId, params.fiscalPeriodId, params.voucherNumber],
)
const lines = params.lines ?? [
{ account: '1930', debit: 5000, credit: 0 },
{ account: '2099', debit: 0, credit: 5000 },
]
for (const l of lines) {
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, $2, $3, $4)`,
[id, l.account, l.debit, l.credit],
)
}
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [id])
return id
}
async function linkOpeningBalance(companyId: string, periodId: string, entryId: string) {
// First-time link: OLD.opening_balances_set is false, so the immutability
// trigger permits setting the FK + flag together.
await getPool().query(
`UPDATE public.fiscal_periods
SET opening_balance_entry_id = $3, opening_balances_set = true
WHERE id = $2 AND company_id = $1`,
[companyId, periodId, entryId],
)
}
async function seed() {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
return { userId, companyId, fiscalPeriodId }
}
describe('replace_period_opening_balance_link RPC', () => {
it('repoints the period to a new posted IB entry while opening_balances_set is true', async () => {
const { userId, companyId, fiscalPeriodId } = await seed()
const oldEntry = await insertPostedOpeningBalance({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
await linkOpeningBalance(companyId, fiscalPeriodId, oldEntry)
const newEntry = await insertPostedOpeningBalance({ userId, companyId, fiscalPeriodId, voucherNumber: 2 })
await withUserContext(userId, async (client) => {
await client.query(`SELECT replace_period_opening_balance_link($1, $2, $3)`, [
companyId,
fiscalPeriodId,
newEntry,
])
const after = await client.query<{ opening_balance_entry_id: string; opening_balances_set: boolean }>(
`SELECT opening_balance_entry_id, opening_balances_set
FROM public.fiscal_periods WHERE id = $1`,
[fiscalPeriodId],
)
expect(after.rows[0]!.opening_balance_entry_id).toBe(newEntry)
expect(after.rows[0]!.opening_balances_set).toBe(true)
})
})
it('still blocks a plain UPDATE of the link while set=true (trigger intact)', async () => {
const { userId, companyId, fiscalPeriodId } = await seed()
const oldEntry = await insertPostedOpeningBalance({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
await linkOpeningBalance(companyId, fiscalPeriodId, oldEntry)
const newEntry = await insertPostedOpeningBalance({ userId, companyId, fiscalPeriodId, voucherNumber: 2 })
await withUserContext(userId, async (client) => {
await expect(
client.query(
`UPDATE public.fiscal_periods SET opening_balance_entry_id = $2 WHERE id = $1`,
[fiscalPeriodId, newEntry],
),
).rejects.toThrow()
})
})
it('rejects a non-posted replacement entry', async () => {
const { userId, companyId, fiscalPeriodId } = await seed()
const oldEntry = await insertPostedOpeningBalance({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
await linkOpeningBalance(companyId, fiscalPeriodId, oldEntry)
// Draft (non-posted) candidate entry.
const draftId = 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, status)
VALUES ($1, $2, $3, $4, 3, 'A', '2026-01-01', 'Draft IB', 'opening_balance', 'draft')`,
[draftId, userId, companyId, fiscalPeriodId],
)
await withUserContext(userId, async (client) => {
await expect(
client.query(`SELECT replace_period_opening_balance_link($1, $2, $3)`, [
companyId,
fiscalPeriodId,
draftId,
]),
).rejects.toThrow()
})
})
})
+3
View File
@@ -1778,6 +1778,9 @@ export type PendingOperationType =
| 'match_batch_allocate'
// PR #606/#610: bulk-book N bank txs into 1 combined verifikat
| 'bulk_book_transactions'
// Bulk-book N selected Underlag (Dokumentinkorgen) against their matched bank
// transactions — one verifikat per item, sharing a category + VAT treatment
| 'bulk_book_inbox_items'
// PR #614: link a single bank tx to an already-posted verifikat (no new JE)
| 'link_transaction_journal_entry'
// PR5: Skatteverket filing via MCP. Commit = "send for BankID signing"