UX polish bundle: Enable Banking lookback + sync progress, invoice inbox, matching previews (#548)

* fix(import): dedup opening-balance rows when account numbers differ only in whitespace

The parser's merge map keyed on the post-strip account_number, but rows like
"1930", " 1930 " and "1.930" could leak as separate entries when the
upstream string contained non-breaking spaces or zero-width chars that the
old .replace(/[^0-9]/g, '') ran on already-stripped output. Strip those
explicitly in the raw string and use /\D/g for the digit extraction.

Also adds defense-in-depth dedup inside OpeningBalanceEditStep so any
duplicates that survive the parser collapse before the user sees them.

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

* feat(enable-banking): anchor lookback picker to fiscal year, not days

Replaces the 90/180/365 days dropdown on the account-selection screen
with three explicit modes:

- "Senaste 90 dagar (snabbt)" — fastest path, matches PSD2 ceiling
- "Sedan räkenskapsårets början" (default) — resolves via
  fiscal_year_start_month, surfaces the literal date inline
- "Anpassat datum" — free date picker OR "Föregående räkenskapsårets start"

When the resulting range exceeds 90 days, the picker now surfaces a
quiet helper that points users at the SIE/bankfil import for older
history, so they don't waste an account-selection round-trip discovering
that banks usually cap at ~90 days.

The PATCH /accounts handler accepts initial_lookback_from_date alongside
initial_lookback_days; the new helper getCurrentFiscalYearStart() in
lib/company/fiscal-year.ts is reused.

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

* feat(enable-banking): dedicated sync progress modal replaces silent spinner

After the user confirms account selection, transactions fetch in the
background for 30–60 seconds. Previously this showed only the Spara-button
spinner with no indication of duration or what was happening — users
described being stuck on the page.

The new BankSyncProgressDialog opens immediately on Save, lists the enabled
accounts being synced, and disables manual close until the PATCH resolves.
On completion it shows the imported count and the actual date range the
bank returned, plus an amber escape hatch to SIE/bankfil import when the
returned range was truncated by >7 days from what was requested.

Failure path surfaces in the same modal rather than as a destructive toast
that disappears.

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

* refactor(invoice-inbox): drop duplicate Skapa leverantör button

The inbox detail panel had its own supplier-creation button that fired
/api/suppliers + match-supplier. The same action is reachable from the
supplier-invoice form's "Skapa & välj" card (showAISupplierHint), which
also prefills more fields. The duplicate button is gone; a quiet inline
hint replaces it so the user still knows why no supplier matched.

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

* ui(invoice-inbox): surface currency and totals above the long metadata tail

Move Valuta / Totalt / Moms in FIELD_DEFS so they sit immediately under
Leverantör / Org.nr / VAT-nr. These are the fields the user reads first
when triaging an inbox item; burying them after nine metadata fields
forces unnecessary scrolling on every single invoice.

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

* feat(invoice-inbox): accept .eml forwards and log rejected attachments

Gmail's "Forward as attachment" packages the original email as message/rfc822,
which our MIME allowlist silently dropped. Adds mailparser so we can unwrap
the inner attachments and ingest them under the inner email's subject/from.

Also persists every rejected attachment as an invoice_inbox_items row with
status='error', so users can see what was dropped instead of guessing why
nothing showed up in their inbox.

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

* fix(supplier-invoices): redirect back to inbox after creating from invoice-inbox

When the leverantörsfaktura form is opened from an invoice-inbox item, every
successful create previously kicked the user out to /supplier-invoices or the
just-created invoice's detail page — derailing the "process the next
document" workflow. The Tillbaka button likewise routed to the
supplier-invoice list rather than the inbox they came from.

Adds an afterCreate helper that lands inbox-originated submissions at
/e/general/invoice-inbox and preserves the original target everywhere else.

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

* ui(pending): show transaction/document context for match-and-attach reviews

The granskning page previously rendered attach_document_to_transaction and
match_transaction_invoice operations through the generic key/value preview,
so reviewers saw "document file name: Faktura.pdf / transaction amount:
-216 USD" without any visual indication of which two things were being
paired. The MCP tool already returns enriched preview data; we just
needed dedicated layouts.

Adds:
- AttachDocumentPreview — two-card layout (Transaktion | Dokument) with
  a "Visa dokument" button that fetches a signed download URL on demand
- MatchTransactionInvoicePreview — same layout (Transaktion | Faktura)
- DocumentViewButton — reusable signed-URL opener

Also tightens the matching tools' descriptions so AI clients are nudged
to verify human-readable context before staging.

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

* fix(review): address PR #548 review feedback

- invoice-inbox: hoist mailparser to a static import. The extension system
  generates a static import tree via setup:extensions and disallows dynamic
  imports — await import('mailparser') worked in dev but could fail in
  production standalone builds.
- enable-banking AccountPickerDialog: guard the Save path when "Anpassat
  datum" + "Specifikt datum" is selected with an empty date. Without this,
  lookback.body resolves to null and the PATCH silently falls back to the
  backend's 120-day default, ignoring the user's intent.
- enable-banking BankSyncProgressDialog: drop the empty-body useEffect.
  Close-prevention is already handled inline via the onOpenChange guard +
  onPointerDownOutside + onEscapeKeyDown handlers.
- lib/company/fiscal-year: pin both operands of daysBetween() to UTC when
  parsing ISO date strings. Mixing a UTC-parsed date with new Date() (local
  time) drifts by one day in any timezone east of UTC.

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

* fix(review): address compliance swarm + Swedish review feedback

Three actionable items from the post-fix compliance scan; the rest were
false positives or out of scope.

- enable-banking PATCH /accounts: reject future initial_lookback_from_date
  with 400 instead of silently falling through to the 120-day default.
  Compliance V2.2.
- AttachDocumentPreview: promote the overwrite warning to a destructive
  banner with BFL 7 kap context when the existing document is marked as
  räkenskapsinformation. A muted footnote was too easy to skip past for
  a verifikationsunderlag replacement.
- MatchTransactionInvoicePreview: surface transaction_date + invoice_date
  in the staged preview so reviewers can spot date drift before approving
  (BFL 5 kap 6§ — verifikation date must align with affärshändelse). Also
  shows a quiet hint when the two dates differ by > 31 days. Tool's SELECT
  + stage payload extended accordingly.

Skipped (with rationale):
- V5.3 inner.filename path traversal — lib/core/documents/document-service.ts
  already sanitizes filenames before constructing storage paths.
- V5.2 magic-number MIME — pre-existing pattern for all email attachments;
  scope is codebase-wide.
- V1.2 att.id composite ID — only used as a DB column value, never a path.
- V13.1 / CM-8 SBOM/SCA — repository-wide policy, not this PR.

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

* fix(review): address second-round compliance + Swedish review feedback

Compliance Swarm (defense-in-depth + valid finds):
- invoice-inbox: sanitise .eml inner attachment filenames and content-types
  before they flow into uploadAndExtract or the raw_email_payload JSONB.
  document-service already strips bad chars before constructing storage
  paths, but the swarm flagged the upstream input as unsanitised — easier
  to add a thin sanitiseFilename/sanitiseMime layer than to argue about
  defense-in-depth. Caps lengths too.
- DocumentViewButton: validate documentId as a UUID before interpolating
  into /api/documents/:id — staged preview_data is Record<string, unknown>
  on the wire, so refusing junk early gives a clearer error and keeps the
  internal API from seeing oddly-shaped path segments. (Compliance V1.2.)

Swedish review:
- MatchTransactionInvoicePreview: drop the BFL 5 kap 6§ citation from the
  date-drift hint — that section governs verifikationsinnehåll, not a 31-day
  tolerance. The hint stays (the practical concern is real) but no longer
  pretends to quote a legislated threshold.
- fiscal-year: document the implicit assumption that entity_type reflects
  the company's current tax-year status, not a mid-conversion state.

Skipped (with rationale):
- V5.2 magic-number MIME — pre-existing pattern across all email attachments.
- A.8.12 signed URL via window.open — pre-existing pattern shared with
  JournalEntryAttachments.tsx; refactor to server-side redirect is broader scope.
- A.8.15 logRejection failure path — pre-existing console.error pattern.
- CC9.2 mailparser vendor review / SBOM — out of PR scope.
- CC6.1 IDOR — /api/documents/:id already enforces company_id; false positive.
- Swedish #1 räkenskapsinformation flag origin — server-side already derives
  the flag from document_attachments.journal_entry_id in the staging tool;
  not caller-trusted.

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

* fix(review): fail-safe BFL warning + preserve merge validation errors

Two findings from the third compliance pass; both worth addressing.

- AttachDocumentPreview: treat an absent
  existing_document_is_rakenskapsinformation flag as räkenskapsinformation
  rather than as "safe to overwrite". The MCP staging tool sets the flag
  deterministically from document_attachments.journal_entry_id today, but
  a future code path that forgets it would silently downgrade the BFL 7
  kap warning. Only an explicit `=== false` from the server keeps the
  muted note path.
- Opening-balance merge: union validation_errors when collapsing duplicate
  account_number rows, both in the parser and the EditStep useState
  initializer. Previously a warning that fired on row 5 (e.g. BAS-class
  mismatch) was silently dropped if row 2 of the same account had no error,
  risking misclassified IB data downstream. Added a parser test covering
  the union behaviour for two rows of a class-3 (resultatkonto) account.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-21 12:10:12 +02:00
committed by GitHub
parent e71b4a9138
commit 39204cc0de
18 changed files with 1192 additions and 229 deletions
+6
View File
@@ -23,6 +23,8 @@ import {
BookOpen,
} from 'lucide-react'
import type { PendingOperation, PendingOperationStatus } from '@/types'
import { AttachDocumentPreview } from '@/components/bookkeeping/AttachDocumentPreview'
import { MatchTransactionInvoicePreview } from '@/components/bookkeeping/MatchTransactionInvoicePreview'
const operationLabels: Record<string, { label: string; icon: typeof ArrowLeftRight; variant: 'default' | 'secondary' | 'outline' }> = {
categorize_transaction: { label: 'Kategorisering', icon: ArrowLeftRight, variant: 'default' },
@@ -375,6 +377,10 @@ function OperationPreview({ op }: { op: PendingOperation }) {
return <VoucherPreview data={op.preview_data} />
case 'correct_entry':
return <CorrectEntryPreview data={op.preview_data} />
case 'attach_document_to_transaction':
return <AttachDocumentPreview data={op.preview_data} params={op.params} />
case 'match_transaction_invoice':
return <MatchTransactionInvoicePreview data={op.preview_data} />
default:
return <GenericPreview data={op.preview_data} />
}
+24 -8
View File
@@ -107,6 +107,17 @@ export default function NewSupplierInvoicePage() {
const { canWrite } = useCanWrite()
const { toast } = useToast()
// When opened from an invoice-inbox item, every redirect should land the
// user back in the inbox so they can pick the next document. Outside the
// inbox flow, preserve the original behavior (detail page when we have an
// invoice id, otherwise the list).
const afterCreate = (invoiceId?: string) =>
inboxItemId
? '/e/general/invoice-inbox'
: invoiceId
? `/supplier-invoices/${invoiceId}`
: '/supplier-invoices'
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [suppliersLoaded, setSuppliersLoaded] = useState(false)
const [accounts, setAccounts] = useState<BASAccount[]>([])
@@ -624,7 +635,7 @@ export default function NewSupplierInvoicePage() {
title: 'Utlägg registrerat',
description: `Ankomstnummer: ${result.data.arrival_number}`,
})
router.push('/supplier-invoices')
router.push(afterCreate())
setIsSubmitting(false)
return
}
@@ -637,10 +648,10 @@ export default function NewSupplierInvoicePage() {
description: 'Fakturan skapades men kunde inte godkännas automatiskt',
variant: 'destructive',
})
router.push(`/supplier-invoices/${result.data.id}`)
router.push(afterCreate(result.data.id))
} else {
toast({ title: 'Faktura registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` })
router.push('/supplier-invoices')
router.push(afterCreate())
}
setIsSubmitting(false)
}
@@ -686,7 +697,7 @@ export default function NewSupplierInvoicePage() {
toast({ title: 'Faktura registrerad', description: `Ankomstnummer: ${arrivalNumber}` })
}
router.push(`/supplier-invoices/${invoiceId}`)
router.push(afterCreate(invoiceId))
} else {
// Treat duplicate-number as a recoverable conflict; everything else as a hard error.
if (status === 409 && result.error === 'duplicate_supplier_invoice_number') {
@@ -751,7 +762,7 @@ export default function NewSupplierInvoicePage() {
description: `Ankomstnummer: ${result.data.arrival_number}`,
})
reset(pendingData)
router.push(`/supplier-invoices/${result.data.id}`)
router.push(afterCreate(result.data.id))
return
}
@@ -828,13 +839,18 @@ export default function NewSupplierInvoicePage() {
})
}
reset(pendingData)
router.push(`/supplier-invoices/${invoiceId}`)
router.push(afterCreate(invoiceId))
}
return (
<div className="space-y-6 max-w-4xl">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push('/supplier-invoices')} aria-label="Tillbaka till leverantörsfakturor">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(inboxItemId ? '/e/general/invoice-inbox' : '/supplier-invoices')}
aria-label={inboxItemId ? 'Tillbaka till inkorgen' : 'Tillbaka till leverantörsfakturor'}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
@@ -1320,7 +1336,7 @@ export default function NewSupplierInvoicePage() {
{/* Submit */}
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-3 sm:gap-4">
<Button type="button" variant="outline" className="w-full sm:w-auto" onClick={() => router.push('/supplier-invoices')}>
<Button type="button" variant="outline" className="w-full sm:w-auto" onClick={() => router.push(inboxItemId ? '/e/general/invoice-inbox' : '/supplier-invoices')}>
Avbryt
</Button>
{!watchedPaidPrivately && (
@@ -0,0 +1,114 @@
'use client'
import { ArrowDown, AlertTriangle } from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { DocumentViewButton } from './DocumentViewButton'
interface AttachDocumentPreviewProps {
data: Record<string, unknown>
params: Record<string, unknown>
}
/**
* Two-card preview for `attach_document_to_transaction` operations. Renders
* the transaction and the document side by side so the reviewer can confirm
* the pairing without cross-referencing IDs.
*/
export function AttachDocumentPreview({ data, params }: AttachDocumentPreviewProps) {
const txDescription = (data.transaction_description as string) || '—'
const txAmount = data.transaction_amount as number | undefined
const txCurrency = (data.transaction_currency as string) || 'SEK'
const txDate = data.transaction_date as string | undefined
const docFileName = (data.document_file_name as string) || '—'
const docVendor = data.document_vendor_name as string | undefined
const docAmount = data.document_amount as number | undefined
const docCurrency = (data.document_currency as string) || txCurrency
const docInvoiceDate = data.document_invoice_date as string | undefined
const willOverwrite = data.will_overwrite_existing === true
const existingDocName = data.existing_document_file_name as string | undefined
// Fail safe: if the staging tool didn't explicitly assert the existing doc
// is NOT räkenskapsinformation (i.e. `=== false`), treat overwrite as a
// BFL 7 kap event. A missing/undefined flag must not silently downgrade
// the destructive warning.
const existingIsAccounting =
willOverwrite && data.existing_document_is_rakenskapsinformation !== false
const documentId = params.document_id as string | undefined
return (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<PreviewCard label="Transaktion">
<Row label="Datum" value={txDate ? formatDate(txDate) : '—'} tabular />
<Row label="Beskrivning" value={txDescription} />
<Row
label="Belopp"
value={typeof txAmount === 'number' ? formatCurrency(txAmount, txCurrency) : '—'}
tabular
/>
</PreviewCard>
<PreviewCard label="Dokument">
<Row label="Fil" value={docFileName} />
{docVendor && <Row label="Leverantör" value={docVendor} />}
{docInvoiceDate && <Row label="Fakturadatum" value={formatDate(docInvoiceDate)} tabular />}
{typeof docAmount === 'number' && (
<Row label="Belopp" value={formatCurrency(docAmount, docCurrency)} tabular />
)}
{documentId && (
<div className="pt-1">
<DocumentViewButton documentId={documentId} />
</div>
)}
</PreviewCard>
</div>
<div className="flex items-center justify-center text-xs text-muted-foreground">
<ArrowDown className="h-3.5 w-3.5 mr-1" />
kopplas till transaktionen
</div>
{willOverwrite && existingIsAccounting && (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Ersätter räkenskapsinformation</p>
<p className="mt-1 text-xs">
Befintligt dokument{existingDocName ? ` (${existingDocName})` : ''} är markerat som
räkenskapsinformation enligt BFL 7 kap. Att ersätta det här gör det tidigare
verifikationsunderlaget otillgängligt bekräfta att du har originalet sparat innan
du godkänner.
</p>
</div>
</div>
)}
{willOverwrite && !existingIsAccounting && (
<div className="rounded-md border border-border bg-muted/30 p-2 text-xs text-muted-foreground">
Ersätter befintligt dokument{existingDocName ? `: ${existingDocName}` : ''}.
</div>
)}
</div>
)
}
function PreviewCard({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="rounded-lg border border-border p-3 space-y-1.5">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</p>
{children}
</div>
)
}
function Row({ label, value, tabular }: { label: string; value: string; tabular?: boolean }) {
return (
<div className="flex justify-between gap-3">
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
<span className={`text-sm ${tabular ? 'tabular-nums' : ''} text-right truncate`}>{value}</span>
</div>
)
}
@@ -0,0 +1,72 @@
'use client'
import { useState } from 'react'
import { ExternalLink, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
interface DocumentViewButtonProps {
documentId: string
label?: string
className?: string
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/**
* Opens a document's signed download URL in a new tab. The signed URL is
* minted on demand via /api/documents/:id (60 min TTL), so we don't bake
* stale URLs into the preview payload.
*/
export function DocumentViewButton({ documentId, label = 'Visa dokument', className }: DocumentViewButtonProps) {
const { toast } = useToast()
const [loading, setLoading] = useState(false)
const handleClick = async () => {
// documentId originates from staged preview_data (Record<string, unknown>);
// validate the shape before interpolating into the request URL so a malformed
// payload can't redirect the fetch at another internal endpoint.
if (!UUID_RE.test(documentId)) {
toast({
title: 'Ogiltigt dokument-ID',
description: 'Försök ladda om sidan eller kontakta support.',
variant: 'destructive',
})
return
}
setLoading(true)
try {
const res = await fetch(`/api/documents/${documentId}`)
const json = await res.json().catch(() => ({}))
if (!res.ok || !json?.data?.download_url) {
toast({
title: 'Kunde inte öppna dokumentet',
description: json?.error || 'Försök igen om en stund.',
variant: 'destructive',
})
return
}
window.open(json.data.download_url as string, '_blank', 'noopener,noreferrer')
} finally {
setLoading(false)
}
}
return (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleClick}
disabled={loading}
className={className}
>
{loading ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
)}
{label}
</Button>
)
}
@@ -0,0 +1,92 @@
'use client'
import { ArrowDown } from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
interface MatchTransactionInvoicePreviewProps {
data: Record<string, unknown>
}
/**
* Two-card preview for `match_transaction_invoice` operations. Mirrors
* AttachDocumentPreview's layout so reviewers learn one matching idiom.
*/
export function MatchTransactionInvoicePreview({ data }: MatchTransactionInvoicePreviewProps) {
const txDescription = (data.transaction_description as string) || '—'
const txAmount = data.transaction_amount as number | undefined
const txCurrency = (data.transaction_currency as string) || 'SEK'
const txDate = data.transaction_date as string | undefined
const invoiceNumber = (data.invoice_number as string) || '—'
const invoiceTotal = data.invoice_total as number | undefined
const invoiceCurrency = (data.invoice_currency as string) || txCurrency
const invoiceDate = data.invoice_date as string | undefined
const customerName = data.customer_name as string | undefined
// BFL 5 kap 4§ requires bookings to be made "so soon as possible" relative
// to the affärshändelse, so a transaction and invoice that diverge by more
// than a calendar month deserve a second look before the reviewer approves
// the match. The threshold is editorial, not legislated — it just nudges
// the reviewer; it doesn't block.
const showDateDriftHint =
txDate &&
invoiceDate &&
Math.abs(new Date(txDate).getTime() - new Date(invoiceDate).getTime()) > 31 * 24 * 60 * 60 * 1000
return (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<PreviewCard label="Transaktion">
{txDate && <Row label="Datum" value={formatDate(txDate)} tabular />}
<Row label="Beskrivning" value={txDescription} />
<Row
label="Belopp"
value={typeof txAmount === 'number' ? formatCurrency(txAmount, txCurrency) : '—'}
tabular
/>
</PreviewCard>
<PreviewCard label="Faktura">
<Row label="Nummer" value={invoiceNumber} tabular />
{invoiceDate && <Row label="Fakturadatum" value={formatDate(invoiceDate)} tabular />}
{customerName && <Row label="Kund" value={customerName} />}
{typeof invoiceTotal === 'number' && (
<Row label="Totalt" value={formatCurrency(invoiceTotal, invoiceCurrency)} tabular />
)}
</PreviewCard>
</div>
<div className="flex items-center justify-center text-xs text-muted-foreground">
<ArrowDown className="h-3.5 w-3.5 mr-1" />
matchas mot fakturan
</div>
{showDateDriftHint && (
<p className="text-xs text-muted-foreground">
Transaktionsdatum och fakturadatum skiljer sig med mer än en månad kontrollera att
matchningen avser rätt affärshändelse.
</p>
)}
</div>
)
}
function PreviewCard({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="rounded-lg border border-border p-3 space-y-1.5">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</p>
{children}
</div>
)
}
function Row({ label, value, tabular }: { label: string; value: string; tabular?: boolean }) {
return (
<div className="flex justify-between gap-3">
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
<span className={`text-sm ${tabular ? 'tabular-nums' : ''} text-right truncate`}>{value}</span>
</div>
)
}
@@ -1260,81 +1260,16 @@ function FieldsRail({
!isProcessed && !isBookedDirectly && !!item.matched_transaction_id
const isResolved = isProcessed || isBookedDirectly || isLinkedToTransaction
const [isRetrying, setIsRetrying] = useState(false)
const [isCreatingSupplier, setIsCreatingSupplier] = useState(false)
// "Skapa leverantör" — surface when extraction caught a supplier name
// but no existing supplier matched. Frees the user from navigating to
// /suppliers/new manually for the very common "first invoice from this
// vendor" case.
// Surface a quiet hint when extraction caught a supplier name but no existing
// supplier matched. The actual creation flow lives on the leverantörsfaktura
// form (Skapa & välj), so we don't render a separate button here.
const extractedSupplierName = data?.supplier?.name?.trim() || null
const showCreateSupplierCta =
const showNoMatchHint =
!isResolved &&
!item.matched_supplier_id &&
!!extractedSupplierName
const handleCreateSupplier = async () => {
if (!extractedSupplierName) return
setIsCreatingSupplier(true)
try {
// Heuristic supplier_type: any extracted VAT number starting with "SE"
// (or a 10-digit org_number) → Swedish; otherwise default to
// non_eu_business. The user can correct on the supplier detail page.
const vat = data?.supplier?.vatNumber?.trim() || ''
const org = data?.supplier?.orgNumber?.trim() || ''
const supplierType =
vat.toUpperCase().startsWith('SE') || /^\d{6}-?\d{4}$/.test(org)
? 'swedish_business'
: 'non_eu_business'
const createRes = await fetch('/api/suppliers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: extractedSupplierName,
supplier_type: supplierType,
org_number: org || undefined,
vat_number: vat || undefined,
address_line1: data?.supplier?.address || undefined,
bankgiro: data?.supplier?.bankgiro || undefined,
plusgiro: data?.supplier?.plusgiro || undefined,
}),
})
const createJson = await createRes.json().catch(() => ({}))
if (!createRes.ok || !createJson?.data?.id) {
toast({
title: 'Kunde inte skapa leverantör',
description: createJson?.error || 'Försök igen.',
variant: 'destructive',
})
return
}
// Link the new supplier back to the inbox item so the next action
// (Skapa leverantörsfaktura) prefills correctly.
const matchRes = await fetch(
`/api/extensions/ext/invoice-inbox/items/${item.id}/match-supplier`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_id: createJson.data.id }),
},
)
if (!matchRes.ok) {
const matchErr = await matchRes.json().catch(() => ({}))
toast({
title: 'Leverantör skapad, men inte kopplad',
description: matchErr?.error || 'Välj leverantören manuellt på leverantörsfakturan.',
variant: 'destructive',
})
} else {
toast({ title: 'Leverantör skapad', description: extractedSupplierName })
}
await onRetryRequested()
} finally {
setIsCreatingSupplier(false)
}
}
const handleRetry = async () => {
setIsRetrying(true)
try {
@@ -1412,26 +1347,12 @@ function FieldsRail({
</div>
)}
{/* Skapa leverantör hint — only when no existing supplier matched */}
{showCreateSupplierCta && (
<div className="border-b bg-primary/5 px-4 py-2.5 text-xs flex items-center justify-between gap-2">
<span className="text-muted-foreground">
Ingen leverantör matchade <span className="text-foreground font-medium">{extractedSupplierName}</span>
</span>
<Button
variant="outline"
size="sm"
className="h-7 text-xs shrink-0"
onClick={handleCreateSupplier}
disabled={isCreatingSupplier}
>
{isCreatingSupplier ? (
<Loader2 className="h-3 w-3 mr-1.5 animate-spin" />
) : (
<Plus className="h-3 w-3 mr-1.5" />
)}
Skapa leverantör
</Button>
{/* Hint only — creation happens on the leverantörsfaktura form via "Skapa & välj" */}
{showNoMatchHint && (
<div className="border-b bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
Ingen leverantör matchade{' '}
<span className="text-foreground font-medium">{extractedSupplierName}</span>
{' — leverantören skapas när du klickar Skapa leverantörsfaktura.'}
</div>
)}
@@ -1604,15 +1525,15 @@ const FIELD_DEFS: FieldDef[] = [
{ key: 'supplier.name', label: 'Leverantör', type: 'text' },
{ key: 'supplier.orgNumber', label: 'Org.nr', type: 'text' },
{ key: 'supplier.vatNumber', label: 'VAT-nr', type: 'text' },
{ key: 'invoice.currency', label: 'Valuta', type: 'text' },
{ key: 'totals.total', label: 'Totalt', type: 'number', inputMode: 'decimal' },
{ key: 'totals.vatAmount', label: 'Moms', type: 'number', inputMode: 'decimal' },
{ key: 'supplier.bankgiro', label: 'Bankgiro', type: 'text' },
{ key: 'supplier.plusgiro', label: 'Plusgiro', type: 'text' },
{ key: 'invoice.invoiceNumber', label: 'Fakturanr', type: 'text' },
{ key: 'invoice.paymentReference', label: 'OCR/Referens', type: 'text' },
{ key: 'invoice.invoiceDate', label: 'Fakturadatum', type: 'date' },
{ key: 'invoice.dueDate', label: 'Förfallodatum', type: 'date' },
{ key: 'invoice.currency', label: 'Valuta', type: 'text' },
{ key: 'totals.total', label: 'Totalt', type: 'number', inputMode: 'decimal' },
{ key: 'totals.vatAmount', label: 'Moms', type: 'number', inputMode: 'decimal' },
]
function readField(data: InvoiceExtractionResult, key: FieldKey): string {
+33 -11
View File
@@ -67,17 +67,39 @@ export default function OpeningBalanceEditStep({
onContinue,
onBack,
}: OpeningBalanceEditStepProps) {
const [rows, setRows] = useState<EditableRow[]>(() =>
initialRows.map((r) => ({
id: generateId(),
account_number: r.account_number,
account_name: r.account_name,
debit_amount: r.debit_amount,
credit_amount: r.credit_amount,
validation_errors: r.validation_errors,
bas_match: r.bas_match,
})),
)
const [rows, setRows] = useState<EditableRow[]>(() => {
// Defense-in-depth dedup: if the parser ever leaks duplicates by
// account_number, collapse them here before the user sees them. Union
// validation_errors so a warning surfaced only on the later row isn't
// silently dropped during the merge.
const byAccount = new Map<string, EditableRow>()
for (const r of initialRows) {
const key = r.account_number.replace(/\D/g, '')
const existing = byAccount.get(key)
if (existing) {
existing.debit_amount = Math.round((existing.debit_amount + r.debit_amount) * 100) / 100
existing.credit_amount = Math.round((existing.credit_amount + r.credit_amount) * 100) / 100
if (!existing.account_name && r.account_name) existing.account_name = r.account_name
if (r.validation_errors?.length) {
const seen = new Set(existing.validation_errors)
for (const err of r.validation_errors) {
if (!seen.has(err)) existing.validation_errors.push(err)
}
}
continue
}
byAccount.set(key, {
id: generateId(),
account_number: r.account_number,
account_name: r.account_name,
debit_amount: r.debit_amount,
credit_amount: r.credit_amount,
validation_errors: [...r.validation_errors],
bas_match: r.bas_match,
})
}
return Array.from(byAccount.values())
})
const [activeAutocomplete, setActiveAutocomplete] = useState<string | null>(null)
const [autocompleteQuery, setAutocompleteQuery] = useState('')
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import {
Dialog,
DialogContent,
@@ -11,6 +12,7 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
@@ -22,7 +24,17 @@ import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import {
getCurrentFiscalYearStart,
getPreviousFiscalYearStart,
daysBetween,
} from '@/lib/company/fiscal-year'
import type { CompanySettings } from '@/types'
import type { StoredAccount } from '../types'
import {
BankSyncProgressDialog,
type SyncProgressState,
} from './BankSyncProgressDialog'
interface AccountPickerDialogProps {
open: boolean
@@ -42,11 +54,8 @@ interface ChartAccount {
account_name: string
}
const LOOKBACK_OPTIONS = [
{ days: 90, label: 'Senaste 90 dagar (PSD2 standard, rekommenderas)' },
{ days: 180, label: 'Senaste 180 dagar' },
{ days: 365, label: 'Senaste 365 dagar' },
] as const
type LookbackMode = 'fast' | 'fiscal-year' | 'custom'
type CustomSubMode = 'date' | 'previous-fiscal-year'
// Suggested BAS account per currency. The mapping engine falls back to 1930
// when ledger_account is unset, so the SEK case is just an explicit hint.
@@ -77,11 +86,17 @@ export function AccountPickerDialog({
const [selected, setSelected] = useState<Set<string>>(new Set())
const [isSaving, setIsSaving] = useState(false)
const [lookbackDays, setLookbackDays] = useState<number>(90)
const [sieLastDate, setSieLastDate] = useState<string | null>(null)
const [showCustomLookback, setShowCustomLookback] = useState(false)
const [chartAccounts, setChartAccounts] = useState<ChartAccount[]>([])
const [ledgerByUid, setLedgerByUid] = useState<Record<string, string>>({})
const [companySettings, setCompanySettings] = useState<Pick<CompanySettings, 'fiscal_year_start_month' | 'entity_type'> | null>(null)
const [lookbackMode, setLookbackMode] = useState<LookbackMode>('fiscal-year')
const [customSubMode, setCustomSubMode] = useState<CustomSubMode>('date')
const [customDate, setCustomDate] = useState<string>('')
const [progressOpen, setProgressOpen] = useState(false)
const [progressState, setProgressState] = useState<SyncProgressState>({ kind: 'syncing' })
useEffect(() => {
if (open) {
@@ -89,7 +104,9 @@ export function AccountPickerDialog({
accounts.filter(a => a.enabled !== false).map(a => a.uid)
)
setSelected(initial)
setShowCustomLookback(false)
setLookbackMode('fiscal-year')
setCustomSubMode('date')
setCustomDate('')
// Pre-populate ledger picks from existing StoredAccount values, falling
// back to currency-based suggestions for accounts the user hasn't mapped yet.
@@ -103,8 +120,25 @@ export function AccountPickerDialog({
}
}, [open, accounts])
// Fetch the latest SIE import end date so we can anchor the backfill to
// "day after last SIE entry" (SpeedLedger pattern). Only matters on the
// Load fiscal_year_start_month + entity_type so "Sedan räkenskapsårets början"
// resolves to the right date for non-calendar fiscal years.
useEffect(() => {
if (!open || !company?.id) return
let cancelled = false
;(async () => {
const { data } = await supabase
.from('company_settings')
.select('fiscal_year_start_month, entity_type')
.eq('company_id', company.id)
.maybeSingle()
if (cancelled) return
setCompanySettings((data as { fiscal_year_start_month?: number; entity_type?: CompanySettings['entity_type'] } | null) as Pick<CompanySettings, 'fiscal_year_start_month' | 'entity_type'> | null)
})()
return () => { cancelled = true }
}, [open, company?.id, supabase])
// Fetch the latest SIE import end date so we can offer "day after last SIE entry"
// as a one-click escape from the default fiscal-year start. Only matters on the
// initial activation flow — selection edits don't re-run sync.
useEffect(() => {
if (!open || !isInitialSelection || !company?.id) {
@@ -122,16 +156,7 @@ export function AccountPickerDialog({
.limit(1)
.maybeSingle()
if (cancelled) return
const fye = (data as { fiscal_year_end?: string } | null)?.fiscal_year_end || null
setSieLastDate(fye)
if (fye) {
const dayAfter = new Date(fye)
dayAfter.setDate(dayAfter.getDate() + 1)
const days = Math.ceil((Date.now() - dayAfter.getTime()) / (24 * 60 * 60 * 1000))
setLookbackDays(Math.min(365, Math.max(30, days)))
} else {
setLookbackDays(90)
}
setSieLastDate((data as { fiscal_year_end?: string } | null)?.fiscal_year_end || null)
})()
return () => { cancelled = true }
}, [open, isInitialSelection, company?.id, supabase])
@@ -217,7 +242,34 @@ export function AccountPickerDialog({
return
}
// Block save when the user picked "Anpassat datum" but left the date blank.
// Without this guard, lookback.body is null and the PATCH would silently
// fall back to the backend's 120-day default — not what the user asked for.
if (
isInitialSelection &&
lookbackMode === 'custom' &&
customSubMode === 'date' &&
!lookback.body
) {
toast({
title: 'Ange startdatum',
description: 'Välj ett datum för att hämta historik, eller välj ett annat alternativ.',
variant: 'destructive',
})
return
}
setIsSaving(true)
// For the initial-selection path, open the progress modal up-front so the
// user has visible feedback during the 3060s backfill. Selection edits
// (no backfill) keep the existing toast-only feedback.
if (isInitialSelection) {
setProgressState({ kind: 'syncing' })
setProgressOpen(true)
onOpenChange(false)
}
try {
// Send a mapping entry per selected account. Account_mappings doesn't
// include disabled accounts — their existing ledger_account stays untouched.
@@ -233,7 +285,7 @@ export function AccountPickerDialog({
connection_id: connectionId,
enabled_uids: Array.from(selected),
account_mappings,
...(isInitialSelection ? { initial_lookback_days: lookbackDays } : {}),
...(isInitialSelection && lookback.body ? lookback.body : {}),
}),
})
@@ -244,39 +296,32 @@ export function AccountPickerDialog({
}
if (isInitialSelection && data.initial_sync) {
const { imported, returned_min_date, returned_max_date } = data.initial_sync as {
imported: number
returned_min_date: string | null
returned_max_date: string | null
}
const range = returned_min_date && returned_max_date
? ` från ${returned_min_date} till ${returned_max_date}`
: ''
toast({
title: 'Konton sparade',
description: `Importerade ${imported} transaktioner${range}.`,
})
setProgressState({ kind: 'done', summary: data.initial_sync })
} else if (isInitialSelection && data.initial_sync_error) {
toast({
title: 'Konton sparade — bakgrundssync misslyckades',
description: 'Vi försöker igen vid nästa körning. Bankanslutningen är aktiv.',
variant: 'destructive',
setProgressState({
kind: 'failed',
error: { message: 'Vi sparade kontovalet men kunde inte hämta transaktioner just nu. Vi försöker igen vid nästa körning.' },
})
} else {
toast({
title: 'Kontoval sparat',
description: `${data.enabled_count} av ${data.total_count} konton kommer synkas.`,
})
onOpenChange(false)
}
onOpenChange(false)
onSaved()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte spara kontoval',
variant: 'destructive',
})
const message = error instanceof Error ? error.message : 'Kunde inte spara kontoval'
if (isInitialSelection) {
setProgressState({ kind: 'failed', error: { message } })
} else {
toast({
title: 'Fel',
description: message,
variant: 'destructive',
})
}
} finally {
setIsSaving(false)
}
@@ -289,7 +334,49 @@ export function AccountPickerDialog({
return d.toISOString().split('T')[0]
}, [sieLastDate])
const fiscalYearStart = useMemo(
() => getCurrentFiscalYearStart(companySettings),
[companySettings],
)
const previousFiscalYearStart = useMemo(
() => getPreviousFiscalYearStart(companySettings),
[companySettings],
)
// Resolve mode → concrete request payload and a "resolved from-date" for display.
const lookback = useMemo(() => {
if (lookbackMode === 'fast') {
return { body: { initial_lookback_days: 90 }, fromDate: null as string | null, days: 90 }
}
if (lookbackMode === 'fiscal-year') {
return { body: { initial_lookback_from_date: fiscalYearStart }, fromDate: fiscalYearStart, days: daysBetween(fiscalYearStart) }
}
// custom
const date = customSubMode === 'previous-fiscal-year' ? previousFiscalYearStart : customDate
if (date && /^\d{4}-\d{2}-\d{2}$/.test(date)) {
return { body: { initial_lookback_from_date: date }, fromDate: date, days: daysBetween(date) }
}
return { body: null as Record<string, string | number> | null, fromDate: null as string | null, days: 0 }
}, [lookbackMode, customSubMode, customDate, fiscalYearStart, previousFiscalYearStart])
const showLongRangeHelper = lookback.days > 90
return (
<>
<BankSyncProgressDialog
open={progressOpen}
onOpenChange={(next) => {
setProgressOpen(next)
// When the user closes the summary, propagate the saved/refresh
// signal to the parent (it would have been emitted on success earlier;
// this just guards the failure case where we still want a refresh).
if (!next) onSaved()
}}
bankName={bankName}
accounts={accounts.filter((a) => selected.has(a.uid))}
state={progressState}
/>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
@@ -302,70 +389,131 @@ export function AccountPickerDialog({
</DialogHeader>
{isInitialSelection && (
<div className="rounded-lg border border-border bg-muted/30 p-4 text-sm">
{sieLastDate && dayAfterSie ? (
<div className="space-y-2">
<p>
Vi hittade en SIE-import som täcker fram till{' '}
<span className="font-medium tabular-nums">{sieLastDate}</span>.
Vi hämtar bankhistorik från{' '}
<span className="font-medium tabular-nums">{dayAfterSie}</span>{' '}
att inget överlappar din tidigare bokföring.
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-4 text-sm">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-muted-foreground">
Hämta historik från
</label>
<p className="mt-1 text-xs text-muted-foreground">
Vi börjar hämta transaktioner från det datum du väljer. Du behöver inte tänka i dagar.
</p>
</div>
{sieLastDate && dayAfterSie && (
<div className="flex items-start justify-between gap-3 rounded-md border border-border bg-background/60 p-3">
<p className="text-xs text-muted-foreground">
Senaste SIE-importen täcker till{' '}
<span className="font-medium tabular-nums text-foreground">{sieLastDate}</span>.
Vi föreslår{' '}
<span className="font-medium tabular-nums text-foreground">{dayAfterSie}</span>{' '}
som startdatum inget överlappar din bokföring.
</p>
<button
type="button"
onClick={() => setShowCustomLookback(v => !v)}
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
className="shrink-0 text-xs text-foreground underline underline-offset-2"
onClick={() => {
setLookbackMode('custom')
setCustomSubMode('date')
setCustomDate(dayAfterSie)
}}
disabled={isSaving}
>
{showCustomLookback ? 'Använd föreslagen period' : 'Anpassa period'}
Använd detta datum
</button>
{showCustomLookback && (
<Select
value={String(lookbackDays)}
onValueChange={(v) => setLookbackDays(Number(v))}
disabled={isSaving}
>
<SelectTrigger className="mt-2 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOOKBACK_OPTIONS.map(opt => (
<SelectItem key={opt.days} value={String(opt.days)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
) : (
<div className="space-y-2">
<label className="block text-xs font-medium uppercase tracking-wider text-muted-foreground">
Hämta historik från
</label>
<Select
value={String(lookbackDays)}
onValueChange={(v) => setLookbackDays(Number(v))}
)}
<div className="space-y-2">
<label className="flex cursor-pointer items-start gap-2">
<input
type="radio"
name="lookback-mode"
value="fast"
checked={lookbackMode === 'fast'}
onChange={() => setLookbackMode('fast')}
disabled={isSaving}
className="mt-1"
/>
<span>
<span className="block">Senaste 90 dagar <span className="text-muted-foreground">(snabbt)</span></span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2">
<input
type="radio"
name="lookback-mode"
value="fiscal-year"
checked={lookbackMode === 'fiscal-year'}
onChange={() => setLookbackMode('fiscal-year')}
disabled={isSaving}
className="mt-1"
/>
<span>
<span className="block">Sedan räkenskapsårets början</span>
<span className="text-xs text-muted-foreground tabular-nums">
från {fiscalYearStart}
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2">
<input
type="radio"
name="lookback-mode"
value="custom"
checked={lookbackMode === 'custom'}
onChange={() => setLookbackMode('custom')}
disabled={isSaving}
className="mt-1"
/>
<span className="flex-1">
<span className="block">Anpassat datum</span>
{lookbackMode === 'custom' && (
<div className="mt-2 space-y-2">
<Select
value={customSubMode}
onValueChange={(v) => setCustomSubMode(v as CustomSubMode)}
disabled={isSaving}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="date">Specifikt datum</SelectItem>
<SelectItem value="previous-fiscal-year">
Föregående räkenskapsårets start ({previousFiscalYearStart})
</SelectItem>
</SelectContent>
</Select>
{customSubMode === 'date' && (
<Input
type="date"
value={customDate}
onChange={(e) => setCustomDate(e.target.value)}
max={new Date().toISOString().split('T')[0]}
min={new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]}
disabled={isSaving}
className="tabular-nums"
/>
)}
</div>
)}
</span>
</label>
</div>
{showLongRangeHelper && (
<p className="text-xs text-muted-foreground">
Din bank returnerar oftast max 90 dagar. Behöver du äldre transaktioner kan du{' '}
<Link
href="/import?mode=sie"
className="text-foreground underline underline-offset-2"
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOOKBACK_OPTIONS.map(opt => (
<SelectItem key={opt.days} value={String(opt.days)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
PSD2-bankregler begränsar oftast historiken till 90 dagar bakåt.
Vi visar exakt vad banken returnerade efter sparat val.
För äldre data, använd SIE- eller CSV-import.
</p>
</div>
importera via SIE eller bankfil
</Link>
. Vi visar exakt vad banken returnerade efter sparat val.
</p>
)}
</div>
)}
@@ -500,5 +648,6 @@ export function AccountPickerDialog({
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,176 @@
'use client'
import Link from 'next/link'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Loader2, CheckCircle2, AlertTriangle } from 'lucide-react'
import { daysBetween } from '@/lib/company/fiscal-year'
import type { StoredAccount } from '../types'
export interface SyncProgressSummary {
imported: number
duplicates: number
requested_from: string
returned_min_date: string | null
returned_max_date: string | null
}
export interface SyncProgressError {
message: string
}
export type SyncProgressState =
| { kind: 'syncing' }
| { kind: 'done'; summary: SyncProgressSummary }
| { kind: 'failed'; error: SyncProgressError }
interface BankSyncProgressDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
bankName: string
accounts: StoredAccount[]
state: SyncProgressState
}
export function BankSyncProgressDialog({
open,
onOpenChange,
bankName,
accounts,
state,
}: BankSyncProgressDialogProps) {
// Close-prevention while sync is in flight is handled inline below via the
// onOpenChange guard + onPointerDownOutside + onEscapeKeyDown handlers.
const enabledAccounts = accounts.filter((a) => a.enabled !== false)
return (
<Dialog
open={open}
onOpenChange={(next) => {
// Block manual close mid-sync
if (!next && state.kind === 'syncing') return
onOpenChange(next)
}}
>
<DialogContent
className="max-w-md"
onPointerDownOutside={(e) => {
if (state.kind === 'syncing') e.preventDefault()
}}
onEscapeKeyDown={(e) => {
if (state.kind === 'syncing') e.preventDefault()
}}
>
<DialogHeader>
<DialogTitle>
{state.kind === 'syncing' && `Hämtar transaktioner från ${bankName}`}
{state.kind === 'done' && 'Klart'}
{state.kind === 'failed' && 'Synkningen misslyckades'}
</DialogTitle>
<DialogDescription>
{state.kind === 'syncing' && (
<>
Vi hämtar transaktioner från {enabledAccounts.length}{' '}
{enabledAccounts.length === 1 ? 'konto' : 'konton'}. Detta kan ta upp till en minut. Stäng inte fönstret.
</>
)}
{state.kind === 'done' && (
<>
Vi hämtade {state.summary.imported}{' '}
{state.summary.imported === 1 ? 'transaktion' : 'transaktioner'}.
</>
)}
{state.kind === 'failed' && (
<>Vi försöker igen automatiskt i bakgrunden. Du kan stänga den här rutan.</>
)}
</DialogDescription>
</DialogHeader>
{state.kind === 'syncing' && (
<div className="space-y-3 py-2">
<div className="flex items-center justify-center py-6">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<ul className="rounded-lg border border-border divide-y divide-border text-sm">
{enabledAccounts.map((a) => (
<li key={a.uid} className="flex items-center justify-between gap-3 px-3 py-2">
<span className="truncate">{a.name || a.iban || a.uid}</span>
<span className="text-xs text-muted-foreground">{a.currency}</span>
</li>
))}
</ul>
</div>
)}
{state.kind === 'done' && (
<DoneBody summary={state.summary} />
)}
{state.kind === 'failed' && (
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm text-muted-foreground">
{state.error.message}
</div>
)}
<DialogFooter>
<Button
type="button"
onClick={() => onOpenChange(false)}
disabled={state.kind === 'syncing'}
>
{state.kind === 'syncing' ? 'Hämtar…' : 'Klar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function DoneBody({ summary }: { summary: SyncProgressSummary }) {
const requestedDays = daysBetween(summary.requested_from)
const returnedDays =
summary.returned_min_date && summary.returned_max_date
? daysBetween(summary.returned_min_date, new Date(summary.returned_max_date))
: 0
const wasTruncated = requestedDays - returnedDays > 7
return (
<div className="space-y-3 py-2">
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/30 p-4">
<CheckCircle2 className="h-6 w-6 shrink-0 text-foreground" />
<div className="text-sm">
<p>
<span className="tabular-nums font-medium">{summary.imported}</span> nya transaktioner
importerade.
</p>
{summary.returned_min_date && summary.returned_max_date && (
<p className="text-xs text-muted-foreground">
Datum: {summary.returned_min_date} {summary.returned_max_date}
</p>
)}
</div>
</div>
{wasTruncated && (
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Banken returnerade kortare historik än begärt. För äldre data, använd{' '}
<Link href="/import?mode=sie" className="text-foreground underline underline-offset-2">
SIE- eller bankfil-import
</Link>
.
</span>
</div>
)}
</div>
)
}
@@ -458,6 +458,7 @@ export const enableBankingExtension: Extension = {
const connection_id = body?.connection_id
const enabled_uids = body?.enabled_uids
const rawLookback = body?.initial_lookback_days
const rawLookbackFromDate = body?.initial_lookback_from_date
const account_mappings = body?.account_mappings
if (typeof connection_id !== 'string' || !connection_id) {
@@ -519,7 +520,35 @@ export const enableBankingExtension: Extension = {
// Default 120; clamp to [30, 365]. Ignored for selection edits.
// PSD2 obliges ASPSPs to ~90 days without fresh SCA, but many Swedish banks
// return more if asked — request 120 and accept whatever the bank gives back.
//
// If the client sent initial_lookback_from_date (preferred for fiscal-year-anchored
// backfills), derive days from that and reject future dates outright. Otherwise
// fall back to initial_lookback_days (or the 120-day default).
if (typeof rawLookbackFromDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(rawLookbackFromDate)) {
const from = new Date(rawLookbackFromDate + 'T00:00:00Z')
if (!Number.isFinite(from.getTime())) {
return NextResponse.json(
{ error: 'initial_lookback_from_date är inte ett giltigt datum.' },
{ status: 400 }
)
}
const diffMs = Date.now() - from.getTime()
const daysFromDate = Math.ceil(diffMs / (24 * 60 * 60 * 1000))
if (daysFromDate <= 0) {
return NextResponse.json(
{ error: 'initial_lookback_from_date måste ligga i det förflutna.' },
{ status: 400 }
)
}
}
const initialLookbackDays = (() => {
if (typeof rawLookbackFromDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(rawLookbackFromDate)) {
const from = new Date(rawLookbackFromDate + 'T00:00:00Z')
// Future/invalid dates already rejected above; days > 0 is guaranteed.
const diffMs = Date.now() - from.getTime()
const days = Math.ceil(diffMs / (24 * 60 * 60 * 1000))
return Math.min(365, Math.max(1, days))
}
const n = typeof rawLookback === 'number' && Number.isFinite(rawLookback) ? rawLookback : 120
return Math.min(365, Math.max(30, Math.round(n)))
})()
+114
View File
@@ -24,6 +24,26 @@ import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { CreateSupplierInvoiceSchema, BookInboxItemDirectlySchema } from '@/lib/api/schemas'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
import { simpleParser } from 'mailparser'
import path from 'node:path'
/**
* Defensive filename sanitisation for content arriving from .eml inner
* attachments and rejected-attachment metadata. The document-service already
* sanitises before storage paths are built (lib/core/documents/document-service.ts),
* so this is defense-in-depth: strip directory traversal sequences and exotic
* characters before they ever flow into DB columns or downstream consumers.
*/
function sanitiseFilename(raw: string | null | undefined, fallback: string): string {
const base = path.basename(String(raw ?? '').trim())
const cleaned = base.replace(/[^\w.-]/g, '_').slice(0, 200)
return cleaned || fallback
}
function sanitiseMime(raw: string | null | undefined): string {
const value = String(raw ?? '').trim().slice(0, 120)
return /^[\w./+-]+$/.test(value) ? value : 'application/octet-stream'
}
import type { InvoiceExtractionResult, InvoiceInboxItem, SupplierInvoice, SupplierInvoiceItem } from '@/types'
const MAX_FILE_SIZE = 10 * 1024 * 1024
@@ -1116,6 +1136,45 @@ export const invoiceInboxExtension: Extension = {
}
const results: Array<{ attachment_id: string; inbox_item_id?: string; error?: string; duplicate?: boolean }> = []
// Persist a "rejected" inbox row so the user has visibility into the drop.
// Without this, attachments that fail MIME validation vanish silently —
// a common Gmail "forward as attachment" foot-gun until we added .eml
// handling below.
const logRejection = async (
attachmentId: string,
attachmentName: string | null,
mime: string,
reason: string,
) => {
// attachment_name and mime are attacker-controlled (they come from the
// forwarded email headers); sanitise before they land in the JSONB
// raw_email_payload column so they can't surface as injection or
// oversized values when read back into the UI / audit trails.
try {
await serviceSupabase.from('invoice_inbox_items').insert({
company_id: inbox.company_id,
user_id: userId,
status: 'error',
source: 'email',
email_from: from,
email_subject: subject,
email_received_at: created_at,
email_body_text: bodyText,
resend_email_id: email_id,
resend_attachment_id: attachmentId,
error_message: reason.slice(0, 500),
raw_email_payload: {
messageId: message_id,
attachment_name: sanitiseFilename(attachmentName, 'unknown'),
mime: sanitiseMime(mime),
},
})
} catch (insertErr) {
console.error('[invoice-inbox/inbound] Failed to log rejected attachment:', insertErr)
}
}
for (const att of attachments) {
try {
const { data: existing } = await serviceSupabase
@@ -1130,11 +1189,66 @@ export const invoiceInboxExtension: Extension = {
}
const download = await fetchInboundAttachment(email_id, att.id)
// Gmail "Forward as attachment" wraps the original email as message/rfc822.
// Unwrap it and process the inner attachments as if they had arrived
// directly, carrying the inner email's subject/from into our metadata.
if (download.contentType === 'message/rfc822') {
const parsed = await simpleParser(Buffer.from(download.buffer))
const innerAttachments = parsed.attachments || []
if (innerAttachments.length === 0) {
await logRejection(att.id, download.filename, download.contentType, 'Det vidarebefordrade meddelandet innehöll inga bilagor')
results.push({ attachment_id: att.id, error: 'eml_no_inner_attachments' })
continue
}
const innerFrom = parsed.from?.text || from
const innerSubject = parsed.subject || subject
for (let i = 0; i < innerAttachments.length; i++) {
const inner = innerAttachments[i]
const innerType = sanitiseMime(inner.contentType)
const innerName = sanitiseFilename(inner.filename, `attachment-${i}`)
const innerBuffer = inner.content
if (!innerBuffer) continue
const innerId = `${att.id}#${i}`
if (!UPLOAD_ALLOWED_MIME_TYPES.has(innerType)) {
await logRejection(innerId, innerName, innerType, `Avvisad bilaga från vidarebefordrat mejl: filtypen ${innerType} stöds inte`)
results.push({ attachment_id: innerId, error: `Unsupported type ${innerType}` })
continue
}
if (innerBuffer.byteLength > MAX_FILE_SIZE) {
await logRejection(innerId, innerName, innerType, 'Bilagan i det vidarebefordrade mejlet är för stor')
results.push({ attachment_id: innerId, error: 'Inner attachment too large' })
continue
}
const innerArrayBuffer = new Uint8Array(innerBuffer).buffer
const innerResult = await uploadAndExtract(
serviceSupabase,
userId,
inbox.company_id,
{ name: innerName, buffer: innerArrayBuffer, type: innerType },
'email',
{
from: innerFrom,
subject: innerSubject,
receivedAt: created_at,
messageId: message_id,
bodyText,
resendEmailId: email_id,
resendAttachmentId: innerId,
}
)
results.push({ attachment_id: innerId, inbox_item_id: innerResult.inbox_item_id })
}
continue
}
if (!UPLOAD_ALLOWED_MIME_TYPES.has(download.contentType)) {
await logRejection(att.id, download.filename, download.contentType, `Avvisad: filtypen ${download.contentType} stöds inte`)
results.push({ attachment_id: att.id, error: `Unsupported type ${download.contentType}` })
continue
}
if (download.buffer.byteLength > MAX_FILE_SIZE) {
await logRejection(att.id, download.filename, download.contentType, 'Bilagan är för stor')
results.push({ attachment_id: att.id, error: 'Attachment too large' })
continue
}
+8 -3
View File
@@ -3407,7 +3407,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_match_transaction_to_invoice',
description: 'Match a bank transaction (income, amount>0) to a customer invoice. Stages for approval. Supports partial payments and auto-storno of prior categorization.',
description: 'Match a bank transaction (income, amount>0) to a customer invoice. Confirm tx date/amount and invoice number/customer match before staging — preview mirrors what you pass. Supports partial payments and auto-storno of prior categorization.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -3432,7 +3432,7 @@ export const tools: McpTool[] = [
// Validate both exist and are matchable
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('id, description, merchant_name, amount, currency, invoice_id')
.select('id, description, merchant_name, amount, currency, date, invoice_id')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
@@ -3462,9 +3462,14 @@ export const tools: McpTool[] = [
transaction_description: txDesc,
transaction_amount: transaction.amount,
transaction_currency: transaction.currency,
// Surface both dates so the reviewer can spot a material mismatch
// between the payment and the invoice it's being matched against
// before approving.
transaction_date: transaction.date,
invoice_number: invoice.invoice_number,
invoice_total: invoice.total,
invoice_currency: invoice.currency,
invoice_date: invoice.invoice_date,
customer_name: (invoice.customer as Record<string, unknown>)?.name as string,
},
actor
@@ -4369,7 +4374,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_attach_document_to_transaction',
description: 'Stage attaching a document to a bank transaction. The document is pinned to the tx; when the tx is later categorized the link propagates to the journal entry. Stages for approval.',
description: 'Stage attaching a document to a bank transaction. Verify tx (date, amount, counterparty) and document (filename, vendor, amount) match first — the preview shown to the human reviewer mirrors what you pass here. Stages for approval.',
inputSchema: {
type: 'object',
additionalProperties: false,
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import { getCurrentFiscalYearStart, getPreviousFiscalYearStart, daysBetween } from '../fiscal-year'
describe('getCurrentFiscalYearStart', () => {
it('returns Jan 1 of current year for calendar-year fiscals', () => {
expect(getCurrentFiscalYearStart({ fiscal_year_start_month: 1, entity_type: 'aktiebolag' }, new Date('2026-05-21'))).toBe('2026-01-01')
})
it('returns previous-year July 1 when today is before July with a July fiscal start', () => {
expect(getCurrentFiscalYearStart({ fiscal_year_start_month: 7, entity_type: 'aktiebolag' }, new Date('2026-05-21'))).toBe('2025-07-01')
})
it('returns current-year July 1 when today is after July with a July fiscal start', () => {
expect(getCurrentFiscalYearStart({ fiscal_year_start_month: 7, entity_type: 'aktiebolag' }, new Date('2026-09-15'))).toBe('2026-07-01')
})
it('locks enskild firma to calendar year even with a non-Jan setting', () => {
expect(getCurrentFiscalYearStart({ fiscal_year_start_month: 7, entity_type: 'enskild_firma' }, new Date('2026-05-21'))).toBe('2026-01-01')
})
it('defaults to Jan 1 when settings are missing', () => {
expect(getCurrentFiscalYearStart(null, new Date('2026-05-21'))).toBe('2026-01-01')
})
})
describe('getPreviousFiscalYearStart', () => {
it('returns prior calendar year for calendar fiscals', () => {
expect(getPreviousFiscalYearStart({ fiscal_year_start_month: 1, entity_type: 'aktiebolag' }, new Date('2026-05-21'))).toBe('2025-01-01')
})
it('handles July fiscal start correctly', () => {
expect(getPreviousFiscalYearStart({ fiscal_year_start_month: 7, entity_type: 'aktiebolag' }, new Date('2026-05-21'))).toBe('2024-07-01')
})
})
describe('daysBetween', () => {
it('counts whole days between two dates', () => {
expect(daysBetween('2026-01-01', '2026-01-11')).toBe(10)
})
it('returns 0 when from > to (no negative)', () => {
expect(daysBetween('2026-05-21', '2026-05-01')).toBe(0)
})
})
+51
View File
@@ -0,0 +1,51 @@
import type { CompanySettings } from '@/types'
/**
* Return the ISO date (YYYY-MM-DD) for the start of the fiscal year that
* contains `today`, given the company's fiscal_year_start_month setting.
*
* Enskild firma is locked to calendar year per BFL. We assume `entity_type`
* reflects the company's *current* tax-year status — if an enskild firma is
* mid-conversion to an AB, callers should re-resolve after the conversion
* lands rather than backfilling from a stale anchor.
*/
export function getCurrentFiscalYearStart(
settings: Pick<CompanySettings, 'fiscal_year_start_month' | 'entity_type'> | null | undefined,
today: Date = new Date(),
): string {
let startMonth = settings?.fiscal_year_start_month || 1
if (settings?.entity_type === 'enskild_firma') startMonth = 1
const year = today.getMonth() + 1 >= startMonth ? today.getFullYear() : today.getFullYear() - 1
return `${year}-${String(startMonth).padStart(2, '0')}-01`
}
/**
* Return the ISO date for the start of the PREVIOUS fiscal year — useful when
* the user wants to backfill the year that just closed.
*/
export function getPreviousFiscalYearStart(
settings: Pick<CompanySettings, 'fiscal_year_start_month' | 'entity_type'> | null | undefined,
today: Date = new Date(),
): string {
let startMonth = settings?.fiscal_year_start_month || 1
if (settings?.entity_type === 'enskild_firma') startMonth = 1
const currentYearStart = today.getMonth() + 1 >= startMonth
? today.getFullYear()
: today.getFullYear() - 1
return `${currentYearStart - 1}-${String(startMonth).padStart(2, '0')}-01`
}
export function daysBetween(from: string | Date, to: string | Date = new Date()): number {
// Bare ISO date strings ("2026-01-01") are parsed as UTC midnight, but
// `new Date()` is local wall-clock time. Mixing the two means timezones east
// of UTC can be one day past UTC midnight while still on the prior local
// date, producing off-by-one drift. Pin both string operands to UTC so the
// math is timezone-independent. Date operands (rare; tests + future callers)
// are trusted as-is.
const parse = (v: string | Date) =>
typeof v === 'string' ? new Date(v + 'T00:00:00Z') : v
const diff = parse(to).getTime() - parse(from).getTime()
return Math.max(0, Math.ceil(diff / (24 * 60 * 60 * 1000)))
}
@@ -161,6 +161,59 @@ describe('parseOpeningBalanceFile', () => {
expect(result.warnings.some((w) => w.includes('1930'))).toBe(true)
})
it('preserves validation_errors from every duplicated row when merging', async () => {
const XLSX = await import('xlsx')
const { parseOpeningBalanceFile } = await import('../parser')
const wb = XLSX.utils.book_new()
// Two rows for account 3001 (a P&L class warning fires on each) plus a
// balance-sheet row to keep totals reachable. Both copies of 3001 should
// contribute their warning into the merged row's validation_errors so it
// isn't silently lost.
const data = [
['Kontonr', 'Debet', 'Kredit'],
['3001', 100, 0],
['3001', 200, 0],
['2099', 0, 300],
]
const ws = XLSX.utils.aoa_to_sheet(data)
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1')
const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' })
const result = parseOpeningBalanceFile(buffer, 'test.xlsx')
const merged = result.rows.find((r) => r.account_number === '3001')
expect(merged).toBeDefined()
expect(merged!.debit_amount).toBe(300)
expect(merged!.validation_errors.length).toBeGreaterThan(0)
expect(merged!.validation_errors.some((e) => e.includes('resultatkonto'))).toBe(true)
})
it('merges duplicates that differ only in whitespace / formatting', async () => {
const XLSX = await import('xlsx')
const { parseOpeningBalanceFile } = await import('../parser')
const wb = XLSX.utils.book_new()
// Same account written three ways: plain, padded with NBSP, with a dot
const data = [
['Kontonr', 'Debet', 'Kredit'],
['1930', 10000, 0],
[' 1930 ', 20000, 0],
['1.930', 30000, 0],
['2099', 0, 60000],
]
const ws = XLSX.utils.aoa_to_sheet(data)
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1')
const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' })
const result = parseOpeningBalanceFile(buffer, 'test.xlsx')
const matches = result.rows.filter((r) => r.account_number === '1930')
expect(matches.length).toBe(1)
expect(matches[0].debit_amount).toBe(60000)
expect(result.rows.length).toBe(2)
})
it('skips zero-amount rows', async () => {
const XLSX = await import('xlsx')
const { parseOpeningBalanceFile } = await import('../parser')
+21 -5
View File
@@ -102,13 +102,15 @@ export function parseOpeningBalanceFile(
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i]
const rawAccountNumber = String(row[columns.account_number_col] || '').trim()
const rawAccountNumber = String(row[columns.account_number_col] || '')
.replace(/[ ]/g, '') // strip NBSP and zero-width chars
.trim()
// Skip empty rows
if (!rawAccountNumber) continue
// Clean account number (remove leading zeros, spaces, dashes)
const accountNumber = rawAccountNumber.replace(/[^0-9]/g, '')
// Clean account number — strip every non-digit (whitespace, dots, dashes, letters)
const accountNumber = rawAccountNumber.replace(/\D/g, '')
// Skip non-4-digit account numbers (likely header/total rows)
if (!/^\d{4}$/.test(accountNumber)) {
@@ -186,15 +188,29 @@ export function parseOpeningBalanceFile(
})
}
// Merge duplicate accounts
// Merge duplicate accounts — keyed on the already-normalized account_number.
// Union validation_errors across rows so a warning that fires on row 5 (e.g.
// BAS-class mismatch) isn't silently dropped because row 2 of the same
// account had no error. Suppressed validation issues on IB-feeding data
// would risk a misclassification propagating into the ledger.
const mergedMap = new Map<string, ParsedOpeningBalanceRow>()
for (const row of rows) {
const existing = mergedMap.get(row.account_number)
if (existing) {
existing.debit_amount = Math.round((existing.debit_amount + row.debit_amount) * 100) / 100
existing.credit_amount = Math.round((existing.credit_amount + row.credit_amount) * 100) / 100
if (!existing.account_name && row.account_name) {
existing.account_name = row.account_name
}
if (row.validation_errors?.length) {
const seen = new Set(existing.validation_errors)
for (const err of row.validation_errors) {
if (!seen.has(err)) existing.validation_errors.push(err)
}
existing.is_valid = existing.is_valid && row.is_valid
}
} else {
mergedMap.set(row.account_number, { ...row })
mergedMap.set(row.account_number, { ...row, validation_errors: [...row.validation_errors] })
}
}
const mergedRows = Array.from(mergedMap.values())
+93 -12
View File
@@ -38,6 +38,7 @@
"ics": "^3.8.1",
"jszip": "^3.10.1",
"lucide-react": "^0.563.0",
"mailparser": "^3.9.8",
"next": "16.1.5",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
@@ -57,6 +58,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/mailparser": "^3.4.6",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
@@ -7584,6 +7586,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/mailparser": {
"version": "3.4.6",
"resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz",
"integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"iconv-lite": "^0.6.3"
}
},
"node_modules/@types/mailparser/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -11231,9 +11257,9 @@
}
},
"node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -12479,23 +12505,35 @@
}
},
"node_modules/mailparser": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.1.tgz",
"integrity": "sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA==",
"version": "3.9.8",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz",
"integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"he": "1.2.0",
"html-to-text": "9.0.5",
"iconv-lite": "0.7.0",
"libmime": "5.3.7",
"iconv-lite": "0.7.2",
"libmime": "5.3.8",
"linkify-it": "5.0.0",
"nodemailer": "7.0.11",
"nodemailer": "8.0.5",
"punycode.js": "2.3.1",
"tlds": "1.261.0"
}
},
"node_modules/mailparser/node_modules/libmime": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -13320,9 +13358,9 @@
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==",
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -14373,6 +14411,49 @@
}
}
},
"node_modules/resend/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/resend/node_modules/mailparser": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.1.tgz",
"integrity": "sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"he": "1.2.0",
"html-to-text": "9.0.5",
"iconv-lite": "0.7.0",
"libmime": "5.3.7",
"linkify-it": "5.0.0",
"nodemailer": "7.0.11",
"punycode.js": "2.3.1",
"tlds": "1.261.0"
}
},
"node_modules/resend/node_modules/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/resend/node_modules/svix": {
"version": "1.84.1",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz",
+2
View File
@@ -44,6 +44,7 @@
"ics": "^3.8.1",
"jszip": "^3.10.1",
"lucide-react": "^0.563.0",
"mailparser": "^3.9.8",
"next": "16.1.5",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
@@ -63,6 +64,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/mailparser": "^3.4.6",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",