* 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>
210 lines
7.4 KiB
TypeScript
210 lines
7.4 KiB
TypeScript
'use client'
|
||
|
||
import { useTranslations } from 'next-intl'
|
||
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 {
|
||
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'
|
||
import { suggestSeedDate } from '@/lib/bookkeeping/suggest-fiscal-period'
|
||
|
||
/** Status of a fiscal period, in legal precedence: closed > locked > open. */
|
||
function periodStatus(p: FiscalPeriod): 'closed' | 'locked' | 'open' {
|
||
if (p.is_closed) return 'closed'
|
||
if (p.locked_at) return 'locked'
|
||
return 'open'
|
||
}
|
||
|
||
const STATUS_VARIANT: Record<'closed' | 'locked' | 'open', 'secondary' | 'warning' | 'success'> = {
|
||
closed: 'secondary',
|
||
locked: 'warning',
|
||
open: 'success',
|
||
}
|
||
|
||
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 {
|
||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||
if (!res.ok) throw new Error('fetch failed')
|
||
const { data } = await res.json()
|
||
setPeriods((data as FiscalPeriod[]) || [])
|
||
setHasError(false)
|
||
} catch {
|
||
setHasError(true)
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => { fetchPeriods() }, [fetchPeriods])
|
||
|
||
// 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">
|
||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||
{t('fy_heading')}
|
||
</h2>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setDialogOpen(true)}
|
||
disabled={isLoading}
|
||
>
|
||
<Plus className="mr-1.5 h-4 w-4" />
|
||
{t('fy_create')}
|
||
</Button>
|
||
</div>
|
||
|
||
<p className="text-xs text-muted-foreground">{t('fy_help')}</p>
|
||
|
||
{isLoading ? (
|
||
<div className="space-y-2">
|
||
<Skeleton className="h-4 w-48" />
|
||
<Skeleton className="h-4 w-40" />
|
||
</div>
|
||
) : hasError ? (
|
||
<p className="text-sm text-muted-foreground">{t('fy_load_error')}</p>
|
||
) : sorted.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">{t('fy_empty')}</p>
|
||
) : (
|
||
<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">
|
||
<span className="text-sm font-medium">{p.name}</span>
|
||
<span className="ml-2 text-sm text-muted-foreground tabular-nums">
|
||
{formatDate(p.period_start)} – {formatDate(p.period_end)}
|
||
</span>
|
||
</div>
|
||
<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>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<CreatePeriodDialog
|
||
open={dialogOpen}
|
||
onOpenChange={setDialogOpen}
|
||
entryDate={suggestSeedDate(periods, new Date().toISOString().split('T')[0])}
|
||
periods={periods}
|
||
onCreated={fetchPeriods}
|
||
/>
|
||
|
||
<DestructiveConfirmDialog {...dialogProps} />
|
||
</section>
|
||
)
|
||
}
|