Files
accounted/components/transactions/TransactionHistoryList.tsx
T
MattssonandClaude Fable 5 d840257c0c Add/stripe connect transactions (#1139)
* fix(mcp-oauth): allow ChatGPT connector callbacks and resume OAuth after login

Add chatgpt.com/connector/oauth/* (per-instance) and the legacy
chatgpt.com/connector_platform_oauth_redirect to the built-in OAuth
redirect allowlist so ChatGPT MCP connectors can register and authorize.

Fix the login page dropping the ?next= destination: an OAuth-initiated
visit that required login previously ended on the dashboard and the
connection flow silently died. Login now resumes to the sanitized next
path (hard navigation, since the consent page is route-handler HTML),
carries it through the MFA step-up as returnTo, and /mfa/verify
hard-navigates for /api/ destinations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): dedup incoming feed rows against booked hand-entered twins

Users who bookkeep via MCP/chat first and connect their bank afterwards got
the same movement twice: the synced row's external_id lives in a different
namespace, the free-form manual title never text-bridges the bank's raw
string, and the cross-channel mirror deliberately excluded manual/mcp rows.

Extend the mirror with a booked-hand-entered track: an incoming feed row is
skipped when a BOOKED manual/mcp row shares its (date, ore) bucket count-
symmetrically. Gates beyond the feed-vs-feed mirror: stored row must be
booked (staged rows never consume an import), currencies must not contradict
(bucket key is date+ore only), the cash-account guard applies to the count
exactly as to consumption, and symmetry uses the Layer-1-unmatched incoming
count so an already-stored row cannot inflate it. Consumption stamps the
batch cash_account_id onto an account-unbound hand row, so one hand row can
never consume feed rows on other accounts in later syncs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): inline verifikat rattelse (strike lines + text/date edit)

Second sanctioned correction track under BFL 5 kap 5/9 pp, Fortnox-style:
strike lines inside a posted verifikat with replacements in the same
voucher, and correct description/entry_date without an andringsverifikat.
Envelope: posted entries, open unlocked periods, company lock date,
same-period date moves, structural/FX/doc-linked lines excluded, and a
reconciliation guard preserving per-account net on bank/reskontra sides of
externally linked entries. Every rattelse writes an immutable who/when row
(journal_entry_rattelse_log, WORM, archived as rakenskapsinformation) and
struck originals render struck-through in the verifikat; list rows and the
detail header carry a Rattad marker. CLAUDE.md hard rule 1 and the
swedish-accounting-compliance skill are amended to state the two-track
rule. Staging carries the DDL; prod gets it on merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: live saldo in booking form, prior-year window comparison, hideable assistant FAB

- Manual journal entry: saldo column now shows before -> after computed
  from the typed debit/credit amounts (direction feedback while booking)
- Resultatrapport: a narrowed date range now compares against the same
  window shifted one year back (#862), merged across fiscal periods for
  brutet rakenskapsar; P&L rows report window activity instead of
  rolled-forward YTD closing
- Assistant FAB: per-user hide toggle (user_preferences.hide_assistant_fab,
  settings > assistant), sidebar entry unaffected; collapsed sessions keep
  their reopen handle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(stripe): sync balance transactions as a bank feed on 1686

Import the connected Stripe balance into the transactions inbox, opt-in
per connection (transaction_sync_enabled on stripe_connections):

- Balance transactions map to feed rows with the two-row gross+fee split
  and frozen external_id formats (stripe_{acct}_{txn} / _fee), dated on
  created, bound to a provisioned "Stripe-saldo" cash account on 1686 so
  booking settles against the clearing account by construction.
- Double-booking protection: settled payment-link charges import
  pre-linked to their settlement entry; payout rows import pre-linked to
  the payout entry; processPayoutPaidEvent claims the payout's fee rows
  at booking time (linkPayoutFeedRows, idempotent from both directions).
- Cursor last_balance_txn_synced_at with 24h overlap; first run
  backfills 90 days floored at the day after the company lock date.
- Nightly cron /api/extensions/stripe/transactions/cron (03:30),
  transaction-sync toggle route, "Synka nu" covers both feeds, settings
  panel toggle with last-synced/backfill note, sv+en strings.
- Migration 20260723200000 (applied to staging).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): offer match-to-voucher on unbooked history rows

Unbooked transactions with is_business already set (e.g. left behind when
a voucher was removed without a full uncategorize) land in the history
list instead of the inbox, where the match-against-existing-voucher
action did not exist, leaving them with no path back to voucher
matching. Add the same menu item to the history list for unbooked rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(transactions): enhance ownership checks and error handling in journal entry routes

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 01:16:20 +02:00

534 lines
20 KiB
TypeScript

'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { DataListEmpty } from '@/components/ui/data-list'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { ContextPicker } from '@/components/common/ContextPicker'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { isImportedTransaction } from '@/lib/transactions/origin'
import { getCategoryDisplayName } from '@/lib/tax/expense-warnings'
import {
ArrowLeftRight,
FileText,
Landmark,
Link2,
FileSearch,
Loader2,
MoreHorizontal,
Paperclip,
Trash2,
} from 'lucide-react'
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { JeUnderlagStatus } from '@/lib/transactions/underlag-status'
import type { TransactionWithInvoice, HistoryFilter, SourceFilter } from './transaction-types'
import type {
SkattekontoTransactionWithSuggestion,
StoredSkattekontoTransaction,
} from '@/types/skatteverket'
type HistoryRow =
| { source: 'bank'; date: string; data: TransactionWithInvoice }
| { source: 'skatteverket'; date: string; data: SkattekontoTransactionWithSuggestion }
interface TransactionHistoryListProps {
transactions: TransactionWithInvoice[]
skvRows?: SkattekontoTransactionWithSuggestion[]
searchTerm?: string
sourceFilter: SourceFilter
onSourceFilterChange: (sourceFilter: SourceFilter) => void
/** Underlag status per journal_entry_id (computeJeUnderlagStatus): drives
* the per-row "Underlag"/"Underlag saknas" badges on booked rows. */
jeUnderlagStatus?: Record<string, JeUnderlagStatus>
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
/** Open the attach-underlag dialog (pin an inbox doc / fresh upload). */
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
/** Open the match-against-existing-voucher dialog. Unbooked rows can end up
* here (not in the inbox) when is_business is already set, e.g. after a
* voucher was removed without a full uncategorize; without this item such
* rows have no path back to voucher matching. */
onOpenMatchVoucher?: (transaction: TransactionWithInvoice) => void
onDelete?: (id: string) => void
onSkvBokfor?: (row: StoredSkattekontoTransaction) => void
onSkvMatch?: (row: StoredSkattekontoTransaction) => void
hasMore?: boolean
isLoadingMore?: boolean
onLoadMore?: () => void
}
/**
* "Alla" view: every transaction (booked and not), rendered in the same
* dry-table language as the inbox so the two modes read as one page.
* Bokförd is the normal state (muted text); Ej bokförd is the exception chip.
*/
export default function TransactionHistoryList({
transactions,
skvRows = [],
searchTerm = '',
sourceFilter,
onSourceFilterChange,
jeUnderlagStatus,
onOpenMatchDialog,
onOpenCategoryDialog,
onOpenAttachDocument,
onOpenMatchVoucher,
onDelete,
onSkvBokfor,
onSkvMatch,
hasMore,
isLoadingMore,
onLoadMore,
}: TransactionHistoryListProps) {
const t = useTranslations('tx_history')
const [filter, setFilter] = useState<HistoryFilter>('all')
// The bank/private filter doesn't apply to SKV rows: they have no
// is_business flag. So when the filter is 'business' or 'private' we
// implicitly hide SKV.
const bankFiltered = transactions.filter((tx) => {
const matchesSearch = tx.description.toLowerCase().includes(searchTerm.toLowerCase())
const matchesFilter =
filter === 'all' ||
(filter === 'business' && tx.is_business === true) ||
(filter === 'private' && tx.is_business === false)
return matchesSearch && matchesFilter
})
const skvFiltered = skvRows.filter((r) => {
if (filter !== 'all') return false
return r.transaktionstext.toLowerCase().includes(searchTerm.toLowerCase())
})
const merged: HistoryRow[] = []
if (sourceFilter !== 'skatteverket') {
for (const tx of bankFiltered) {
merged.push({ source: 'bank', date: tx.date, data: tx })
}
}
if (sourceFilter !== 'bank') {
for (const r of skvFiltered) {
merged.push({ source: 'skatteverket', date: r.transaktionsdatum, data: r })
}
}
merged.sort((a, b) => {
if (a.date !== b.date) return b.date.localeCompare(a.date)
return a.source === 'bank' ? -1 : 1
})
const showSourceFilter = sourceFilter !== 'all' || (skvRows.length > 0 && transactions.length > 0)
const filtered = merged
const FILTERS: Array<{ key: HistoryFilter; labelKey: string }> = [
{ key: 'all', labelKey: 'filter_all' },
{ key: 'business', labelKey: 'filter_business' },
{ key: 'private', labelKey: 'filter_private' },
]
return (
<div className="space-y-4">
{/* Business/private seg + source chip, mirroring the inbox toolbar. */}
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
{FILTERS.map(({ key, labelKey }) => (
<button
key={key}
type="button"
role="tab"
aria-selected={filter === key}
onClick={() => setFilter(key)}
className={cn(
'rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
filter === key
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t(labelKey)}
</button>
))}
</div>
{showSourceFilter && (
<div className="ml-auto">
<ContextPicker
value={sourceFilter}
onChange={(id) => onSourceFilterChange(id as SourceFilter)}
triggerLabel={
sourceFilter === 'all'
? t('source_all')
: sourceFilter === 'bank'
? t('source_bank')
: t('source_skatteverket')
}
items={[
{ id: 'all', label: t('source_all') },
{ id: 'bank', label: t('source_bank') },
{ id: 'skatteverket', label: t('source_skatteverket') },
]}
/>
</div>
)}
</div>
{filtered.length === 0 ? (
<DataListEmpty
icon={<ArrowLeftRight className="h-6 w-6" />}
title={t('empty_title')}
description={searchTerm ? t('empty_search') : t('empty_filter')}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></th>
<th className={TH_CLASS}>{t('th_date')}</th>
<th className={cn(TH_CLASS, 'w-full')}>{t('th_description')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_amount')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_status')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{filtered.map((item) =>
item.source === 'bank' ? (
<BankHistoryRow
key={`bank-${item.data.id}`}
transaction={item.data}
jeUnderlagStatus={jeUnderlagStatus}
onOpenMatchDialog={onOpenMatchDialog}
onOpenCategoryDialog={onOpenCategoryDialog}
onOpenAttachDocument={onOpenAttachDocument}
onOpenMatchVoucher={onOpenMatchVoucher}
onDelete={onDelete}
/>
) : (
<SkattekontoHistoryRow
key={`skv-${item.data.id}`}
row={item.data}
onBokfor={onSkvBokfor}
onMatch={onSkvMatch}
/>
),
)}
</tbody>
</table>
</div>
)}
{hasMore && onLoadMore && !searchTerm && filtered.length > 0 && (
<div className="flex justify-center">
<Button variant="outline" onClick={onLoadMore} disabled={isLoadingMore}>
{isLoadingMore ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('loading_more')}
</>
) : (
t('load_more')
)}
</Button>
</div>
)}
</div>
)
}
function BankHistoryRow({
transaction,
jeUnderlagStatus,
onOpenMatchDialog,
onOpenCategoryDialog,
onOpenAttachDocument,
onOpenMatchVoucher,
onDelete,
}: {
transaction: TransactionWithInvoice
jeUnderlagStatus?: Record<string, JeUnderlagStatus>
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
onOpenMatchVoucher?: (transaction: TransactionWithInvoice) => void
onDelete?: (id: string) => void
}) {
const t = useTranslations('tx_history')
// Viewers must not see write affordances. CorrectionAffordance opens a
// dialog that stages a storno + correction journal entry; the API path
// already 403s for viewers but rendering the trigger creates a confusing
// dead end.
const { canWrite } = useCanWrite()
const isIncome = transaction.amount > 0
const isBooked = !!transaction.journal_entry_id
// Only user-created rows are deletable; imported (bank sync / CSV) rows are
// ignore-only. Mirrors the server guard in DELETE /api/transactions/[id].
const canDelete = !isBooked && !isImportedTransaction(transaction)
const isLinkedToInvoice = !!transaction.invoice_id
const hasInvoiceMatch =
!isLinkedToInvoice && !!transaction.potential_invoice && !isBooked
// Underlag status: see computeJeUnderlagStatus. Unknown/not-yet-loaded JE
// renders neither badge (no false "saknas" flash while the enrichment loads).
const jeStatus = transaction.journal_entry_id
? jeUnderlagStatus?.[transaction.journal_entry_id]
: undefined
const hasJeDoc = jeStatus === 'has'
const missingUnderlag = isBooked && !transaction.document_id && jeStatus === 'missing'
const showAttachItem = canWrite && !!onOpenAttachDocument
// Same affordance as the inbox card: an unbooked row may need to be linked
// to an already-booked voucher (e.g. the other leg of a transfer).
const showMatchVoucherItem = canWrite && !isBooked && !!onOpenMatchVoucher
const showOverflowMenu =
hasInvoiceMatch || (canDelete && !!onDelete) || (isBooked && canWrite) || showAttachItem || showMatchVoucherItem
const isPrivate = transaction.is_business === false
const categoryLabel =
transaction.is_business === true &&
!(transaction.category === 'uncategorized' && transaction.journal_entry_id)
? getCategoryDisplayName(transaction.category)
: null
return (
<tr
data-tx-id={transaction.id}
className="group transition-colors duration-150 hover:bg-secondary/35"
>
<td className={cn(TD_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(transaction.date)}
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{transaction.description}</span>
<TransactionAttachmentIndicator
documentId={transaction.document_id}
journalEntryId={transaction.journal_entry_id}
hasJeDoc={hasJeDoc}
missing={missingUnderlag}
onAttach={
showAttachItem ? () => onOpenAttachDocument!(transaction) : undefined
}
/>
{categoryLabel && (
<span className="hidden shrink-0 text-xs text-muted-foreground lg:inline">
{categoryLabel}
</span>
)}
{isLinkedToInvoice && (
<span className="hidden shrink-0 items-center gap-1 text-xs text-muted-foreground md:inline-flex">
<Link2 className="h-3 w-3" />
{t('linked_to_invoice')}
</span>
)}
{hasInvoiceMatch && (
<Badge variant="secondary" className="hidden shrink-0 gap-1 font-normal md:inline-flex">
<FileText className="h-3 w-3" />
{t('possible_match_invoice', {
number: transaction.potential_invoice!.invoice_number ?? '',
})}
</Badge>
)}
</span>
</td>
<td
className={cn(
TD_CLASS,
'whitespace-nowrap text-right tabular-nums rr-mask',
isIncome && 'text-success',
)}
title={
transaction.currency !== 'SEK' && transaction.amount_sek != null
? formatCurrency(transaction.amount_sek)
: undefined
}
>
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[9px]')}>
<span className="inline-flex items-center justify-end gap-2">
{isBooked ? (
<>
<span className="text-muted-foreground">
{isPrivate ? t('private_badge') : t('posted')}
</span>
<Link
href={`/bookkeeping/${transaction.journal_entry_id}`}
className={QUIET_LINK_CLASS}
>
{t('view_voucher_short')}
</Link>
</>
) : isPrivate ? (
<span className="text-muted-foreground">{t('private_badge')}</span>
) : (
<>
<Badge variant="secondary" className="font-normal">
{t('not_posted')}
</Badge>
<Button
size="sm"
variant="outline"
className="h-7 px-3.5 text-xs"
onClick={() => onOpenCategoryDialog(transaction)}
>
{t('book')}
</Button>
</>
)}
{showOverflowMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
aria-label="Fler alternativ"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{hasInvoiceMatch && (
<DropdownMenuItem onSelect={() => onOpenMatchDialog(transaction)}>
<FileText className="h-3.5 w-3.5" />
{t('possible_match_invoice', {
number: transaction.potential_invoice!.invoice_number ?? '',
})}
</DropdownMenuItem>
)}
{showMatchVoucherItem && (
<DropdownMenuItem onSelect={() => onOpenMatchVoucher!(transaction)}>
<FileSearch className="h-3.5 w-3.5" />
{t('match_voucher')}
</DropdownMenuItem>
)}
{/* Attach underlag: available on both booked rows (the route
propagates the doc onto the verifikation) and unbooked. */}
{showAttachItem && (
<DropdownMenuItem onSelect={() => onOpenAttachDocument!(transaction)}>
<Paperclip className="h-3.5 w-3.5" />
{t('attach_document')}
</DropdownMenuItem>
)}
{isBooked && canWrite && transaction.journal_entry_id && (
<CorrectionAffordance journalEntryId={transaction.journal_entry_id}>
{({ open, isLoading }) => (
<DropdownMenuItem onSelect={() => open()} disabled={isLoading}>
{isLoading ? t('fetching') : t('create_correction')}
</DropdownMenuItem>
)}
</CorrectionAffordance>
)}
{canDelete && onDelete && (
<>
{(hasInvoiceMatch || showAttachItem || showMatchVoucherItem) && <DropdownMenuSeparator />}
<DropdownMenuItem
onSelect={() => onDelete(transaction.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
{t('delete')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</span>
</td>
</tr>
)
}
function SkattekontoHistoryRow({
row,
onBokfor,
onMatch,
}: {
row: SkattekontoTransactionWithSuggestion
onBokfor?: (row: StoredSkattekontoTransaction) => void
onMatch?: (row: StoredSkattekontoTransaction) => void
}) {
const t = useTranslations('tx_history')
const amount = Number(row.belopp_skatteverket)
const isIncome = amount > 0
const isBooked = !!row.journal_entry_id
return (
<tr className="group transition-colors duration-150 hover:bg-secondary/35">
<td className={cn(TD_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(row.transaktionsdatum)}
</td>
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{row.transaktionstext}</span>
<Badge variant="outline" className="h-4 shrink-0 gap-1 px-1.5 py-0 text-[10px] font-normal">
<Landmark className="h-3 w-3" />
{t('skv_badge')}
</Badge>
{!isBooked && row.match_suggestion && (
<Badge variant="warning" className="h-4 shrink-0 px-1.5 py-0 text-[10px]">
{t('possible_duplicate')}
</Badge>
)}
</span>
</td>
<td
className={cn(
TD_CLASS,
'whitespace-nowrap text-right tabular-nums rr-mask',
isIncome && 'text-success',
)}
>
{isIncome ? '+' : ''}
{formatCurrency(amount)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[9px]')}>
<span className="inline-flex items-center justify-end gap-2">
{isBooked ? (
<>
<span className="text-muted-foreground">{t('posted')}</span>
<Link href={`/bookkeeping/${row.journal_entry_id}`} className={QUIET_LINK_CLASS}>
{t('view_voucher_short')}
</Link>
</>
) : (
<>
<Badge variant="secondary" className="font-normal">
{t('not_posted')}
</Badge>
{onMatch && (
<Button
size="sm"
variant="outline"
className="h-7 px-3.5 text-xs"
onClick={() => onMatch(row)}
>
{row.match_suggestion ? t('link') : t('match')}
</Button>
)}
{!row.match_suggestion && onBokfor && (
<button type="button" className={QUIET_LINK_CLASS} onClick={() => onBokfor(row)}>
{t('book')}
</button>
)}
</>
)}
</span>
</td>
</tr>
)
}