Fix/cus fee 28 (#820)
* feat(invoices): add Plusgiro input to bank details settings Plusgiro was already persisted, validated by the API schema, rendered on the invoice PDF and toggleable via "Visa plusgiro" — but the settings UI had no field to enter the number, so plusgiro-only users could not fill it in. Add the input next to Bankgiro with Luhn validation and hyphen formatting, include it in the save payload (normalised on save so raw digits still match the dashed schema format), and add sv/en strings. Adds validatePlusgiroNumber/formatPlusgiroNumber helpers + tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(invoices): respect non-VAT-registered seller in PDF preview + portal tooltips Two user-reported bugs: - PDF preview (/api/invoices/preview-pdf) ignored company.vat_registered and fell back to the customer-driven 25% rate, so a non-momsregistrerad seller saw VAT in the review step even though the created invoice books none. Mirror the server-side write gate (build-invoice-write.ts): force 0% when vat_registered is false (delivery notes excepted). - InfoTooltip rendered TooltipContent without a Portal, so tooltips were clipped by the scrollable DialogContent (overflow-y-auto) in the send-invoice journal-entry review. Wrap in TooltipPrimitive.Portal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(transactions): book library mall from its literal lines, not a lossy fallback Booking a bank transaction with a user-created booking-template (mall) via the convertible "QuickReview" fast path reduced the template to a single category + one account_override, silently discarding the chosen debit/credit. A kundinbetalning mall (D 1930 / K 1510) booked as a generic cost (D 6991 / K 1930), or with a VAT line as D 1930 / K 1930 / K 2611 — and the result flipped with the direction inferred from the business/settlement line tags, so visually-identical templates produced different verifikationer. Route every library template through the journal-entry editor (applyTemplate -> /book), which posts the literal lines, regardless of convertibility. Add regression tests locking the contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): make the booking-time duplicate guard bypassable TRANSACTION_BOOK_POSSIBLE_DUPLICATE told users they could "book anyway" but the UI dead-ended on a toast with no way to do so. Add a shared DuplicateBookingDialog that surfaces the already-booked sibling and lets the user review it or book anyway (force bound to the reviewed candidate, which the server re-detects so a stale id cannot wave the guard away). - Wire the dialog into the /transactions categorize flow and the manual booking dialog (JournalEntryForm -> /api/transactions/[id]/book) - Bind the override to expected_duplicate_transaction_id OR expected_duplicate_journal_entry_id so ledger-only vouchers (paid invoice, salary run) can be confirmed too - Extend the guard to the pending-operations commit path and the MCP server - Tests for book/categorize routes, detection, and the commit guard Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): log duplicate-guard bypass to behandlingshistorik in the agent commit path The web /book and /categorize routes append a durable BankTransactionDuplicateDismissed event when a user books over a detected possible double-booking. The agent commit path (commitCategorizeTransaction, commitMarkInvoicePaid) skipped the guard silently on allow_duplicate=true, leaving no behandlingshistorik — an auditor could not reconstruct why the duplicate was allowed (BFNAR 2013:2 kap 8). When allow_duplicate=true, re-detect the candidate and append the dismissal event (BankTransactionDuplicateDismissed for the bank-line path, InvoiceDuplicatePaymentDismissed for mark-paid). Best-effort — a logging failure never blocks a legitimate booking. Payloads stay PII-safe (ids, amounts, dates only — no customer or merchant name). Also fix the misleading DuplicateBookingDialog JSDoc: the retry binds expected_duplicate_journal_entry_id, not candidate.transaction_id, so the systemdokumentation matches the actual control (BFL 7 kap). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mcp-server): stub booking-duplicate guard in receipt-matcher categorize tests The gnubok_categorize_transaction tool runs the booking-time duplicate guard before staging; its detection queries consumed the queued supabase mock results, so the staging assertions saw a thrown duplicate error instead of a staged op. Mock detectBookingDuplicate to "no duplicate" since these tests don't exercise that path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(transactions): use roundOre for duplicate-guard öre rounding Replace naive Math.round(x*100)/100 with roundOre() from @/lib/money in the booking-time duplicate guard (detection lib, commit executor, MCP categorize tool), satisfying the no-new-antipatterns ratchet guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sie-export): paginate journal entries and lines to prevent truncation * fix(bookkeeping): keep the Verifikat/Utkast toggle reachable on an empty list The journal entry list early-returned a pristine empty card whenever the visible list was empty and no filter was active, returning before the Verifikat/Utkast toggle rendered. This stranded users with only drafts (no posted entries) and users who emptied the drafts list, who then had to use the main menu to get back to posted entries. Narrow the early return to a genuinely empty ledger (committed view, no drafts, no filters); make the in-list empty placeholder context-aware (no drafts / no filter matches / no posted entries yet); resolve the draft count before clearing loading on an empty committed list to avoid a toggle flicker. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enable-banking): persist psu_type and reuse it on reconnect Reconnecting a bank connection re-derived psu_type from the company entity_type every time (aktiebolag -> 'business'), silently overriding the type the user actually authorized with. A connection that only signs as 'personal' — common for AB owners who use a personal Mobile BankID, notably at Handelsbanken — flipped back to 'business' on every consent renewal and failed at the bank's signing step. - Add nullable bank_connections.psu_type column (idempotent migration) - Persist psu_type on connect; on reconnect reuse the stored value (explicit client override still wins) - Let users switch account type (Företag/Privat) from the reconnect button - Tests for persistence, reuse, and override Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(import): set maxDuration=300 on bank-file execute to prevent timeout A full-year bank file (300+ rows) runs a sequential per-row ingest that takes ~85s of server time. The execute route set no maxDuration, so it inherited the platform default and was killed mid-run — the import "spins then aborts" for the user. Match the SIE import route and give it a 5-minute budget. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(transactions): add assistant entry point on transaction rows The agent ("Lena") could only be reached from Dokumentinkorgen, and only once an underlag was matched to a transaction. Transaktioner is the most common starting point for booking, so users could not start a booking with the assistant from there at all. Add a per-row "Fråga [namn]" button on unbooked transaction rows that opens the existing transaction.categorization intent with the row's transaction_id. The intent already reads any linked underlag, so it works whether or not a receipt is attached. No new logic — only the missing entry point. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(invoices): enable Swish payment QR on invoices Flip SHOW_SWISH_ON_INVOICE on so the Swish row and payment QR render on the invoice PDF, and make the "Visa Swish" settings toggle live (it was hardcoded disabled). The preview-pdf route now builds the QR too, so it shows in forhandsvisning. Position the QR in the top-right of the payment box. No Swish API integration -- the QR is generated offline and prefills the customer Swish app; reconciliation stays via bank matching. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): scope verifikat list to current year, add storno action, clarify correction preview Three UI fixes from user feedback; no engine logic changed. - List defaults to the current räkenskapsår instead of all years. Voucher numbers run per fiscal year (one A42/year), so showing every year at once made them look like duplicates. New resolveCurrentPeriodId helper. - Add 'Återför (storno)' action on the entry detail page and list row, wiring the existing reverseEntry — a pure reversal (BFL 5 kap 5§) with no replacement, distinct from 'Rätta'. - Correction 'Effekt per konto' preview now labels a removed account 'tas bort' (vs a bare dash) and warns when the proposal is unbalanced; dialog explains the rows are the full new verifikat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bank_connections): add psu_type column to persist chosen authorization type * feat(errors): add CannotReverseStornoError for handling reversal of storno or correction entries --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,12 +8,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Pencil, Check, X, Copy, ChevronDown, CalendarClock, FileText, Link2 } from 'lucide-react'
|
||||
import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Pencil, Check, X, Copy, ChevronDown, CalendarClock, FileText, Link2, RotateCcw } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
@@ -45,6 +46,8 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
const [showEdit, setShowEdit] = useState(false)
|
||||
const [showRecordate, setShowRecordate] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [showReverseConfirm, setShowReverseConfirm] = useState(false)
|
||||
const [isReversing, setIsReversing] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isCommitting, setIsCommitting] = useState(false)
|
||||
const [isLastInSeries, setIsLastInSeries] = useState(false)
|
||||
@@ -155,6 +158,33 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
}
|
||||
}, [id, router, toast, t])
|
||||
|
||||
// Pure reversal (storno) — cancels the verifikat with a stornoverifikation and
|
||||
// no replacement, per BFL 5 kap 5§. Distinct from "Rätta", which always books
|
||||
// a replacement entry. Routes through the engine's reverseEntry (storno +
|
||||
// reverses_id link; original → 'reversed', never deleted).
|
||||
const handleReverse = useCallback(async () => {
|
||||
setIsReversing(true)
|
||||
try {
|
||||
const res = await fetch(`/api/bookkeeping/journal-entries/${id}/reverse`, { method: 'POST' })
|
||||
const result = await res.json()
|
||||
if (res.ok) {
|
||||
const storno = result.data
|
||||
toast({
|
||||
title: t('toast_reverse_done_title'),
|
||||
description: t('toast_reverse_done_description', { voucher: formatVoucher(storno ?? {}) }),
|
||||
})
|
||||
setShowReverseConfirm(false)
|
||||
await fetchData()
|
||||
} else {
|
||||
toast({ title: t('toast_reverse_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' })
|
||||
}
|
||||
} catch {
|
||||
toast({ title: t('toast_reverse_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setIsReversing(false)
|
||||
}
|
||||
}, [id, toast, fetchData, t])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
@@ -297,6 +327,11 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
<CalendarClock className="mr-2 h-4 w-4" />
|
||||
{t('correct_date')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setShowReverseConfirm(true)}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t('reverse_action')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
@@ -712,6 +747,25 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmationDialog>
|
||||
|
||||
{/* Reverse (storno) confirmation dialog */}
|
||||
<ConfirmationDialog
|
||||
open={showReverseConfirm}
|
||||
onOpenChange={setShowReverseConfirm}
|
||||
onConfirm={handleReverse}
|
||||
isSubmitting={isReversing}
|
||||
title={t('reverse_confirm_title')}
|
||||
warningText={t('reverse_warning')}
|
||||
confirmLabel={t('reverse_confirm_label')}
|
||||
>
|
||||
<div className="flex items-start gap-3 rounded-lg border bg-muted/50 p-4">
|
||||
<RotateCcw className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium mb-1">{t('reverse_dialog_heading', { voucher: formatVoucher(entry) })}</p>
|
||||
<p className="text-muted-foreground">{t('reverse_dialog_body')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmationDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ import type { Transaction } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
// Bank-file imports run a sequential, per-row ingest (insert + invoice/supplier
|
||||
// matching + FX lookup). A full-year file (300+ rows) takes ~85s of server time,
|
||||
// which sits right on the platform's default function limit and gets killed
|
||||
// mid-run — the import "spins then aborts" for the user. Give it the same 5-minute
|
||||
// budget the SIE import route uses (app/api/import/sie/execute/route.ts).
|
||||
export const maxDuration = 300
|
||||
|
||||
interface ExecuteRequest {
|
||||
transactions: ParsedBankTransaction[]
|
||||
format: BankFileFormatId
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { getVatRules } from '@/lib/invoices/vat-rules'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
||||
@@ -180,6 +180,7 @@ export async function POST(request: Request) {
|
||||
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
|
||||
company as CompanySettings,
|
||||
)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, previewInvoice)
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: previewInvoice,
|
||||
@@ -188,6 +189,7 @@ export async function POST(request: Request) {
|
||||
company: renderCompany,
|
||||
isPreview: true,
|
||||
branding,
|
||||
swishQrDataUrl,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -22,8 +22,10 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
//
|
||||
// Page-specific triggers (e.g. "Granska med assistent" on a supplier invoice)
|
||||
// still call useAgentSheet() directly from their own buttons because they
|
||||
// know exactly which entity to pass. (Per-transaction help is reached from
|
||||
// Dokumentinkorgen, not a transactions-page row button.)
|
||||
// know exactly which entity to pass. (Per-transaction help has its own
|
||||
// row-level "Fråga [namn]" button in TransactionInboxCard — and the matching
|
||||
// "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 pathname = usePathname()
|
||||
|
||||
@@ -178,7 +178,14 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
|
||||
|
||||
{/* Corrected lines (editable) */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Rättade rader</p>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Rättade rader</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Det här är hela den nya verifikationen — alla konton som ska finnas kvar måste stå
|
||||
kvar. Tar du bort ett konto nollställs det (stornon återför det). Vill du bara återföra
|
||||
hela verifikatet utan att ersätta det, använd Återför (storno) istället.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{lines.map((line, index) => (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AccountNumber } from '@/components/ui/account-number'
|
||||
import {
|
||||
buildCorrectionRows,
|
||||
formatSignedAmount,
|
||||
type AccountRow,
|
||||
type CorrectionLineInput,
|
||||
} from '@/components/bookkeeping/correction-preview-rows'
|
||||
import type { JournalEntryLine } from '@/types'
|
||||
@@ -28,6 +29,18 @@ export default function CorrectionPreview({ originalLines, correctedLines }: Pro
|
||||
return (Number.isFinite(d) && d > 0) || (Number.isFinite(c) && c > 0)
|
||||
})
|
||||
|
||||
// An account that was on the original but the user dropped from the rättelse:
|
||||
// the storno still drains it to zero (delta = −original). Flag it so the cell
|
||||
// reads "tas bort" instead of a bare "–", which would imply "unchanged".
|
||||
const isRemoved = (row: AccountRow) =>
|
||||
hasAnyCorrection && !row.correctionPresent && Math.abs(row.original) >= 0.005
|
||||
|
||||
// The per-account deltas sum to the corrected lines' debit − credit. A non-zero
|
||||
// sum means the proposed rättelse is not yet balanced — surface that here so the
|
||||
// förändring column is read as a work-in-progress, not a miscalculation.
|
||||
const netDelta = rows.reduce((sum, r) => sum + r.delta, 0)
|
||||
const unbalanced = hasAnyCorrection && Math.abs(netDelta) >= 0.005
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
return (
|
||||
@@ -62,8 +75,8 @@ export default function CorrectionPreview({ originalLines, correctedLines }: Pro
|
||||
<td className={`px-3 py-1.5 text-right tabular-nums ${signClass(row.storno)}`}>
|
||||
{formatSignedAmount(row.storno)}
|
||||
</td>
|
||||
<td className={`px-3 py-1.5 text-right tabular-nums ${signClass(row.correction)}`}>
|
||||
{hasAnyCorrection ? formatSignedAmount(row.correction) : '–'}
|
||||
<td className={`px-3 py-1.5 text-right tabular-nums ${isRemoved(row) ? 'text-muted-foreground' : signClass(row.correction)}`}>
|
||||
{!hasAnyCorrection ? '–' : isRemoved(row) ? 'tas bort' : formatSignedAmount(row.correction)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-1.5 text-right tabular-nums border-l font-medium ${signClass(row.delta)}`}
|
||||
@@ -96,8 +109,8 @@ export default function CorrectionPreview({ originalLines, correctedLines }: Pro
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Rättelse</dt>
|
||||
<dd className={`tabular-nums ${signClass(row.correction)}`}>
|
||||
{hasAnyCorrection ? formatSignedAmount(row.correction) : '–'}
|
||||
<dd className={`tabular-nums ${isRemoved(row) ? 'text-muted-foreground' : signClass(row.correction)}`}>
|
||||
{!hasAnyCorrection ? '–' : isRemoved(row) ? 'tas bort' : formatSignedAmount(row.correction)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -107,8 +120,13 @@ export default function CorrectionPreview({ originalLines, correctedLines }: Pro
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Förändring = storno + rättelse. Det är det netto som tillkommer ovanpå originalet när du
|
||||
bokför.
|
||||
bokför. Ett konto du tar bort nollställs av stornon.
|
||||
</p>
|
||||
{unbalanced && (
|
||||
<p className="text-xs text-destructive">
|
||||
Förslaget balanserar inte ännu – debet och kredit i rättelsen måste vara lika.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,15 +26,17 @@ import {
|
||||
STORAGE_KEY_PREFIX as FISCAL_YEAR_STORAGE_KEY_PREFIX,
|
||||
ALL_YEARS_VALUE as FISCAL_YEAR_ALL_VALUE,
|
||||
} from '@/components/common/FiscalYearSelector'
|
||||
import { ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Paperclip, AlertTriangle, CircleSlash, Loader2, BookOpen, X, Copy, Lock, Search, SlidersHorizontal } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Paperclip, AlertTriangle, CircleSlash, Loader2, BookOpen, X, Copy, Lock, Search, SlidersHorizontal, RotateCcw } from 'lucide-react'
|
||||
import { formatDate, formatCurrency } from '@/lib/utils'
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { resolveCurrentPeriodId } from '@/lib/bookkeeping/suggest-fiscal-period'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
|
||||
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
|
||||
import NoDocRequiredToggle from '@/components/bookkeeping/NoDocRequiredToggle'
|
||||
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
|
||||
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
|
||||
import AttachmentPreviewSheet from '@/components/bookkeeping/AttachmentPreviewSheet'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -92,6 +94,8 @@ export default function JournalEntryList() {
|
||||
const [bulkReason, setBulkReason] = useState('')
|
||||
const [bulkSubmitting, setBulkSubmitting] = useState(false)
|
||||
const [correctionEntry, setCorrectionEntry] = useState<JournalEntry | null>(null)
|
||||
const [reverseEntryTarget, setReverseEntryTarget] = useState<JournalEntry | null>(null)
|
||||
const [isReversing, setIsReversing] = useState(false)
|
||||
const [previewEntryId, setPreviewEntryId] = useState<string | null>(null)
|
||||
const [sortBy, setSortBy] = useState<SortBy>('date_desc')
|
||||
const [sortHydrated, setSortHydrated] = useState(false)
|
||||
@@ -214,41 +218,53 @@ export default function JournalEntryList() {
|
||||
setPageSizeHydrated(true)
|
||||
}, [company?.id])
|
||||
|
||||
// Restore the persisted fiscal-year selection (per company), reading the same
|
||||
// localStorage key FiscalYearSelector writes. The selector lives inside the
|
||||
// filter dialog and only mounts when opened, so we resolve the saved scope
|
||||
// here — independent of the dialog — to keep the initial fetch correct.
|
||||
// periodHydrated gates the first fetch so the list loads already scoped.
|
||||
useEffect(() => {
|
||||
if (company?.id && typeof window !== 'undefined') {
|
||||
const stored = window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id)
|
||||
setPeriodId(stored && stored !== FISCAL_YEAR_ALL_VALUE ? stored : null)
|
||||
} else {
|
||||
setPeriodId(null)
|
||||
}
|
||||
setPeriodHydrated(true)
|
||||
}, [company?.id])
|
||||
|
||||
// Fetch fiscal periods so the active räkenskapsår can be labelled on the
|
||||
// filter bar without opening the dialog (BFL period-orientation: the user
|
||||
// should always see which year the ledger is scoped to). Read-only — the
|
||||
// dialog's FiscalYearSelector still owns selection; this copy resolves the
|
||||
// name for display.
|
||||
// Fetch fiscal periods AND resolve the initial fiscal-year scope in one pass.
|
||||
// The list is period-oriented (BFL): verifikationsnummer run as an unbroken
|
||||
// series *per räkenskapsår*, so the same number (e.g. A42) recurs once per
|
||||
// year. Showing every year at once makes those look like duplicates and makes
|
||||
// a bare "A42" reference ambiguous — so we default to the räkenskapsår the
|
||||
// user is currently in rather than "all years". An explicit "Alla
|
||||
// räkenskapsår" choice (persisted as ALL_YEARS_VALUE) is still honoured.
|
||||
// Resolving the scope here — not in the dialog's FiscalYearSelector, which
|
||||
// only mounts when opened — keeps the first fetch correct. periodHydrated
|
||||
// gates that first fetch so the list loads already scoped to the resolved year.
|
||||
useEffect(() => {
|
||||
if (!company?.id) {
|
||||
setPeriods([])
|
||||
setPeriodId(null)
|
||||
setPeriodHydrated(true)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
let fetched: FiscalPeriod[] = []
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
if (!res.ok) return
|
||||
const { data } = await res.json()
|
||||
if (!cancelled) setPeriods((data || []) as FiscalPeriod[])
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
fetched = (data || []) as FiscalPeriod[]
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — the chip falls back to the active-filter count badge.
|
||||
// Non-critical — fall through with an empty list (scope stays "all years").
|
||||
}
|
||||
if (cancelled) return
|
||||
setPeriods(fetched)
|
||||
|
||||
const stored =
|
||||
typeof window !== 'undefined'
|
||||
? window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id)
|
||||
: null
|
||||
if (stored === FISCAL_YEAR_ALL_VALUE) {
|
||||
// User explicitly chose "all years" — respect it.
|
||||
setPeriodId(null)
|
||||
} else if (stored && fetched.some((p) => p.id === stored)) {
|
||||
setPeriodId(stored)
|
||||
} else {
|
||||
// No (valid) saved scope → default to the current räkenskapsår.
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
setPeriodId(resolveCurrentPeriodId(fetched, today))
|
||||
}
|
||||
setPeriodHydrated(true)
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
@@ -299,13 +315,21 @@ export default function JournalEntryList() {
|
||||
const loadedEntries = data || []
|
||||
setEntries(loadedEntries)
|
||||
setCount(total || 0)
|
||||
|
||||
// The pristine empty card vs. the (toggle-bearing) "drafts exist" state hinges
|
||||
// on draftCount. When the committed list comes back empty, resolve the draft
|
||||
// count BEFORE clearing loading so the toggle doesn't flash out for a frame on
|
||||
// a stale count of 0. Every other case refreshes the badge in the background.
|
||||
if (loadedEntries.length === 0 && listMode === 'committed') {
|
||||
await fetchDraftCount()
|
||||
} else {
|
||||
fetchDraftCount()
|
||||
}
|
||||
setLoading(false)
|
||||
|
||||
// Fetch attachment counts for the loaded entries
|
||||
const ids = loadedEntries.map((e: JournalEntry) => e.id)
|
||||
fetchAttachmentCounts(ids)
|
||||
|
||||
fetchDraftCount()
|
||||
}
|
||||
|
||||
// Cheap count-only query for the "Utkast" badge — all years, so the badge
|
||||
@@ -364,6 +388,35 @@ export default function JournalEntryList() {
|
||||
}
|
||||
}
|
||||
|
||||
// Pure reversal (storno) of a posted verifikat — books a stornoverifikation
|
||||
// with no replacement, per BFL 5 kap 5§. Routes through the engine's
|
||||
// reverseEntry (storno + reverses_id link; original → 'reversed', never
|
||||
// deleted). "Rätta" stays the path for booking a replacement entry instead.
|
||||
const handleReverse = async () => {
|
||||
const target = reverseEntryTarget
|
||||
if (!target) return
|
||||
setIsReversing(true)
|
||||
try {
|
||||
const res = await fetch(`/api/bookkeeping/journal-entries/${target.id}/reverse`, { method: 'POST' })
|
||||
const result = await res.json()
|
||||
if (res.ok) {
|
||||
const storno = result.data
|
||||
toast({
|
||||
title: t('toast_reverse_done_title'),
|
||||
description: t('toast_reverse_done_description', { voucher: formatVoucher(storno ?? {}) }),
|
||||
})
|
||||
setReverseEntryTarget(null)
|
||||
await fetchEntries()
|
||||
} else {
|
||||
toast({ title: t('toast_reverse_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' })
|
||||
}
|
||||
} catch {
|
||||
toast({ title: t('toast_reverse_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setIsReversing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// A posted, document-requiring entry with no attachment yet and not already
|
||||
// exempt — i.e. the rows that show the warning triangle. Only these can be
|
||||
// batch-marked "Inget underlag krävs".
|
||||
@@ -560,7 +613,12 @@ export default function JournalEntryList() {
|
||||
})
|
||||
}
|
||||
|
||||
if (!loading && entries.length === 0 && !hasActiveFilters) {
|
||||
// Pristine, untouched ledger: nothing posted, no drafts, no filters, and we're
|
||||
// on the committed view. ONLY this genuinely-empty case may short-circuit the
|
||||
// whole component — every other empty state (a draft exists, or we're in the
|
||||
// drafts view) must fall through to the main render below so the
|
||||
// Verifikat/Utkast toggle stays reachable.
|
||||
if (!loading && entries.length === 0 && !hasActiveFilters && listMode === 'committed' && draftCount === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
@@ -897,14 +955,32 @@ export default function JournalEntryList() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
// Empty placeholder, scoped to the situation: an empty drafts view, a
|
||||
// filtered committed view with no matches, or a committed view with no
|
||||
// posted entries yet (but drafts exist — hence we got here, not the
|
||||
// pristine early return above).
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<div className="p-4 rounded-full bg-muted mb-4">
|
||||
<Search className="h-6 w-6 text-muted-foreground" />
|
||||
{listMode === 'drafts' || !hasActiveFilters ? (
|
||||
<BookOpen className="h-6 w-6 text-muted-foreground" />
|
||||
) : (
|
||||
<Search className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-1">{t('no_results_title')}</h3>
|
||||
<h3 className="text-lg font-medium mb-1">
|
||||
{listMode === 'drafts'
|
||||
? t('empty_drafts_title')
|
||||
: hasActiveFilters
|
||||
? t('no_results_title')
|
||||
: t('empty_title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
||||
{t('no_results_description')}
|
||||
{listMode === 'drafts'
|
||||
? t('empty_drafts_description')
|
||||
: hasActiveFilters
|
||||
? t('no_results_description')
|
||||
: t('empty_description')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1276,6 +1352,17 @@ export default function JournalEntryList() {
|
||||
{t('create_correction')}
|
||||
</Button>
|
||||
)}
|
||||
{canWrite && entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setReverseEntryTarget(entry)}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t('reverse_action')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -1365,6 +1452,27 @@ export default function JournalEntryList() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reverse (storno) confirmation dialog */}
|
||||
{reverseEntryTarget && (
|
||||
<ConfirmationDialog
|
||||
open={!!reverseEntryTarget}
|
||||
onOpenChange={(open) => { if (!open && !isReversing) setReverseEntryTarget(null) }}
|
||||
onConfirm={handleReverse}
|
||||
isSubmitting={isReversing}
|
||||
title={t('reverse_confirm_title')}
|
||||
warningText={t('reverse_warning')}
|
||||
confirmLabel={t('reverse_confirm_label')}
|
||||
>
|
||||
<div className="flex items-start gap-3 rounded-lg border bg-muted/50 p-4">
|
||||
<RotateCcw className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium mb-1">{t('reverse_dialog_heading', { voucher: formatVoucher(reverseEntryTarget) })}</p>
|
||||
<p className="text-muted-foreground">{t('reverse_dialog_body')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
|
||||
{/* Attachment preview sheet */}
|
||||
<AttachmentPreviewSheet
|
||||
entryId={previewEntryId}
|
||||
|
||||
@@ -22,8 +22,8 @@ describe('buildCorrectionRows', () => {
|
||||
const rows = buildCorrectionRows(original, corrected)
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ account_number: '1930', original: -1000, storno: 1000, correction: -1200, delta: -200 },
|
||||
{ account_number: '5410', original: 1000, storno: -1000, correction: 1200, delta: 200 },
|
||||
{ account_number: '1930', original: -1000, storno: 1000, correction: -1200, delta: -200, correctionPresent: true },
|
||||
{ account_number: '5410', original: 1000, storno: -1000, correction: 1200, delta: 200, correctionPresent: true },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('buildCorrectionRows', () => {
|
||||
storno: -1000,
|
||||
correction: 0,
|
||||
delta: -1000,
|
||||
correctionPresent: false,
|
||||
})
|
||||
expect(rows.find((r) => r.account_number === '5420')).toEqual({
|
||||
account_number: '5420',
|
||||
@@ -53,6 +54,7 @@ describe('buildCorrectionRows', () => {
|
||||
storno: 0,
|
||||
correction: 1000,
|
||||
delta: 1000,
|
||||
correctionPresent: true,
|
||||
})
|
||||
expect(rows.find((r) => r.account_number === '1930')).toEqual({
|
||||
account_number: '1930',
|
||||
@@ -60,6 +62,7 @@ describe('buildCorrectionRows', () => {
|
||||
storno: 1000,
|
||||
correction: -1000,
|
||||
delta: 0,
|
||||
correctionPresent: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,6 +95,7 @@ describe('buildCorrectionRows', () => {
|
||||
storno: -1000,
|
||||
correction: 1500.5,
|
||||
delta: 500.5,
|
||||
correctionPresent: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -111,6 +115,37 @@ describe('buildCorrectionRows', () => {
|
||||
expect(rows[0].correction).toBe(0)
|
||||
})
|
||||
|
||||
it('flags a removed account: storno drains it, correctionPresent is false', () => {
|
||||
// The reported scenario: a sale-shaped verifikat where the user drops the
|
||||
// revenue line (3001) from the rättelse. The storno still reverses 3001
|
||||
// (delta = −original), and correctionPresent=false lets the UI label it
|
||||
// "tas bort" rather than rendering a bare "–".
|
||||
const original = [
|
||||
makeJournalEntryLine({ account_number: '1510', debit_amount: 0, credit_amount: 1875 }),
|
||||
makeJournalEntryLine({ account_number: '2611', debit_amount: 375, credit_amount: 0 }),
|
||||
makeJournalEntryLine({ account_number: '3001', debit_amount: 1500, credit_amount: 0 }),
|
||||
]
|
||||
const corrected = [
|
||||
{ account_number: '2611', debit_amount: 375, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1875 },
|
||||
]
|
||||
const rows = buildCorrectionRows(original, corrected)
|
||||
|
||||
expect(rows.find((r) => r.account_number === '3001')).toEqual({
|
||||
account_number: '3001',
|
||||
original: 1500,
|
||||
storno: -1500,
|
||||
correction: 0,
|
||||
delta: -1500,
|
||||
correctionPresent: false,
|
||||
})
|
||||
// The kept accounts net to zero and stay flagged as present.
|
||||
expect(rows.find((r) => r.account_number === '1510')?.delta).toBe(0)
|
||||
expect(rows.find((r) => r.account_number === '1510')?.correctionPresent).toBe(true)
|
||||
expect(rows.find((r) => r.account_number === '2611')?.delta).toBe(0)
|
||||
expect(rows.find((r) => r.account_number === '2611')?.correctionPresent).toBe(true)
|
||||
})
|
||||
|
||||
it('rounds to öre to avoid 0.1+0.2 drift', () => {
|
||||
const original = [
|
||||
makeJournalEntryLine({ account_number: '5410', debit_amount: 0.1, credit_amount: 0 }),
|
||||
|
||||
@@ -12,6 +12,14 @@ export interface AccountRow {
|
||||
storno: number
|
||||
correction: number
|
||||
delta: number
|
||||
/**
|
||||
* True when this account appears on at least one (4-digit) corrected line.
|
||||
* Lets the UI tell apart an account the user removed from the rättelse — which
|
||||
* the storno then zeroes (delta = −original) — from one that was never part of
|
||||
* the correction at all. Without this distinction a removed account renders as
|
||||
* a bare "–", reading as "unchanged" when it is in fact being drained.
|
||||
*/
|
||||
correctionPresent: boolean
|
||||
}
|
||||
|
||||
function toNumber(v: string | number | null | undefined): number {
|
||||
@@ -44,7 +52,14 @@ export function buildCorrectionRows(
|
||||
const ensure = (acc: string): AccountRow => {
|
||||
let row = map.get(acc)
|
||||
if (!row) {
|
||||
row = { account_number: acc, original: 0, storno: 0, correction: 0, delta: 0 }
|
||||
row = {
|
||||
account_number: acc,
|
||||
original: 0,
|
||||
storno: 0,
|
||||
correction: 0,
|
||||
delta: 0,
|
||||
correctionPresent: false,
|
||||
}
|
||||
map.set(acc, row)
|
||||
}
|
||||
return row
|
||||
@@ -63,6 +78,7 @@ export function buildCorrectionRows(
|
||||
const net = toNumber(line.debit_amount) - toNumber(line.credit_amount)
|
||||
const row = ensure(line.account_number)
|
||||
row.correction += net
|
||||
row.correctionPresent = true
|
||||
}
|
||||
|
||||
for (const row of map.values()) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslations } from 'next-intl'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import type { CompanySettings } from '@/types'
|
||||
@@ -113,17 +112,16 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Swish on invoices is "coming soon" — the toggle is disabled until the
|
||||
payment-QR flow ships (gated by SHOW_SWISH_ON_INVOICE in pdf-template). */}
|
||||
<div className="flex items-center justify-between opacity-60">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>{t('show_swish_label')}</Label>
|
||||
<Badge variant="secondary" className="text-[10px]">{t('coming_soon')}</Badge>
|
||||
</div>
|
||||
<Label>{t('show_swish_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('show_swish_help')}</p>
|
||||
</div>
|
||||
<Switch checked={false} disabled aria-label={t('show_swish_label')} />
|
||||
<Switch
|
||||
checked={settings.invoice_show_swish ?? false}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_swish', v)}
|
||||
aria-label={t('show_swish_label')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
FileText,
|
||||
Link2,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
MoreHorizontal,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
@@ -46,6 +47,7 @@ import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-exten
|
||||
const HAS_AI_EXTRACTION = ENABLED_EXTENSION_IDS.has('document-extraction')
|
||||
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
|
||||
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
|
||||
|
||||
interface TransactionInboxCardProps {
|
||||
@@ -104,6 +106,11 @@ export default function TransactionInboxCard({
|
||||
// Attaching underlag is a write — hide the affordance from viewers so they
|
||||
// don't dead-end on a 403 (mirrors the gate in TransactionHistoryList).
|
||||
const { canWrite } = useCanWrite()
|
||||
// The transaction-side entry point to the assistant ("Lena"). openAgentSheet
|
||||
// hands this specific bank line to the transaction.categorization intent —
|
||||
// the mirror of "Fråga assistenten" in Dokumentinkorgen, so the user can
|
||||
// start a booking with the agent from the inbox they actually live in.
|
||||
const { openAgentSheet, identity } = useAgentSheet()
|
||||
const isProcessing = processingId === transaction.id
|
||||
const isDisabled = processingId !== null && processingId !== transaction.id
|
||||
const isIncome = transaction.amount > 0
|
||||
@@ -142,6 +149,12 @@ export default function TransactionInboxCard({
|
||||
// Unbooked rows are still actionable (match, split, edit, categorize) — that
|
||||
// includes imported bank rows, which are the whole point of the inbox.
|
||||
const isUnbooked = !transaction.journal_entry_id
|
||||
// "Fråga [namn]" hands the row to the assistant for categorization/booking.
|
||||
// Only on unbooked rows (nothing to categorize once it's a verifikat) and
|
||||
// only after the user has built their agent in /onboarding/agent
|
||||
// (identity.isVerified) — same gate as the FAB / AgentSparkleButton.
|
||||
const assistantName = identity.displayName?.trim() || 'min assistent'
|
||||
const showAskAssistant = isUnbooked && identity.isVerified
|
||||
// ...but only rows the USER created in the app may be deleted. Imported rows
|
||||
// (bank sync / CSV) are ignore-only — mirrors the server guard in
|
||||
// DELETE /api/transactions/[id]. See lib/transactions/origin.ts.
|
||||
@@ -326,13 +339,35 @@ export default function TransactionInboxCard({
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* The Paperclip indicator next to the description
|
||||
(TransactionAttachmentIndicator) is the single click
|
||||
target for opening the underlag. We deliberately don't
|
||||
duplicate that with a second icon in the trailing slot.
|
||||
Per-transaction agent help has moved to Dokumentinkorgen:
|
||||
match the underlag to the transaction and ask from there,
|
||||
where the receipt/invoice is in view. */}
|
||||
{/* "Fråga [namn]" — hand this bank line to the assistant for
|
||||
categorization/booking. The transaction-side entry point to
|
||||
the agent, mirroring "Fråga assistenten" in Dokumentinkorgen.
|
||||
The intent reads any linked underlag automatically, so it
|
||||
works whether or not the row already has a receipt attached.
|
||||
Icon-only ghost so it sits quietly in the row's action
|
||||
group. (The Paperclip indicator next to the description
|
||||
stays the single click target for opening the underlag —
|
||||
we don't duplicate that here.) */}
|
||||
{showAskAssistant && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
openAgentSheet({
|
||||
intentId: 'transaction.categorization',
|
||||
intentArgs: { transaction_id: transaction.id },
|
||||
contextRef: `transaction:${transaction.id}`,
|
||||
})
|
||||
}}
|
||||
aria-label={`Fråga ${assistantName}`}
|
||||
title={`Fråga ${assistantName}`}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* Secondary actions (split, edit, delete) collapse into a ⋯
|
||||
overflow menu so the row stays uncluttered. */}
|
||||
{showOverflowMenu && (
|
||||
|
||||
@@ -260,3 +260,112 @@ describe('POST /connect (enable-banking) — reconnect in place', () => {
|
||||
expect(updateSpy.mock.calls[1][0]).toEqual({ authorization_id: 'auth-123' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /connect (enable-banking) — psu_type persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function stubAuth() {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ url: 'https://bank.example/auth', authorization_id: 'auth-123' }),
|
||||
text: async () => '',
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
it('reuses the stored psu_type on reconnect when the client sends no override', async () => {
|
||||
// A 'personal' connection must NOT silently flip to 'business' on renewal —
|
||||
// that was the Handelsbanken signing-failure trap.
|
||||
stubAuth()
|
||||
const updateSpy = vi.fn()
|
||||
const ctx = makeContext(
|
||||
{
|
||||
id: 'conn-1',
|
||||
company_id: 'company-1',
|
||||
bank_name: 'Handelsbanken',
|
||||
provider: 'handelsbanken-se',
|
||||
session_id: null,
|
||||
status: 'expired',
|
||||
psu_type: 'personal',
|
||||
},
|
||||
updateSpy
|
||||
)
|
||||
|
||||
const req = new Request('http://localhost/api/extensions/ext/enable-banking/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: 'conn-1', aspsp_name: 'Handelsbanken', aspsp_country: 'SE' }),
|
||||
})
|
||||
|
||||
const res = await connectRoute.handler(req, ctx)
|
||||
expect(res.status).toBe(200)
|
||||
// The CSRF-state staging update (first write) carries the reused type.
|
||||
expect(updateSpy.mock.calls[0][0]).toMatchObject({ psu_type: 'personal' })
|
||||
})
|
||||
|
||||
it('lets an explicit psu_type override the stored type (switch account type in place)', async () => {
|
||||
stubAuth()
|
||||
const updateSpy = vi.fn()
|
||||
const ctx = makeContext(
|
||||
{
|
||||
id: 'conn-1',
|
||||
company_id: 'company-1',
|
||||
bank_name: 'Handelsbanken',
|
||||
provider: 'handelsbanken-se',
|
||||
session_id: null,
|
||||
status: 'expired',
|
||||
psu_type: 'business',
|
||||
},
|
||||
updateSpy
|
||||
)
|
||||
|
||||
const req = new Request('http://localhost/api/extensions/ext/enable-banking/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
connection_id: 'conn-1',
|
||||
aspsp_name: 'Handelsbanken',
|
||||
aspsp_country: 'SE',
|
||||
psu_type: 'personal',
|
||||
}),
|
||||
})
|
||||
|
||||
const res = await connectRoute.handler(req, ctx)
|
||||
expect(res.status).toBe(200)
|
||||
expect(updateSpy.mock.calls[0][0]).toMatchObject({ psu_type: 'personal' })
|
||||
})
|
||||
|
||||
it('persists psu_type on a fresh connect (derived from entity_type)', async () => {
|
||||
stubAuth()
|
||||
const insertSpy = vi.fn()
|
||||
// Fresh connect: the shared single() resolver returns this object for BOTH
|
||||
// the companies entity_type lookup and the post-insert row read, so giving it
|
||||
// entity_type drives the derivation and id provides the returned row.
|
||||
const ctx = makeContext(
|
||||
{ id: 'conn-new', entity_type: 'enskild_firma' },
|
||||
vi.fn(),
|
||||
insertSpy
|
||||
)
|
||||
|
||||
const req = new Request('http://localhost/api/extensions/ext/enable-banking/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ aspsp_name: 'Handelsbanken', aspsp_country: 'SE' }),
|
||||
})
|
||||
|
||||
const res = await connectRoute.handler(req, ctx)
|
||||
expect(res.status).toBe(200)
|
||||
expect(insertSpy).toHaveBeenCalledTimes(1)
|
||||
expect(insertSpy.mock.calls[0][0]).toMatchObject({ psu_type: 'personal' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client'
|
||||
import Link from 'next/link'
|
||||
@@ -14,6 +21,7 @@ import {
|
||||
Trash2,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
XCircle,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
@@ -23,7 +31,7 @@ interface BankConnectionStatusProps {
|
||||
connection: BankConnection
|
||||
onSync: (connectionId: string) => void
|
||||
onDisconnect: (connectionId: string) => void
|
||||
onReconnect?: (connection: BankConnection) => void
|
||||
onReconnect?: (connection: BankConnection, psuType?: 'personal' | 'business') => void
|
||||
onManageAccounts?: (connectionId: string) => void
|
||||
isSyncing?: boolean
|
||||
}
|
||||
@@ -133,13 +141,30 @@ export function BankConnectionStatus({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(isConnectionExpired || isConnectionError) && onReconnect && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onReconnect(connection)}
|
||||
>
|
||||
Förnya anslutning
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
Förnya anslutning
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* Let the user pick the account type for the bank login. The
|
||||
server reuses the last-used type by default, but some banks
|
||||
(notably Handelsbanken) only sign with one of them — e.g. an
|
||||
AB owner who signs with a personal Mobile BankID needs
|
||||
"Privatkonto", not the company default "Företagskonto". */}
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
Logga in på banken som
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem onSelect={() => onReconnect(connection, 'business')}>
|
||||
Företagskonto
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onReconnect(connection, 'personal')}>
|
||||
Privatkonto
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{isConnectionError && (
|
||||
<Button
|
||||
|
||||
@@ -170,7 +170,7 @@ export default function BankingSettingsPanel() {
|
||||
// same row (revoking the dead session, issuing fresh authorization), then
|
||||
// hands off to the bank's consent screen. The OAuth callback drives the row
|
||||
// back through account selection to active.
|
||||
async function handleReconnect(connection: BankConnection) {
|
||||
async function handleReconnect(connection: BankConnection, psuTypeOverride?: 'personal' | 'business') {
|
||||
if (connectingRef.current) return
|
||||
connectingRef.current = true
|
||||
setIsConnecting(true)
|
||||
@@ -185,6 +185,9 @@ export default function BankingSettingsPanel() {
|
||||
connection_id: connection.id,
|
||||
aspsp_name: connection.bank_name,
|
||||
aspsp_country: country,
|
||||
// Omitted → server reuses the connection's stored psu_type (falling
|
||||
// back to entity_type). Set → switch account type in place.
|
||||
...(psuTypeOverride ? { psu_type: psuTypeOverride } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -121,12 +121,12 @@ export const enableBankingExtension: Extension = {
|
||||
// For reconnect, load the existing connection up front (company-scoped)
|
||||
// so we can revoke its dead session and reuse its bank identity.
|
||||
let existing:
|
||||
| { id: string; bank_name: string; provider: string; session_id: string | null }
|
||||
| { id: string; bank_name: string; provider: string; session_id: string | null; psu_type: string | null }
|
||||
| null = null
|
||||
if (isReconnect) {
|
||||
const { data, error: findErr } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, bank_name, provider, session_id')
|
||||
.select('id, bank_name, provider, session_id, psu_type')
|
||||
.eq('id', reconnectId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
@@ -155,10 +155,18 @@ export const enableBankingExtension: Extension = {
|
||||
)
|
||||
}
|
||||
|
||||
// Detect PSU type: explicit override > company entity_type > default 'business'
|
||||
// Detect PSU type: explicit override > stored type (reconnect) >
|
||||
// company entity_type > default 'business'. Reusing the stored type on
|
||||
// reconnect is the key fix: re-deriving from entity_type would flip a
|
||||
// working 'personal' connection (e.g. an AB owner who signs with a
|
||||
// personal Mobile BankID) back to 'business' on every consent renewal,
|
||||
// failing at the bank's signing step. The client can still pass an
|
||||
// explicit psu_type to switch account type in place.
|
||||
let psuType: 'personal' | 'business' = 'business'
|
||||
if (explicitPsuType === 'personal' || explicitPsuType === 'business') {
|
||||
psuType = explicitPsuType
|
||||
} else if (isReconnect && (existing?.psu_type === 'personal' || existing?.psu_type === 'business')) {
|
||||
psuType = existing.psu_type
|
||||
} else {
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
@@ -249,6 +257,7 @@ export const enableBankingExtension: Extension = {
|
||||
status: 'expired',
|
||||
session_id: null,
|
||||
error_message: null,
|
||||
psu_type: psuType,
|
||||
})
|
||||
.eq('id', existing.id)
|
||||
.eq('company_id', companyId)
|
||||
@@ -329,6 +338,7 @@ export const enableBankingExtension: Extension = {
|
||||
authorization_id,
|
||||
oauth_state: oauthState,
|
||||
status: 'pending',
|
||||
psu_type: psuType,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { validateBalance, getSwedishLocalDate, createDraftEntry, reverseEntry } from '../engine'
|
||||
import { BookkeepingDatabaseError, AccountsNotInChartError } from '../errors'
|
||||
import { BookkeepingDatabaseError, AccountsNotInChartError, CannotReverseStornoError } from '../errors'
|
||||
import type { CreateJournalEntryLineInput, JournalEntryStatus } from '@/types'
|
||||
|
||||
// Mock Supabase client for createDraftEntry/reverseEntry tests
|
||||
@@ -612,6 +612,55 @@ describe('reverseEntry — entry_date defaults to original entry date', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('reverseEntry — rejects reversing a storno or correction', () => {
|
||||
// BFL 5 kap 5§: a storno-of-a-storno makes the original verifikat's
|
||||
// cancellation chain ambiguous. The UI hides "Återför" for these source
|
||||
// types; the engine is the server-side backstop against a direct API call.
|
||||
function supabaseReturningOriginal(original: Record<string, unknown>) {
|
||||
return {
|
||||
rpc: vi.fn(),
|
||||
from: vi.fn().mockImplementation((table: string) => {
|
||||
if (table === 'journal_entries') {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'update', 'insert']) b[m] = vi.fn().mockReturnValue(b)
|
||||
b.single = vi.fn().mockResolvedValue({ data: original, error: null })
|
||||
return b
|
||||
}
|
||||
return createMockChain()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
for (const sourceType of ['storno', 'correction'] as const) {
|
||||
it(`throws CannotReverseStornoError for source_type '${sourceType}'`, async () => {
|
||||
const original = {
|
||||
id: 'entry-1',
|
||||
company_id: 'company-1',
|
||||
status: 'posted',
|
||||
fiscal_period_id: 'period-1',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 3,
|
||||
entry_date: '2024-11-15',
|
||||
description: 'Makulering: Hyra november',
|
||||
source_type: sourceType,
|
||||
source_id: null,
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 10000, credit_amount: 0 },
|
||||
{ account_number: '5010', debit_amount: 0, credit_amount: 10000 },
|
||||
],
|
||||
}
|
||||
const supabase = supabaseReturningOriginal(original)
|
||||
|
||||
await expect(
|
||||
reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1'),
|
||||
).rejects.toBeInstanceOf(CannotReverseStornoError)
|
||||
|
||||
// No reversal was written — the guard fires before any voucher number is drawn.
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('reverseEntry — bank transaction unlink', () => {
|
||||
// After a reversal the booked bank transaction must return to "Att bokföra"
|
||||
// (journal_entry_id cleared) so the user can book it again. The agent paths
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeSuggestedPeriod, suggestSeedDate } from '../suggest-fiscal-period'
|
||||
import {
|
||||
computeSuggestedPeriod,
|
||||
suggestSeedDate,
|
||||
resolveCurrentPeriodId,
|
||||
} from '../suggest-fiscal-period'
|
||||
|
||||
type Range = { period_start: string; period_end: string }
|
||||
|
||||
@@ -71,3 +75,30 @@ describe('suggestSeedDate', () => {
|
||||
expect(suggestSeedDate([FY2024], today)).toBe('2025-01-01')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCurrentPeriodId', () => {
|
||||
const P2024 = { id: 'p2024', period_start: '2024-01-01', period_end: '2024-12-31' }
|
||||
const P2025 = { id: 'p2025', period_start: '2025-01-01', period_end: '2025-12-31' }
|
||||
const P2026 = { id: 'p2026', period_start: '2026-01-01', period_end: '2026-12-31' }
|
||||
|
||||
it('returns null when there are no periods', () => {
|
||||
expect(resolveCurrentPeriodId([], '2026-06-29')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the period that contains today', () => {
|
||||
expect(resolveCurrentPeriodId([P2024, P2025, P2026], '2026-06-29')).toBe('p2026')
|
||||
})
|
||||
|
||||
it('is order-independent', () => {
|
||||
expect(resolveCurrentPeriodId([P2026, P2024, P2025], '2025-03-01')).toBe('p2025')
|
||||
})
|
||||
|
||||
it('falls back to the most recent started period when today sits in a gap after the last year', () => {
|
||||
// Today is in 2027 but only periods up to 2026 exist (next year not created yet).
|
||||
expect(resolveCurrentPeriodId([P2024, P2025, P2026], '2027-02-15')).toBe('p2026')
|
||||
})
|
||||
|
||||
it('falls back to the earliest period when every period is still upcoming', () => {
|
||||
expect(resolveCurrentPeriodId([P2025, P2026], '2024-06-01')).toBe('p2025')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
BookkeepingDatabaseError,
|
||||
CannotEditNonDraftError,
|
||||
CannotReverseNonPostedError,
|
||||
CannotReverseStornoError,
|
||||
EntryAlreadyReversedError,
|
||||
EntryDateOutsideFiscalPeriodError,
|
||||
FiscalPeriodNotFoundError,
|
||||
@@ -639,6 +640,14 @@ export async function reverseEntry(
|
||||
throw new CannotReverseNonPostedError(original.status)
|
||||
}
|
||||
|
||||
// A storno or correction entry must never itself be reversed: a
|
||||
// storno-of-a-storno makes the original verifikat's cancellation chain
|
||||
// ambiguous (BFL 5 kap 5§). The UI hides "Återför" for these source types;
|
||||
// this is the server-side backstop against a direct API call.
|
||||
if (original.source_type === 'storno' || original.source_type === 'correction') {
|
||||
throw new CannotReverseStornoError(original.source_type)
|
||||
}
|
||||
|
||||
const lines = (original.lines as JournalEntryLine[]) || []
|
||||
|
||||
// Create reversed lines (swap debit and credit, preserve dimensions)
|
||||
|
||||
@@ -10,6 +10,7 @@ export const FISCAL_PERIOD_NOT_FOUND = 'FISCAL_PERIOD_NOT_FOUND' as const
|
||||
export const ENTRY_DATE_OUTSIDE_FISCAL_PERIOD = 'ENTRY_DATE_OUTSIDE_FISCAL_PERIOD' as const
|
||||
export const JOURNAL_ENTRY_NOT_FOUND = 'JOURNAL_ENTRY_NOT_FOUND' as const
|
||||
export const CANNOT_REVERSE_NON_POSTED = 'CANNOT_REVERSE_NON_POSTED' as const
|
||||
export const CANNOT_REVERSE_STORNO = 'CANNOT_REVERSE_STORNO' as const
|
||||
export const CANNOT_CORRECT_NON_POSTED = 'CANNOT_CORRECT_NON_POSTED' as const
|
||||
export const CANNOT_EDIT_NON_DRAFT = 'CANNOT_EDIT_NON_DRAFT' as const
|
||||
export const ENTRY_ALREADY_REVERSED = 'ENTRY_ALREADY_REVERSED' as const
|
||||
@@ -119,6 +120,22 @@ export class CannotReverseNonPostedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raised when a storno (reversal) is attempted on an entry that is itself a
|
||||
* storno or a correction. Reversing such an entry would produce a
|
||||
* storno-of-a-storno and make the original verifikat's cancellation chain
|
||||
* ambiguous, violating the traceable-correction requirement of BFL 5 kap 5§.
|
||||
* The UI hides the "Återför" action for these source types; this is the
|
||||
* server-side backstop so a direct API call cannot bypass it.
|
||||
*/
|
||||
export class CannotReverseStornoError extends Error {
|
||||
readonly code = CANNOT_REVERSE_STORNO
|
||||
constructor(public readonly sourceType: string) {
|
||||
super('Cannot reverse a storno or correction entry')
|
||||
this.name = 'CannotReverseStornoError'
|
||||
}
|
||||
}
|
||||
|
||||
export class CannotCorrectNonPostedError extends Error {
|
||||
readonly code = CANNOT_CORRECT_NON_POSTED
|
||||
constructor(public readonly currentStatus: string) {
|
||||
@@ -283,6 +300,7 @@ export function isBookkeepingError(err: unknown): boolean {
|
||||
err instanceof EntryDateOutsideFiscalPeriodError ||
|
||||
err instanceof JournalEntryNotFoundError ||
|
||||
err instanceof CannotReverseNonPostedError ||
|
||||
err instanceof CannotReverseStornoError ||
|
||||
err instanceof CannotCorrectNonPostedError ||
|
||||
err instanceof CannotEditNonDraftError ||
|
||||
err instanceof EntryAlreadyReversedError ||
|
||||
@@ -399,6 +417,19 @@ export function bookkeepingErrorResponse(err: unknown): NextResponse | null {
|
||||
)
|
||||
}
|
||||
|
||||
if (err instanceof CannotReverseStornoError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
details: { sourceType: err.sourceType },
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (err instanceof CannotCorrectNonPostedError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -14,6 +14,9 @@ import type { FiscalPeriod } from '@/types'
|
||||
/** Minimal shape needed for the date math — `FiscalPeriod` satisfies it. */
|
||||
type PeriodRange = Pick<FiscalPeriod, 'period_start' | 'period_end'>
|
||||
|
||||
/** A period plus its id, for resolving which räkenskapsår a view scopes to. */
|
||||
type IdentifiablePeriod = Pick<FiscalPeriod, 'id' | 'period_start' | 'period_end'>
|
||||
|
||||
export interface SuggestedPeriod {
|
||||
name: string
|
||||
period_start: string
|
||||
@@ -121,3 +124,36 @@ export function suggestSeedDate(periods: PeriodRange[], today: string): string {
|
||||
|
||||
return addDays(sorted[sorted.length - 1].period_end, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which fiscal period a period-scoped view (e.g. the verifikat list)
|
||||
* should default to: the räkenskapsår the user is currently in.
|
||||
*
|
||||
* Verifikationsnummer run as an unbroken series *per räkenskapsår* (BFL 5 kap
|
||||
* 7§), so the same number (e.g. A42) legitimately recurs once per year. Showing
|
||||
* every year at once makes those look like duplicates and makes a bare "A42"
|
||||
* reference ambiguous — a period-oriented view should land on a single year.
|
||||
*
|
||||
* Resolution, given `today` (YYYY-MM-DD):
|
||||
* 1. The period that contains today.
|
||||
* 2. Else the most recent period that has already started (period_start ≤ today)
|
||||
* — covers a gap after the last year before the next one is created.
|
||||
* 3. Else the earliest period (a company whose only/first year is still upcoming).
|
||||
* 4. Else null (no periods at all → caller falls back to "all years").
|
||||
*/
|
||||
export function resolveCurrentPeriodId(
|
||||
periods: IdentifiablePeriod[],
|
||||
today: string,
|
||||
): string | null {
|
||||
if (periods.length === 0) return null
|
||||
|
||||
const containing = periods.find((p) => p.period_start <= today && today <= p.period_end)
|
||||
if (containing) return containing.id
|
||||
|
||||
const started = periods
|
||||
.filter((p) => p.period_start <= today)
|
||||
.sort((a, b) => b.period_start.localeCompare(a.period_start))
|
||||
if (started.length > 0) return started[0].id
|
||||
|
||||
return [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))[0].id
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
BookkeepingDatabaseError,
|
||||
CannotCorrectNonPostedError,
|
||||
CannotReverseNonPostedError,
|
||||
CannotReverseStornoError,
|
||||
EntryAlreadyReversedError,
|
||||
EntryDateOutsideFiscalPeriodError,
|
||||
FiscalPeriodNotFoundError,
|
||||
@@ -348,6 +349,9 @@ function extractBookkeepingDetails(err: unknown): { code: string; details?: unkn
|
||||
if (err instanceof CannotReverseNonPostedError) {
|
||||
return { code: err.code, details: { currentStatus: err.currentStatus } }
|
||||
}
|
||||
if (err instanceof CannotReverseStornoError) {
|
||||
return { code: err.code, details: { sourceType: err.sourceType } }
|
||||
}
|
||||
if (err instanceof CannotCorrectNonPostedError) {
|
||||
return { code: err.code, details: { currentStatus: err.currentStatus } }
|
||||
}
|
||||
|
||||
@@ -180,6 +180,11 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Endast bokförda verifikationer kan stornas.',
|
||||
message_en: 'Only posted entries can be reversed.',
|
||||
},
|
||||
CANNOT_REVERSE_STORNO: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'En stornering eller rättelse kan inte stornas.',
|
||||
message_en: 'A storno or correction entry cannot be reversed.',
|
||||
},
|
||||
CANNOT_CORRECT_NON_POSTED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Endast bokförda verifikationer kan rättas.',
|
||||
|
||||
@@ -159,10 +159,9 @@ const LABELS = {
|
||||
},
|
||||
} as const
|
||||
|
||||
// Swish on invoices (the number row + the payment QR) is "coming soon" — gated
|
||||
// off until the QR flow is finished. Flip to true to re-enable both at once;
|
||||
// the settings "Visa Swish" toggle is disabled while this is false.
|
||||
export const SHOW_SWISH_ON_INVOICE = false
|
||||
// Swish on invoices (the number row + the payment QR). When true, the Swish row
|
||||
// and QR render on the invoice PDF and the settings "Visa Swish" toggle is live.
|
||||
export const SHOW_SWISH_ON_INVOICE = true
|
||||
|
||||
// Labor-only disclaimer for the ROT/RUT block. Kept Swedish-only in both
|
||||
// locales — references Skatteverket's fakturamodell directly, which is a
|
||||
@@ -395,6 +394,7 @@ function createStyles(branding?: InvoiceBranding) {
|
||||
padding: 15,
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: 4,
|
||||
position: 'relative',
|
||||
},
|
||||
paymentTitle: {
|
||||
fontSize: 11,
|
||||
@@ -1086,9 +1086,9 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
</View>
|
||||
)}
|
||||
{swishQrDataUrl && (
|
||||
<View style={{ marginTop: 10, alignItems: 'center' }}>
|
||||
<View style={{ position: 'absolute', top: 15, right: 15, width: 96, alignItems: 'center' }}>
|
||||
<Image src={swishQrDataUrl} style={{ width: 96, height: 96 }} />
|
||||
<Text style={[styles.paymentLabel, { marginTop: 2 }]}>{L.swishQrCaption}</Text>
|
||||
<Text style={[styles.paymentLabel, { width: 'auto', marginTop: 2, textAlign: 'center' }]}>{L.swishQrCaption}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'order', 'range', 'lt', 'lte', 'gte', 'gt', 'limit']) {
|
||||
for (const m of ['select', 'eq', 'in', 'order', 'range', 'lt', 'lte', 'gte', 'gt', 'limit', 'neq']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
@@ -46,6 +46,16 @@ const baseOptions = {
|
||||
program_name: 'ERPBase',
|
||||
}
|
||||
|
||||
// Queue consumption order after the pagination fix:
|
||||
// 0: fiscal_periods.single()
|
||||
// 1: previous fiscal period .single() (#RAR -1)
|
||||
// 2: chart_of_accounts (fetchAllRows)
|
||||
// 3: journal_entries (fetchAllRows)
|
||||
// 4: journal_entry_lines (fetchAllRows) ← split out from the entries query
|
||||
// 5: cost_centers
|
||||
// 6: projects
|
||||
// 7: opening balances (RPC fallback or journal_entry_lines page)
|
||||
|
||||
describe('generateSIEExport', () => {
|
||||
it('throws when fiscal period not found', async () => {
|
||||
results = [
|
||||
@@ -59,20 +69,14 @@ describe('generateSIEExport', () => {
|
||||
|
||||
it('generates correct header format', async () => {
|
||||
results = [
|
||||
// 0: fiscal_periods
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
// 1: previous fiscal period (#RAR -1)
|
||||
{ data: null, error: null },
|
||||
// 2: chart_of_accounts (empty)
|
||||
{ data: [], error: null },
|
||||
// 3: journal_entries (empty)
|
||||
{ data: [], error: null },
|
||||
// 4: cost_centers (empty)
|
||||
{ data: [], error: null },
|
||||
// 5: projects (empty)
|
||||
{ data: [], error: null },
|
||||
// 6: compute_prior_opening_balances RPC (empty — no IB)
|
||||
{ data: [], error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // opening balances RPC
|
||||
]
|
||||
|
||||
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
|
||||
@@ -92,10 +96,11 @@ describe('generateSIEExport', () => {
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -118,9 +123,10 @@ describe('generateSIEExport', () => {
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -139,20 +145,18 @@ describe('generateSIEExport', () => {
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null }, // accounts
|
||||
{
|
||||
// journal_entries (no embedded lines — those come from the next slot)
|
||||
data: [
|
||||
{
|
||||
id: 'e1',
|
||||
entry_date: '2024-03-15',
|
||||
voucher_number: 1,
|
||||
voucher_series: 'A',
|
||||
description: 'Sale invoice',
|
||||
status: 'posted',
|
||||
lines: [
|
||||
{ account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, cost_center: null, project: null },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: 'Revenue', cost_center: null, project: null },
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, cost_center: null, project: null },
|
||||
],
|
||||
},
|
||||
{ id: 'e1', entry_date: '2024-03-15', voucher_number: 1, voucher_series: 'A', description: 'Sale invoice', status: 'posted' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
// journal_entry_lines — each carries journal_entry_id for grouping
|
||||
data: [
|
||||
{ journal_entry_id: 'e1', account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, cost_center: null, project: null },
|
||||
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: 'Revenue', cost_center: null, project: null },
|
||||
{ journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, cost_center: null, project: null },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
@@ -175,8 +179,9 @@ describe('generateSIEExport', () => {
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{
|
||||
data: [
|
||||
{ code: 'CC1', name: 'Avdelning 1', is_active: true },
|
||||
@@ -204,21 +209,17 @@ describe('generateSIEExport', () => {
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null }, // accounts
|
||||
{
|
||||
data: [
|
||||
{
|
||||
id: 'e1',
|
||||
entry_date: '2024-03-15',
|
||||
voucher_number: 1,
|
||||
voucher_series: 'A',
|
||||
description: 'With dimensions',
|
||||
status: 'posted',
|
||||
lines: [
|
||||
{ account_number: '5010', debit_amount: 8000, credit_amount: 0, line_description: null, cost_center: 'CC1', project: 'P001' },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 8000, line_description: null, cost_center: null, project: null },
|
||||
],
|
||||
},
|
||||
{ id: 'e1', entry_date: '2024-03-15', voucher_number: 1, voucher_series: 'A', description: 'With dimensions', status: 'posted' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
data: [
|
||||
{ journal_entry_id: 'e1', account_number: '5010', debit_amount: 8000, credit_amount: 0, line_description: null, cost_center: 'CC1', project: 'P001' },
|
||||
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 8000, line_description: null, cost_center: null, project: null },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
@@ -237,27 +238,23 @@ describe('generateSIEExport', () => {
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null }, // accounts
|
||||
{
|
||||
data: [
|
||||
{
|
||||
id: 'e1',
|
||||
entry_date: '2024-01-15',
|
||||
voucher_number: 1,
|
||||
voucher_series: 'A',
|
||||
description: 'Sale',
|
||||
status: 'posted',
|
||||
lines: [
|
||||
{ account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, cost_center: null, project: null },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: null, cost_center: null, project: null },
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, cost_center: null, project: null },
|
||||
],
|
||||
},
|
||||
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale', status: 'posted' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{
|
||||
data: [
|
||||
{ journal_entry_id: 'e1', account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, cost_center: null, project: null },
|
||||
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: null, cost_center: null, project: null },
|
||||
{ journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, cost_center: null, project: null },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -275,26 +272,22 @@ describe('generateSIEExport', () => {
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null }, // accounts
|
||||
{
|
||||
data: [
|
||||
{
|
||||
id: 'e1',
|
||||
entry_date: '2024-01-15',
|
||||
voucher_number: 1,
|
||||
voucher_series: 'A',
|
||||
description: 'Invoice for "consulting"',
|
||||
status: 'posted',
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 100, credit_amount: 0, line_description: null, cost_center: null, project: null },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, cost_center: null, project: null },
|
||||
],
|
||||
},
|
||||
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Invoice for "consulting"', status: 'posted' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{
|
||||
data: [
|
||||
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 100, credit_amount: 0, line_description: null, cost_center: null, project: null },
|
||||
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, cost_center: null, project: null },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -307,10 +300,11 @@ describe('generateSIEExport', () => {
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -331,10 +325,11 @@ describe('generateSIEExport', () => {
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -348,10 +343,11 @@ describe('generateSIEExport', () => {
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -361,6 +357,68 @@ describe('generateSIEExport', () => {
|
||||
expect(output).not.toContain('#OBJEKT')
|
||||
})
|
||||
|
||||
it('does not truncate large periods — every voucher and its lines are exported', async () => {
|
||||
// Regression test for the user-reported bug: the previous nested
|
||||
// `select('*, lines:journal_entry_lines(*)')` query hit PostgREST's
|
||||
// embedded-resource row ceiling and silently truncated to ~30 vouchers.
|
||||
// The pagination fix fetches entries and lines as separate paginated
|
||||
// queries, so a period far larger than any single page round-trips fully.
|
||||
const ENTRY_COUNT = 2500 // well past the 1000-row PostgREST page size
|
||||
|
||||
const entries = Array.from({ length: ENTRY_COUNT }, (_, i) => ({
|
||||
id: `e${i + 1}`,
|
||||
entry_date: '2024-06-01',
|
||||
voucher_number: i + 1,
|
||||
voucher_series: 'A',
|
||||
description: `Voucher ${i + 1}`,
|
||||
status: 'posted',
|
||||
}))
|
||||
|
||||
const lines = entries.flatMap((e) => [
|
||||
{ journal_entry_id: e.id, account_number: '1510', debit_amount: 100, credit_amount: 0, line_description: null, cost_center: null, project: null },
|
||||
{ journal_entry_id: e.id, account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, cost_center: null, project: null },
|
||||
])
|
||||
|
||||
// fetchAllRows paginates at PAGE_SIZE = 1000; chunk the mock data so the
|
||||
// queue mimics real multi-page round-trips and the loop stops on a short page.
|
||||
function paginate<T>(rows: T[]): Array<{ data: T[]; error: null }> {
|
||||
const PAGE = 1000
|
||||
const pages: Array<{ data: T[]; error: null }> = []
|
||||
for (let i = 0; i < rows.length; i += PAGE) {
|
||||
pages.push({ data: rows.slice(i, i + PAGE), error: null })
|
||||
}
|
||||
// Ensure a final short page so fetchAllRows terminates when the data is
|
||||
// an exact multiple of PAGE_SIZE.
|
||||
if (rows.length % PAGE === 0) pages.push({ data: [], error: null })
|
||||
return pages
|
||||
}
|
||||
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null }, // accounts
|
||||
...paginate(entries), // journal_entries — 3 pages (1000 + 1000 + 500)
|
||||
...paginate(lines), // journal_entry_lines — 5000 rows → 5 pages
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
|
||||
|
||||
// Every voucher present — including the first, last, and a middle one
|
||||
// that the old ~30-row cap would have dropped.
|
||||
const verCount = (output.match(/#VER /g) || []).length
|
||||
expect(verCount).toBe(ENTRY_COUNT)
|
||||
expect(output).toContain('#VER "A" 1 20240601 "Voucher 1"')
|
||||
expect(output).toContain(`#VER "A" 1500 20240601 "Voucher 1500"`)
|
||||
expect(output).toContain(`#VER "A" ${ENTRY_COUNT} 20240601 "Voucher ${ENTRY_COUNT}"`)
|
||||
|
||||
// Lines were stitched onto their entries (two #TRANS per voucher)
|
||||
const transCount = (output.match(/#TRANS /g) || []).length
|
||||
expect(transCount).toBe(ENTRY_COUNT * 2)
|
||||
})
|
||||
|
||||
it('emits #IB from compute_prior_opening_balances RPC fallback when opening_balance_entry_id is null', async () => {
|
||||
// Reproduces the user-reported bug: after a multi-year SIE import the
|
||||
// continuation-import guard intentionally leaves opening_balance_entry_id
|
||||
@@ -374,6 +432,7 @@ describe('generateSIEExport', () => {
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries (no movements this period)
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
// RPC fallback returns prior IBs derived from historical journal lines
|
||||
@@ -404,6 +463,7 @@ describe('generateSIEExport', () => {
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
// fetchAllRows page 1 — explicit OB entry lines
|
||||
|
||||
+50
-11
@@ -56,20 +56,59 @@ export async function generateSIEExport(
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Fetch all posted journal entries with lines
|
||||
let entriesQuery = supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', options.fiscal_period_id)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.order('voucher_number')
|
||||
// Fetch all posted journal entries — paginated to avoid truncation.
|
||||
// The previous nested `select('*, lines:journal_entry_lines(*)')` hit
|
||||
// PostgREST's response-row ceiling on the embedded resource and silently
|
||||
// truncated large periods (~30 vouchers). Fetch entries and lines as two
|
||||
// separate paginated queries and stitch them together in memory, mirroring
|
||||
// journal-register.ts.
|
||||
const entries = await fetchAllRows<JournalEntry>(({ from, to }) => {
|
||||
let q = supabase
|
||||
.from('journal_entries')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', options.fiscal_period_id)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
|
||||
if (options.exclude_year_end_closing) {
|
||||
entriesQuery = entriesQuery.neq('source_type', 'year_end')
|
||||
if (options.exclude_year_end_closing) {
|
||||
q = q.neq('source_type', 'year_end')
|
||||
}
|
||||
|
||||
return q.order('voucher_number').range(from, to)
|
||||
})
|
||||
|
||||
// Fetch all lines for those entries, filtered server-side via an inner join
|
||||
// so the same company/period/status (and year-end exclusion) constraints
|
||||
// apply, then group by journal_entry_id.
|
||||
const allLines = await fetchAllRows<JournalEntryLine & { journal_entry_id: string }>(({ from, to }) => {
|
||||
let q = supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('*, journal_entries!inner(company_id, fiscal_period_id, status, source_type)')
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.fiscal_period_id', options.fiscal_period_id)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
|
||||
if (options.exclude_year_end_closing) {
|
||||
q = q.neq('journal_entries.source_type', 'year_end')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return q.range(from, to) as any
|
||||
})
|
||||
|
||||
const linesByEntryId = new Map<string, JournalEntryLine[]>()
|
||||
for (const line of allLines) {
|
||||
const list = linesByEntryId.get(line.journal_entry_id)
|
||||
if (list) {
|
||||
list.push(line)
|
||||
} else {
|
||||
linesByEntryId.set(line.journal_entry_id, [line])
|
||||
}
|
||||
}
|
||||
|
||||
const { data: entries } = await entriesQuery
|
||||
for (const entry of entries) {
|
||||
entry.lines = linesByEntryId.get(entry.id) || []
|
||||
}
|
||||
|
||||
// Fetch cost centers and projects for dimension records
|
||||
const { data: costCenters } = await supabase
|
||||
|
||||
@@ -3012,6 +3012,8 @@
|
||||
"loading": "Loading journal entries...",
|
||||
"empty_title": "No journal entries",
|
||||
"empty_description": "Journal entries are created automatically from invoicing and transaction posting, or manually via the \"New entry\" tab.",
|
||||
"empty_drafts_title": "No drafts",
|
||||
"empty_drafts_description": "Entries you've saved but not yet posted collect here. Switch to Vouchers to see posted entries.",
|
||||
"show_missing": "Show entries missing documents",
|
||||
"sort_date_desc": "Date, newest first",
|
||||
"sort_date_asc": "Date, oldest first",
|
||||
@@ -3044,6 +3046,15 @@
|
||||
"post": "Post",
|
||||
"show_details": "Show details",
|
||||
"create_correction": "Create correction entry",
|
||||
"reverse_action": "Reverse (storno)",
|
||||
"reverse_confirm_title": "Reverse journal entry",
|
||||
"reverse_confirm_label": "Create storno",
|
||||
"reverse_warning": "Linked bank transactions return to \"To book\" and any invoice or payment status is reset. A storno entry is posted and cannot be changed afterwards.",
|
||||
"reverse_dialog_heading": "Reverse {voucher} with a storno",
|
||||
"reverse_dialog_body": "A storno entry that mirrors and zeroes out the journal entry is posted. The original is not deleted — it is marked as reversed and the original → storno chain stays traceable (BFL ch. 5 § 5). To change accounts or amounts instead, use Correct.",
|
||||
"toast_reverse_done_title": "Storno created",
|
||||
"toast_reverse_done_description": "Storno entry {voucher} has been posted.",
|
||||
"toast_reverse_failed": "Could not reverse journal entry",
|
||||
"copy": "Copy",
|
||||
"copy_voucher_tooltip": "Copy voucher",
|
||||
"search_placeholder": "Search description...",
|
||||
@@ -3190,6 +3201,15 @@
|
||||
"correct_menu": "Correct",
|
||||
"correct_lines": "Correct lines",
|
||||
"correct_date": "Correct date",
|
||||
"reverse_action": "Reverse (storno)",
|
||||
"reverse_confirm_title": "Reverse journal entry",
|
||||
"reverse_confirm_label": "Create storno",
|
||||
"reverse_warning": "Linked bank transactions return to \"To book\" and any invoice or payment status is reset. A storno entry is posted and cannot be changed afterwards.",
|
||||
"reverse_dialog_heading": "Reverse {voucher} with a storno",
|
||||
"reverse_dialog_body": "A storno entry that mirrors and zeroes out the journal entry is posted. The original is not deleted — it is marked as reversed and the original → storno chain stays traceable (BFL ch. 5 § 5). To change accounts or amounts instead, use Correct.",
|
||||
"toast_reverse_done_title": "Storno created",
|
||||
"toast_reverse_done_description": "Storno entry {voucher} has been posted.",
|
||||
"toast_reverse_failed": "Could not reverse journal entry",
|
||||
"details_title": "Journal entry details",
|
||||
"field_date": "Date",
|
||||
"field_posted_at": "Posted",
|
||||
|
||||
@@ -3012,6 +3012,8 @@
|
||||
"loading": "Laddar verifikationer...",
|
||||
"empty_title": "Inga verifikationer",
|
||||
"empty_description": "Verifikationer skapas automatiskt vid fakturering och transaktionsbokföring, eller manuellt via fliken \"Ny verifikation\".",
|
||||
"empty_drafts_title": "Inga utkast",
|
||||
"empty_drafts_description": "Här samlas verifikationer du sparat men inte bokfört än. Växla till Verifikat för att se bokförda poster.",
|
||||
"show_missing": "Visa saknade underlag",
|
||||
"sort_date_desc": "Datum, nyast först",
|
||||
"sort_date_asc": "Datum, äldst först",
|
||||
@@ -3044,6 +3046,15 @@
|
||||
"post": "Bokför",
|
||||
"show_details": "Visa detaljer",
|
||||
"create_correction": "Skapa ändringsverifikation",
|
||||
"reverse_action": "Återför (storno)",
|
||||
"reverse_confirm_title": "Återför verifikat",
|
||||
"reverse_confirm_label": "Skapa storno",
|
||||
"reverse_warning": "Kopplade banktransaktioner återgår till \"Att bokföra\" och eventuell faktura- eller betalningsstatus återställs. En stornoverifikation skapas och kan inte ändras efteråt.",
|
||||
"reverse_dialog_heading": "Återför {voucher} med en storno",
|
||||
"reverse_dialog_body": "En stornoverifikation som speglar och nollställer verifikatet bokförs. Originalet raderas inte — det markeras som omfört och kedjan original → storno förblir spårbar (BFL 5 kap. 5 §). Vill du istället byta konton eller belopp, använd Rätta.",
|
||||
"toast_reverse_done_title": "Storno skapad",
|
||||
"toast_reverse_done_description": "Stornoverifikation {voucher} har bokförts.",
|
||||
"toast_reverse_failed": "Kunde inte återföra verifikat",
|
||||
"copy": "Kopiera",
|
||||
"copy_voucher_tooltip": "Kopiera verifikat",
|
||||
"search_placeholder": "Sök verifikationstext...",
|
||||
@@ -3190,6 +3201,15 @@
|
||||
"correct_menu": "Rätta",
|
||||
"correct_lines": "Rätta rader",
|
||||
"correct_date": "Rätta datum",
|
||||
"reverse_action": "Återför (storno)",
|
||||
"reverse_confirm_title": "Återför verifikat",
|
||||
"reverse_confirm_label": "Skapa storno",
|
||||
"reverse_warning": "Kopplade banktransaktioner återgår till \"Att bokföra\" och eventuell faktura- eller betalningsstatus återställs. En stornoverifikation skapas och kan inte ändras efteråt.",
|
||||
"reverse_dialog_heading": "Återför {voucher} med en storno",
|
||||
"reverse_dialog_body": "En stornoverifikation som speglar och nollställer verifikatet bokförs. Originalet raderas inte — det markeras som omfört och kedjan original → storno förblir spårbar (BFL 5 kap. 5 §). Vill du istället byta konton eller belopp, använd Rätta.",
|
||||
"toast_reverse_done_title": "Storno skapad",
|
||||
"toast_reverse_done_description": "Stornoverifikation {voucher} har bokförts.",
|
||||
"toast_reverse_failed": "Kunde inte återföra verifikat",
|
||||
"details_title": "Verifikationsdetaljer",
|
||||
"field_date": "Datum",
|
||||
"field_posted_at": "Bokförd",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Persist the PSD2 PSU type (personal|business) chosen at authorization.
|
||||
--
|
||||
-- Why: the enable-banking /connect handler re-derives psu_type from the company
|
||||
-- entity_type on every call (aktiebolag -> 'business', enskild firma ->
|
||||
-- 'personal'). On a reconnect (90-day consent renewal, or a retry after a failed
|
||||
-- authorization) that silently overrides whatever the user actually authorized
|
||||
-- with. A connection that only works as 'personal' -- common for AB owners who
|
||||
-- sign Handelsbanken with a personal Mobile BankID -- then flips back to
|
||||
-- 'business' on every renewal and fails at the bank's signing step, with no way
|
||||
-- to switch type short of disconnecting. Storing the chosen type lets reconnect
|
||||
-- reuse what worked, and lets the user switch type in place.
|
||||
--
|
||||
-- Nullable, no default: legacy rows stay NULL and fall back to the existing
|
||||
-- entity_type derivation, so behaviour is unchanged until a row is
|
||||
-- (re)authorized. Idempotent so it is safe to re-apply on diverged branches.
|
||||
|
||||
alter table bank_connections
|
||||
add column if not exists psu_type text;
|
||||
|
||||
alter table bank_connections
|
||||
drop constraint if exists bank_connections_psu_type_check;
|
||||
|
||||
alter table bank_connections
|
||||
add constraint bank_connections_psu_type_check
|
||||
check (psu_type is null or psu_type in ('personal', 'business'));
|
||||
|
||||
comment on column bank_connections.psu_type is
|
||||
'PSD2 PSU type chosen at authorization (personal|business). Reused on reconnect so consent renewals keep the account type that actually worked; NULL on legacy rows falls back to company entity_type derivation in the /connect handler.';
|
||||
@@ -333,6 +333,10 @@ export interface BankConnection {
|
||||
// Status
|
||||
status: BankConnectionStatus
|
||||
|
||||
// PSD2 PSU type chosen at authorization. Reused on reconnect so consent
|
||||
// renewals keep the account type that actually worked. NULL on legacy rows.
|
||||
psu_type: 'personal' | 'business' | null
|
||||
|
||||
// Consent
|
||||
consent_expires: string | null
|
||||
last_synced_at: string | null
|
||||
|
||||
Reference in New Issue
Block a user