7eb8715417
* fix(category-mapping): use leaf BAS accounts instead of group codes 3900, 5800, 6200 are BAS gruppkonton (header codes) and shouldn't carry postings. Switched the default mappings to the matching leaf accounts: - income_other: 3900 -> 3999 (Övriga rörelseintäkter) - expense_travel: 5800 -> 5890 (Övriga resekostnader) - expense_telecom: 6200 -> 6230 (Datakommunikation) The fallback for income_other inside getCategoryAccountMapping was also hardcoded to '3900'; updated to '3999' for consistency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(transactions): split-payment allocator — 1 tx → N invoices Closes one of the two flows that motivated PR #602's foundation: allocating a single bank transaction across multiple customer OR multiple supplier invoices, with one combined verifikat (samlingsverifikation per BFL 5 kap 6§ st 3). ## Backend (Phase 3a) - **PL/pgSQL RPC** match_batch_allocate (~400 lines): locks the tx + each target invoice with SELECT … FOR UPDATE in id order, validates status/currency/remaining/direction before any write, builds the combined verifikat via commit_journal_entry (atomically assigns voucher_number + flips draft→posted), inserts N rows in invoice_payments or supplier_invoice_payments pointing at the same JE, advances paid_amount/remaining_amount/status per invoice. Returns { ok, journal_entry_id, voucher_number, allocations: [...] } on success or { ok: false, code, details } on guard failure. Mixed customer+supplier kinds are rejected (v1 scope). - **Endpoint** POST /api/transactions/[id]/match-batch — thin wrapper around the RPC. Validates body via MatchBatchSchema (zod discriminatedUnion + superRefine to catch mixed-kinds at the schema layer). On RPC success, emits one invoice.match_confirmed or supplier_invoice.match_confirmed event per allocation so existing subscribers (reminders, automations, processing-history) keep working. Maps the structured RPC error envelope to errorResponseFromCode. - **16 new BATCH_* error codes** (sv+en): BATCH_TX_NOT_FOUND, BATCH_TX_ALREADY_BOOKED, BATCH_OVERSHOOT, BATCH_AMOUNT_EXCEEDS_TX, BATCH_MIXED_KINDS_UNSUPPORTED, BATCH_DIRECTION_MISMATCH, BATCH_CURRENCY_MISMATCH, BATCH_PERIOD_LOCKED, BATCH_RPC_FAILED, etc. ## UI (Phase 5a) - **MatchAllocationDialog** (components/transactions/) — direction- aware (positive tx → customer invoices, negative → supplier). Search + selectable list of open invoices. Per-row amount input with default = min(invoice.remaining, tx_remaining_budget). Live tally with green-check balanced state, red overshoot warning, gray leftover note. Confirm button disabled on overshoot. POSTs to /match-batch and on 200 triggers the same exit animation as single-tx match. - **Inbox row** gains a second outline icon button (Split icon) next to the existing 1:1 match button, gated by the same showInvoiceMatchButton predicate. Tooltip explains the direction- aware split. Opens MatchAllocationDialog. - **i18n** strings under tx_match_allocation namespace in sv.json and en.json (32 keys each). ## Tests - tests/pg/match-batch-allocate.pg.test.ts — 5 pg-real tests covering combined verifikat shape, overshoot guard, already-booked tx, direction mismatch, mixed-kinds rejection. - app/api/transactions/[id]/match-batch/__tests__/route.test.ts — 5 unit tests covering schema validation, mixed-kinds, happy path, structured-error mapping, raw-error → BATCH_RPC_FAILED. 63 unit tests pass across the touched paths. The RPC migration was already applied to remote in an earlier Phase 3a session (idempotent CREATE OR REPLACE FUNCTION; the next replay is a no-op). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(match-batch): PR #603 review round 1 + CI fixes Closes both CI failures and the three real review findings. ## CI fixes - **pg-real failure**: the RPC declared `v_journal_entry_id uuid := uuid_generate_v4()` which fails in the CI Postgres image (uuid-ossp extension is off). Switched to `gen_random_uuid()` — the codebase standard already used by supplier_invoices, invoice_inbox, etc. - **core-only failure**: my earlier BAS leaf-account commit (3900→3999, 5800→5890, 6200→6230) didn't update the matching `lib/bookkeeping/__tests__/category-mapping.test.ts` expectations, and `getDefaultAccountForCategory`'s fallback for `income_*` was still hardcoded to '3900'. Updated both. ## Review findings (greptile) - **P1 deadlock-stable locking** (`match_batch_allocate.sql:11`): the validation `FOR UPDATE` loop ran in caller-supplied array order. Two concurrent calls with overlapping invoice sets in opposite orders could deadlock and one would abort with `BATCH_RPC_FAILED`. Now all three loops (validate, build lines, advance invoices) iterate via `SELECT … FROM jsonb_array_elements(…) ORDER BY COALESCE(invoice_id, supplier_invoice_id)`, giving a stable global lock order regardless of how the caller ordered the JSON array. - **P1 duplicate-allocation detection** (`match_batch_allocate.sql:163`): the same invoice_id listed twice would pass the per-row overshoot guard (both iterations read the original `remaining_amount`) and the write loop would insert two `invoice_payments` rows for the same invoice. Added a `v_seen_ids text[]` check in the validation loop and a new `BATCH_DUPLICATE_ALLOCATION` error code (sv + en). The dialog already prevents this UI-side via `if (prev[candidate.id] return prev` — the RPC guard is the defense-in-depth layer. - **P2 zod `.positive()`** (`schemas.ts:544`): allocation amount was `nonNegativeAmount` (allowing 0), passing schema validation only to be rejected by the RPC with `BATCH_INVALID_AMOUNT`. Now `z.number().positive(…)` so 0-amount entries fail at the schema layer with a per-field path, cleaner 400. - **P2 strict `> 0` direction check** (`MatchAllocationDialog.tsx:82`): used `amount >= 0` to pick customer-side, but a zero-amount tx would load customer candidates only to hit `BATCH_TX_ZERO_AMOUNT` at submit time after the user has filled in allocations. Switched to `> 0` so 0-amount tx never reaches the dialog at all (it's rejected by the RPC immediately). The fourth Greptile comment (the schema P2 about amount validation) overlaps with the third; addressed in the same edit. ## Verification - 112 unit tests pass across touched paths - ESLint clean - New pg-real test `tests/pg/match-batch-allocate.pg.test.ts` covers the dedupe scenario (same supplier invoice listed twice with summing amounts that individually pass per-row overshoot) - RPC patch applied to remote via Supabase MCP Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(match-batch): PR #603 review round 2 — compliance hardening Addresses the actionable findings from compliance-swarm and Swedish-accounting-compliance reviews. Six small RPC changes + two TS-side guards, all bundled in one follow-up migration. ## Security - **(GDPR Art.5(1)(f) / ISO A.8.2) Caller verification**: SECURITY DEFINER bypasses RLS, and the prior RPC accepted any (p_user_id, p_company_id) pair from the route. Now the function rejects with new `BATCH_UNAUTHORIZED` (sv+en, HTTP 403) if `auth.uid()` is not a member of `p_company_id`. Pattern lifted from `harden_invoice_number_rpcs` (#20260510140000). - **(OWASP V4.2) Allocation cap**: `MatchBatchSchema.allocations` now carries `.max(100)` to prevent DoS via unbounded FOR UPDATE locks. ## Swedish accounting correctness - **source_type per direction**: was hardcoded to `'invoice_paid'` for both customer + supplier batches, mis-routing behandlingshistorik filters. Customer batches keep `'invoice_paid'`, supplier batches now write `'supplier_invoice_paid'`. - **Fiscal-period determinism**: `LIMIT 1` on the period lookup was non-deterministic on overlap (e.g. corrected broken year). Added `ORDER BY period_start DESC` so the most recent matching period wins. - **Tolerance harmonisation**: cross-allocation sum used `+0.01` tolerance while per-row used `+0.005`. Both now `+0.005` so a multi-row batch can't drift ~0.01 SEK while each row passes individually. - **`transactions.category` no longer overwritten**: was forced to `'income_services'` (→ BAS 3001 at 25% VAT) for any customer batch, misrepresenting reduced-rate / export / EU-service invoices. The category is only meaningful 1:1 with a single invoice; batches now leave it as-is, mirroring the supplier-side `ELSE category` branch. ## Tests - `tests/pg/match-batch-allocate.pg.test.ts` now wraps every RPC call in `withUserContext(userId)` so `auth.uid()` resolves to the seeded owner. Without this the new membership check would have failed all existing tests. - New pg-real test: `rejects with BATCH_UNAUTHORIZED when caller is not a member of the company` — outsider user gets explicit refusal. - New happy-path assertion: `source_type = 'supplier_invoice_paid'` on the combined verifikat for supplier batches. 15 unit tests pass on the touched paths. RPC patch applied to remote via Supabase MCP. Out-of-scope mcp-server changes still parked locally. Skipped findings (documented in PR comment thread): - V8.2.1 ownership pre-check at route layer (RPC enforces it) - V4.5 / Art.5(1)(b) narrower API response and event payload — typed contracts require the full shapes - V2.4 rate-limiting — system-level, applies to all match endpoints - A.8.28 client-side RLS reliance — documented architectural choice - Direction pre-check at API layer (RPC catches with cleaner code) - V16 + Art.32 + Art.5(1)(b) low-severity logging nits Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
345 lines
13 KiB
TypeScript
345 lines
13 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { useDocumentExtraction } from '@/lib/hooks/use-document-extraction'
|
|
import ExtractionStatus from '@/components/ui/extraction-status'
|
|
import { motion } from 'framer-motion'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import {
|
|
DataListRow,
|
|
DataListPrimary,
|
|
DataListMeta,
|
|
DataListMetaSeparator,
|
|
} from '@/components/ui/data-list'
|
|
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
|
import {
|
|
AlertCircle,
|
|
ArrowUpRight,
|
|
ArrowDownRight,
|
|
FileText,
|
|
Link2,
|
|
Loader2,
|
|
Split,
|
|
Trash2,
|
|
} from 'lucide-react'
|
|
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
|
|
|
// True when the AI tier is active — gates user-facing strings that promise
|
|
// AI behavior. On the free build (document-extraction disabled) we keep the
|
|
// upload functional but drop the "AI:n läser dokumentet" promise.
|
|
const HAS_AI_EXTRACTION = ENABLED_EXTENSION_IDS.has('document-extraction')
|
|
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
|
|
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
|
|
|
|
interface TransactionInboxCardProps {
|
|
transaction: TransactionWithInvoice
|
|
/** When set, this bank tx looks like the bank side of a 1930↔1630
|
|
* transfer that the user will later see on /skattekonto. */
|
|
skvCounterpartDate?: string
|
|
processingId: string | null
|
|
isBatchMode: boolean
|
|
isSelected: boolean
|
|
entityType?: string
|
|
onCategorize: CategorizeHandler
|
|
/** Confirm an auto-detected invoice match (1-click shortcut). */
|
|
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
|
|
/** Open the manual picker — routes to customer or supplier picker by amount sign. */
|
|
onOpenMatchInvoicePicker: (transaction: TransactionWithInvoice) => void
|
|
/** Open the split-payment allocator (1 tx → N invoices) — same direction
|
|
* detection as the single-pick picker. Optional so legacy callers stay
|
|
* source-compatible. */
|
|
onOpenSplitMatch?: (transaction: TransactionWithInvoice) => void
|
|
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
|
onDelete?: (id: string) => void
|
|
onToggleSelect: (id: string) => void
|
|
onAnimationComplete?: (id: string) => void
|
|
}
|
|
|
|
export default function TransactionInboxCard({
|
|
transaction,
|
|
skvCounterpartDate,
|
|
processingId,
|
|
isBatchMode,
|
|
isSelected,
|
|
onOpenMatchDialog,
|
|
onOpenMatchInvoicePicker,
|
|
onOpenSplitMatch,
|
|
onOpenCategoryDialog,
|
|
onDelete,
|
|
onToggleSelect,
|
|
onAnimationComplete,
|
|
}: TransactionInboxCardProps) {
|
|
const t = useTranslations('tx_inbox_card')
|
|
const isProcessing = processingId === transaction.id
|
|
const isDisabled = processingId !== null && processingId !== transaction.id
|
|
const isIncome = transaction.amount > 0
|
|
// Optimistic override — flips the indicator to "attached" as soon as the
|
|
// upload POST succeeds, without waiting for the parent to refetch. The
|
|
// next parent refresh will sync; in the meantime the user sees the
|
|
// correct visual state immediately. Same hook handles agent-chat uploads
|
|
// via the gnubok:transaction-document-linked window event (AgentChat
|
|
// dispatches it after /api/agent/upload returns).
|
|
const [optimisticDocumentId, setOptimisticDocumentId] = useState<string | null>(null)
|
|
useEffect(() => {
|
|
function onLinked(e: Event) {
|
|
const detail = (e as CustomEvent<{ transaction_id?: string; document_id?: string }>).detail
|
|
if (!detail || detail.transaction_id !== transaction.id || !detail.document_id) return
|
|
setOptimisticDocumentId(detail.document_id)
|
|
}
|
|
window.addEventListener('gnubok:transaction-document-linked', onLinked)
|
|
return () => window.removeEventListener('gnubok:transaction-document-linked', onLinked)
|
|
}, [transaction.id])
|
|
const attachedDocumentId =
|
|
optimisticDocumentId ?? (transaction as { document_id?: string | null }).document_id ?? null
|
|
// Only poll extraction status for documents the user attached during THIS
|
|
// session. Pre-existing attached docs from prior sessions wouldn't change
|
|
// status during this view, and polling them would be wasted requests.
|
|
// Gated on HAS_AI_EXTRACTION so the free tier doesn't poll an endpoint
|
|
// whose pipeline never runs.
|
|
const extraction = useDocumentExtraction(
|
|
HAS_AI_EXTRACTION ? optimisticDocumentId : null,
|
|
)
|
|
|
|
const hasInvoiceMatch = !!transaction.potential_invoice && !transaction.invoice_id
|
|
const hasSupplierInvoiceMatch =
|
|
!!transaction.potential_supplier_invoice && !transaction.supplier_invoice_id
|
|
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
|
|
const showCheckbox = isBatchMode && isUncategorized
|
|
const isDeletable = !transaction.journal_entry_id
|
|
|
|
// Primary action: invoice/supplier-invoice match keeps the 1-click shortcut;
|
|
// otherwise the user opens the template picker.
|
|
const primaryAction = (() => {
|
|
if (hasInvoiceMatch) {
|
|
return (
|
|
<Button
|
|
size="sm"
|
|
variant="default"
|
|
className="h-9 px-3 text-sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onOpenMatchDialog(transaction)
|
|
}}
|
|
disabled={isProcessing || isDisabled}
|
|
>
|
|
{isProcessing ? (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<FileText className="mr-1.5 h-3.5 w-3.5" />
|
|
)}
|
|
{t('match_invoice_btn', {
|
|
number: transaction.potential_invoice!.invoice_number ?? '',
|
|
})}
|
|
</Button>
|
|
)
|
|
}
|
|
if (hasSupplierInvoiceMatch) {
|
|
return (
|
|
<Button
|
|
size="sm"
|
|
variant="default"
|
|
className="h-9 px-3 text-sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onOpenMatchDialog(transaction)
|
|
}}
|
|
disabled={isProcessing || isDisabled}
|
|
>
|
|
{isProcessing ? (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<FileText className="mr-1.5 h-3.5 w-3.5" />
|
|
)}
|
|
{t('match_supplier_invoice_btn', {
|
|
number: transaction.potential_supplier_invoice!.supplier_invoice_number ?? '',
|
|
})}
|
|
</Button>
|
|
)
|
|
}
|
|
return (
|
|
<Button
|
|
size="sm"
|
|
variant="default"
|
|
className="h-9 px-3 text-sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onOpenCategoryDialog(transaction)
|
|
}}
|
|
disabled={isProcessing || isDisabled}
|
|
>
|
|
Bokför
|
|
</Button>
|
|
)
|
|
})()
|
|
|
|
// Manual invoice-match affordance. Hidden once an auto-detected match is
|
|
// already shown as the primary button — having both makes the row noisy.
|
|
const showInvoiceMatchButton =
|
|
isDeletable && !hasInvoiceMatch && !hasSupplierInvoiceMatch
|
|
|
|
const invoiceMatchLabel = isIncome
|
|
? 'Matcha mot kundfaktura'
|
|
: 'Matcha mot leverantörsfaktura'
|
|
|
|
const splitMatchLabel = isIncome
|
|
? 'Dela inbetalningen på flera fakturor'
|
|
: 'Dela utbetalningen på flera leverantörsfakturor'
|
|
|
|
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?.(transaction.id)
|
|
}
|
|
}}
|
|
>
|
|
<DataListRow
|
|
data-tx-id={transaction.id}
|
|
selected={isSelected}
|
|
className={cn(isDisabled && 'opacity-50')}
|
|
rowClassName="py-4 gap-4"
|
|
onClick={showCheckbox ? () => onToggleSelect(transaction.id) : undefined}
|
|
leading={
|
|
showCheckbox ? (
|
|
<Checkbox
|
|
checked={isSelected}
|
|
onCheckedChange={() => onToggleSelect(transaction.id)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label="Välj transaktion"
|
|
/>
|
|
) : (
|
|
<span
|
|
className={cn(
|
|
'inline-flex h-6 w-6 items-center justify-center',
|
|
isIncome ? 'text-success' : 'text-foreground/60'
|
|
)}
|
|
aria-hidden
|
|
>
|
|
{isIncome ? (
|
|
<ArrowUpRight className="h-5 w-5" />
|
|
) : (
|
|
<ArrowDownRight className="h-5 w-5" />
|
|
)}
|
|
</span>
|
|
)
|
|
}
|
|
trailing={
|
|
<>
|
|
<div className="text-right">
|
|
<p
|
|
className={cn(
|
|
'text-base font-medium tabular-nums leading-none',
|
|
isIncome && 'text-success'
|
|
)}
|
|
>
|
|
{isIncome ? '+' : ''}
|
|
{formatCurrency(transaction.amount, transaction.currency)}
|
|
</p>
|
|
{transaction.currency !== 'SEK' && transaction.amount_sek != null && (
|
|
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
|
|
{formatCurrency(transaction.amount_sek)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{!isBatchMode && (
|
|
<>
|
|
{primaryAction}
|
|
{showInvoiceMatchButton && (
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-9 w-9"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onOpenMatchInvoicePicker(transaction)
|
|
}}
|
|
aria-label={invoiceMatchLabel}
|
|
title={invoiceMatchLabel}
|
|
disabled={isProcessing || isDisabled}
|
|
>
|
|
<Link2 className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
{showInvoiceMatchButton && onOpenSplitMatch && (
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-9 w-9"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onOpenSplitMatch(transaction)
|
|
}}
|
|
aria-label={splitMatchLabel}
|
|
title={splitMatchLabel}
|
|
disabled={isProcessing || isDisabled}
|
|
>
|
|
<Split className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
{/* The Paperclip indicator next to the description
|
|
(TransactionAttachmentIndicator) is the single click
|
|
target for opening the underlag. We deliberately don't
|
|
duplicate that with a second icon in the trailing slot.
|
|
Per-transaction agent help has moved to Dokumentinkorgen:
|
|
match the underlag to the transaction and ask from there,
|
|
where the receipt/invoice is in view. */}
|
|
{isDeletable && onDelete && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onDelete(transaction.id)
|
|
}}
|
|
aria-label={t('delete_aria')}
|
|
disabled={isProcessing || isDisabled}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
}
|
|
>
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<DataListPrimary className="text-base">{transaction.description}</DataListPrimary>
|
|
<TransactionAttachmentIndicator documentId={attachedDocumentId} />
|
|
</div>
|
|
<DataListMeta className="mt-1">
|
|
<span className="tabular-nums">{formatDate(transaction.date)}</span>
|
|
{skvCounterpartDate && (
|
|
<>
|
|
<DataListMetaSeparator />
|
|
<Badge variant="warning" className="h-4 gap-1 px-1.5 py-0 text-[10px]">
|
|
<AlertCircle className="h-3 w-3" />
|
|
Möjlig 1930↔1630
|
|
</Badge>
|
|
</>
|
|
)}
|
|
</DataListMeta>
|
|
{/* Extraction status — visible only while AI is reading a freshly
|
|
attached document, or briefly if reading failed. */}
|
|
{HAS_AI_EXTRACTION &&
|
|
!isBatchMode &&
|
|
(extraction.status === 'running' || extraction.status === 'failed') && (
|
|
<div className="mt-2 pt-2 border-t border-border/40">
|
|
<ExtractionStatus
|
|
status={extraction.status}
|
|
elapsedMs={extraction.elapsedMs}
|
|
/>
|
|
</div>
|
|
)}
|
|
</DataListRow>
|
|
</motion.div>
|
|
)
|
|
}
|