Files
accounted/components/transactions/SkattekontoInboxCard.tsx
T
Mattsson 32d9978f1b Fix/chrome pdf preview csp (#572)
* feat: add option to exclude year-end closing entries in SIE export and related reports

* delete docs

* fix: allow Chrome's PDF viewer in verifikat document preview

The /api/documents/:id/inline route shipped with
`object-src 'none'` in its CSP, which blocked Chrome's built-in PDF
viewer (it renders inline PDFs via an internal <embed>). Users on
Chrome saw "Det här innehållet har blockerats" when expanding a PDF
attachment in the bookkeeping view; Firefox (PDF.js) and Edge (own
viewer) were unaffected, and JPGs worked because <img> isn't subject
to object-src.

Drops the CSP for this route to the minimum needed for embeddability:
`frame-ancestors 'self'`. X-Content-Type-Options: nosniff plus the
fixed Content-Type from the handler already block MIME confusion;
X-Frame-Options: SAMEORIGIN + frame-ancestors still block clickjacking.

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

* feat(auth): add webmail deep link to email confirmation screens

Mirrors Stripe's signup UX: after asking the user to verify their email,
detect their webmail provider from the domain and show a button that
opens the inbox in a new tab. Gmail gets a from:<sender> search
pre-populated; Outlook/Yahoo/iCloud/Proton open the inbox directly.
Unknown / custom domains fall back to the existing copy.

Sender address is configurable via NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM
(default noreply@gnubok.se) so white-label installs can match their
Supabase Auth SMTP config.

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

* fix(auth): unblock first-time password set for BankID users with MFA

Supabase rejects updateUser({password}) and mfa.unenroll with "AAL2 session
is required" whenever a TOTP factor is enrolled. BankID magic-link logins
produce AAL1, and middleware skips MFA enforcement for bankid_linked users,
so they had no path to AAL2 — leaving them unable to set a backup password
or disable MFA without going through the email-recovery escape hatch.

- /api/account/password: branch on app_metadata.has_password. First-time set
  writes via service.auth.admin.updateUserById (no existing credential to
  protect, AAL2 guard does not apply). Change-password keeps the user-session
  updateUser so AAL2 still fires for credential rotation.
- /mfa/verify: accept a safeReturnTo query param and route there after
  successful verify, so step-up flows can land back where they came from.
- SecuritySettings: detect the AAL2 error from both change-password and
  mfa.unenroll and redirect through /mfa/verify?returnTo=/settings/account
  instead of toasting a dead-end error.

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

* Add tests and rounding utility for öre precision in bokslut calculations

- Implemented `roundOre` function for rounding SEK amounts to two decimal places, ensuring consistent monetary calculations.
- Introduced `ORE_TOLERANCE` constant for comparing rounded amounts, facilitating invariant checks in financial entries.
- Created comprehensive tests for `roundOre`, covering typical cases, edge cases, and idempotency.
- Added year-end invariants tests to verify database-level guarantees for closing entries, ensuring they balance to the öre and reject discrepancies.
- Developed end-to-end tests for the dispositions chain, validating the correctness of calculations across various scenarios.

* fix: update PDF rendering to remove Swish QR code generation and set default to disable Swish visibility

* fix: enhance security by rejecting data URIs in safeReturnTo function tests

* fix: improve rounding logic in roundOre function and add customer_type migration

* fix: add customer_type column to customers and enforce CHECK constraint

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:29:41 +02:00

172 lines
5.4 KiB
TypeScript

'use client'
import { useTranslations } from 'next-intl'
import { motion } from 'framer-motion'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
DataListRow,
DataListPrimary,
DataListMeta,
DataListMetaSeparator,
} from '@/components/ui/data-list'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { AlertCircle, ArrowUpRight, ArrowDownRight, Landmark, Link2, Loader2 } from 'lucide-react'
import type {
SkattekontoMatchSuggestion,
StoredSkattekontoTransaction,
} from '@/types/skatteverket'
/**
* Skattekonto-rad in the /transactions inbox.
*
* Mirrors the visual rhythm of TransactionInboxCard. The Skatteverket badge is
* the cue that this row is fundamentally different from a bank tx — different
* counter-account (1630 vs 1930), different categorization rules.
*/
export default function SkattekontoInboxCard({
row,
matchSuggestion,
processing,
onBokfor,
onMatch,
onAnimationComplete,
}: {
row: StoredSkattekontoTransaction
matchSuggestion?: SkattekontoMatchSuggestion | null
processing: boolean
onBokfor: (row: StoredSkattekontoTransaction) => void
onMatch: (row: StoredSkattekontoTransaction) => void
onAnimationComplete?: (id: string) => void
}) {
const t = useTranslations('tx_skattekonto_card')
const amount = Number(row.belopp_skatteverket)
const isIncome = amount > 0
const duplicateLabel =
matchSuggestion?.voucher_series && matchSuggestion?.voucher_number
? t('duplicate_title_with_voucher', {
label: formatVoucher({
voucher_series: matchSuggestion.voucher_series,
voucher_number: matchSuggestion.voucher_number,
}),
})
: t('duplicate_title_draft')
return (
<motion.div
layout
initial={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.97, x: -16 }}
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
onAnimationComplete={(definition) => {
if (typeof definition === 'object' && 'opacity' in definition && definition.opacity === 0) {
onAnimationComplete?.(row.id)
}
}}
>
<DataListRow
leading={
<span
className={cn(
'inline-flex h-5 w-5 items-center justify-center',
isIncome ? 'text-success' : 'text-foreground/60'
)}
aria-hidden
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
)}
</span>
}
trailing={
<>
<div className="text-right">
<p
className={cn(
'font-medium tabular-nums leading-none',
isIncome && 'text-success'
)}
>
{isIncome ? '+' : ''}
{formatCurrency(amount)}
</p>
</div>
{matchSuggestion ? (
<>
<Button
size="sm"
variant="default"
className="h-8 px-3 text-xs"
onClick={() => onMatch(row)}
disabled={processing}
>
<Link2 className="mr-1 h-3 w-3" />
{t('link_to_voucher')}
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 px-3 text-xs"
onClick={() => onBokfor(row)}
disabled={processing}
>
{processing && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
{t('book_anyway')}
</Button>
</>
) : (
<>
<Button
size="sm"
variant="default"
className="h-8 px-3 text-xs"
onClick={() => onBokfor(row)}
disabled={processing}
>
{processing && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
{t('book')}
</Button>
<Button
size="sm"
variant="outline"
className="h-8 px-3 text-xs"
onClick={() => onMatch(row)}
disabled={processing}
>
<Link2 className="mr-1 h-3 w-3" />
{t('match_to_voucher')}
</Button>
</>
)}
</>
}
>
<div className="flex items-center gap-1.5 min-w-0">
<DataListPrimary>{row.transaktionstext}</DataListPrimary>
</div>
<DataListMeta>
<span className="tabular-nums">{formatDate(row.transaktionsdatum)}</span>
<DataListMetaSeparator />
<Badge variant="outline" className="h-4 gap-1 px-1.5 py-0 text-[10px]">
<Landmark className="h-3 w-3" />
{t('skv_badge')}
</Badge>
{matchSuggestion && (
<>
<DataListMetaSeparator />
<Badge variant="warning" className="h-4 gap-1 px-1.5 py-0 text-[10px]">
<AlertCircle className="h-3 w-3" />
{duplicateLabel}
</Badge>
</>
)}
</DataListMeta>
</DataListRow>
</motion.div>
)
}