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:
@@ -107,3 +107,4 @@ scripts/*.csv
|
||||
scripts/reopen-bokslut.sql
|
||||
|
||||
.claude/plans/write-up-a-plan-streamed-fiddle.md
|
||||
/ingaende-balanser-test.csv
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
+87
-1
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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':
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 momsdeklaration'
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 momsdeklaration'
|
||||
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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 på 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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 på konto 2099 (Årets resultat), vilket
|
||||
nästan alltid blir fel. Vi rekommenderar att du rättar exporten i källsystemet
|
||||
och laddar upp filen på 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 på 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'}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (~180–220 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 3–8), 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}`,
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* underlag→verifikat 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user