Bug/customer cron job (#516)
* fix(reminder-processor): filter out credited invoices in overdue reminders * feat: implement linking of transactions to journal entries - Added POST endpoint for linking a bank transaction to an existing journal entry without creating new bookkeeping. - Implemented validation for required fields and error handling for various scenarios (e.g., missing journal_entry_id, transaction already linked, journal entry not found). - Created tests for the new endpoint to cover various cases including successful linking, error responses, and invoice handling. - Introduced duplicate payment detection logic to prevent double-booking of bank receipts. - Added a new component for correction affordance in the UI to facilitate user corrections on journal entries. * feat(invoice-matching): enhance force matching with expected journal entry validation
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Lazy entry point for CorrectionEntryDialog when the user is not on the
|
||||
* /bookkeeping/[id] page (e.g. invoice detail, transaction row). Renders a
|
||||
* trigger (button or link slot) that, on click, fetches the journal entry
|
||||
* with its lines and opens the existing CorrectionEntryDialog.
|
||||
*
|
||||
* Used by:
|
||||
* - /invoices/[id] when invoice.journal_entry_id is set
|
||||
* - /transactions row menu when transaction.journal_entry_id is set
|
||||
*
|
||||
* Surfacing the storno+rättelse flow at the point where users notice the
|
||||
* mistake matters — the dialog itself was already correct (it pre-fills
|
||||
* lines and emits the storno+correction pair per BFL), but it was hidden
|
||||
* behind a deep-link the customer never reached.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import type { JournalEntry } from '@/types'
|
||||
|
||||
interface Props {
|
||||
journalEntryId: string
|
||||
onCorrected?: () => void
|
||||
/**
|
||||
* Render prop: receives the click handler and current loading state.
|
||||
* Letting the caller render its own trigger keeps the affordance visually
|
||||
* native to its host page (link on invoice detail, menu item in dropdown).
|
||||
*/
|
||||
children: (args: { open: () => void; isLoading: boolean }) => React.ReactNode
|
||||
}
|
||||
|
||||
export default function CorrectionAffordance({ journalEntryId, onCorrected, children }: Props) {
|
||||
const { toast } = useToast()
|
||||
const [entry, setEntry] = useState<JournalEntry | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
async function handleOpen() {
|
||||
if (isLoading) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/bookkeeping/journal-entries/${journalEntryId}`)
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({
|
||||
title: 'Kunde inte hämta verifikationen',
|
||||
description: getErrorMessage(json, { context: 'journal_entry', statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const fetched = json.data as JournalEntry
|
||||
if (fetched.status !== 'posted') {
|
||||
toast({
|
||||
title: 'Verifikationen kan inte ändras',
|
||||
description:
|
||||
'Endast bokförda verifikationer kan rättas. Utkast hanteras direkt under bokföringen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
setEntry(fetched)
|
||||
setOpen(true)
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kunde inte hämta verifikationen',
|
||||
description: getErrorMessage(err, { context: 'journal_entry' }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({ open: handleOpen, isLoading })}
|
||||
{entry && (
|
||||
<CorrectionEntryDialog
|
||||
entry={entry}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onCorrected={() => {
|
||||
setOpen(false)
|
||||
setEntry(null)
|
||||
onCorrected?.()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { CheckCircle2, AlertTriangle } from 'lucide-react'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
|
||||
interface DuplicateCandidate {
|
||||
journal_entry_id: string
|
||||
voucher_label: string
|
||||
entry_date: string
|
||||
description: string | null
|
||||
amount: number
|
||||
bank_account_number: string
|
||||
reason: 'exact_amount_same_date' | 'exact_amount_within_window'
|
||||
}
|
||||
|
||||
interface InvoiceMatchDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
transaction: TransactionWithInvoice | null
|
||||
isConfirming: boolean
|
||||
onConfirm: () => void
|
||||
onConfirm: (opts?: { force?: boolean; expected_journal_entry_id?: string }) => void
|
||||
onLinkToExisting?: (journalEntryId: string) => void
|
||||
}
|
||||
|
||||
export default function InvoiceMatchDialog({
|
||||
@@ -20,9 +32,43 @@ export default function InvoiceMatchDialog({
|
||||
transaction,
|
||||
isConfirming,
|
||||
onConfirm,
|
||||
onLinkToExisting,
|
||||
}: InvoiceMatchDialogProps) {
|
||||
const isSupplierInvoice = !!transaction?.potential_supplier_invoice
|
||||
const isCustomerInvoice = !!transaction?.potential_invoice
|
||||
const transactionId = transaction?.id ?? null
|
||||
|
||||
// Customer-side only: pre-flight check for a manual verifikation that
|
||||
// already books this receipt. Supplier-side duplicate-payment surfacing
|
||||
// is handled by the mark-paid guard on the supplier-invoice side; here
|
||||
// we only need the customer flow for the reported issue.
|
||||
const [candidate, setCandidate] = useState<DuplicateCandidate | null>(null)
|
||||
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !transactionId || !isCustomerInvoice || !onLinkToExisting) {
|
||||
setCandidate(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
async function check() {
|
||||
setIsCheckingDuplicate(true)
|
||||
try {
|
||||
const res = await fetch(`/api/transactions/${transactionId}/duplicate-payment-check`)
|
||||
if (!res.ok) return
|
||||
const data = (await res.json()) as { candidate: DuplicateCandidate | null }
|
||||
if (!cancelled) setCandidate(data.candidate ?? null)
|
||||
} catch {
|
||||
// Fail-open: hide the warning panel; the server still enforces the guard.
|
||||
} finally {
|
||||
if (!cancelled) setIsCheckingDuplicate(false)
|
||||
}
|
||||
}
|
||||
check()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, transactionId, isCustomerInvoice, onLinkToExisting])
|
||||
|
||||
// The invoice candidate the dialog is about, normalized to a single shape.
|
||||
// Supplier invoices show the negative-amount paid-out match; customer
|
||||
@@ -43,6 +89,66 @@ export default function InvoiceMatchDialog({
|
||||
|
||||
{transaction && (isCustomerInvoice || isSupplierInvoice) && (
|
||||
<div className="space-y-4">
|
||||
{/* Duplicate-payment warning — customer-side only, only when a candidate exists */}
|
||||
{candidate && isCustomerInvoice && (
|
||||
<div className="rounded-lg border border-warning/40 bg-warning/10 p-4 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5 text-warning-foreground" />
|
||||
<div className="text-sm space-y-1">
|
||||
<p className="font-medium text-warning-foreground">Möjlig dubblettbokning</p>
|
||||
<p className="text-muted-foreground">
|
||||
Det finns redan en bokförd verifikation <span className="font-mono">{candidate.voucher_label}</span> på samma belopp ({formatCurrency(candidate.amount, transaction.currency)}) {candidate.reason === 'exact_amount_same_date' ? 'på samma datum' : `inom ±7 dagar (${formatDate(candidate.entry_date)})`}.
|
||||
Har du redan bokfört denna betalning manuellt?
|
||||
</p>
|
||||
{candidate.description && (
|
||||
// Truncate to a short head before render. The
|
||||
// description is free-text and may carry a customer
|
||||
// name or note that's not strictly required to
|
||||
// identify the verifikation (voucher_label + amount +
|
||||
// date already do that). Cap length to keep the
|
||||
// dialog tight and limit incidental PII surfacing
|
||||
// in the rendered DOM. GDPR Art.5(1)(c).
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{candidate.description.length > 80
|
||||
? `${candidate.description.slice(0, 80).trimEnd()}…`
|
||||
: candidate.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onLinkToExisting && (
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onLinkToExisting(candidate.journal_entry_id)}
|
||||
disabled={isConfirming}
|
||||
className="sm:flex-1"
|
||||
>
|
||||
Koppla till {candidate.voucher_label}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onConfirm({
|
||||
force: true,
|
||||
// Echo the candidate the user reviewed back to
|
||||
// the server so the bypass is bound to this
|
||||
// specific duplicate. See match-invoice route.
|
||||
expected_journal_entry_id: candidate.journal_entry_id,
|
||||
})
|
||||
}
|
||||
disabled={isConfirming}
|
||||
className="sm:flex-1"
|
||||
>
|
||||
Skapa ny verifikation ändå
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction details */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Transaktion</p>
|
||||
@@ -156,7 +262,7 @@ export default function InvoiceMatchDialog({
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isConfirming}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={onConfirm} disabled={isConfirming}>
|
||||
<Button onClick={() => onConfirm()} disabled={isConfirming || isCheckingDuplicate}>
|
||||
{isConfirming ? 'Bekräftar...' : 'Bekräfta matchning'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -26,6 +26,11 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
|
||||
|
||||
useEffect(() => {
|
||||
if (!company) return
|
||||
// Capture the company id once so the async closure below never
|
||||
// dereferences a `company` that has flipped to null between renders.
|
||||
// The earlier non-null assertions allowed a stale render to query
|
||||
// against an undefined company_id; pinning the value avoids that.
|
||||
const companyId = company.id
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
setIsLoading(true)
|
||||
@@ -39,14 +44,38 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
|
||||
const { data } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)')
|
||||
.eq('company_id', company!.id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('document_type', 'invoice')
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.gt('remaining_amount', 0)
|
||||
.order('invoice_date', { ascending: false })
|
||||
.limit(200)
|
||||
if (cancelled) return
|
||||
setInvoices((data as OpenInvoice[]) || [])
|
||||
const all = (data as OpenInvoice[]) || []
|
||||
|
||||
// Status-leak guard: if an invoice still says 'sent'/'overdue' but
|
||||
// already has a payment voucher attached (manual or system), hide it.
|
||||
// Partially-paid invoices intentionally pass through — they may take
|
||||
// more payments. Mirrors the server-side filter in findMatchingInvoices.
|
||||
const fullIds = all
|
||||
.filter((inv) => inv.status === 'sent' || inv.status === 'overdue')
|
||||
.map((inv) => inv.id)
|
||||
let visible = all
|
||||
if (fullIds.length > 0) {
|
||||
const { data: paid } = await supabase
|
||||
.from('invoice_payments')
|
||||
.select('invoice_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('invoice_id', fullIds)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
if (cancelled) return
|
||||
const paidSet = new Set<string>(
|
||||
((paid as { invoice_id: string }[] | null) ?? []).map((r) => r.invoice_id),
|
||||
)
|
||||
visible = all.filter((inv) => !paidSet.has(inv.id))
|
||||
}
|
||||
|
||||
setInvoices(visible)
|
||||
setIsLoading(false)
|
||||
}
|
||||
load()
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
|
||||
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import type { TransactionWithInvoice, HistoryFilter } from './transaction-types'
|
||||
import type {
|
||||
SkattekontoTransactionWithSuggestion,
|
||||
@@ -216,6 +218,11 @@ function BankHistoryRow({
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
onDelete?: (id: string) => void
|
||||
}) {
|
||||
// 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. Mirrors the canWrite gate on the invoice detail page.
|
||||
const { canWrite } = useCanWrite()
|
||||
return (
|
||||
<Card data-tx-id={transaction.id} className="hover:border-primary/50 transition-colors">
|
||||
<CardContent className="py-4">
|
||||
@@ -272,6 +279,26 @@ function BankHistoryRow({
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Bokförd
|
||||
</Badge>
|
||||
<Link
|
||||
href={`/bookkeeping/${transaction.journal_entry_id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
Visa verifikation
|
||||
</Link>
|
||||
{canWrite && (
|
||||
<CorrectionAffordance journalEntryId={transaction.journal_entry_id}>
|
||||
{({ open, isLoading }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={open}
|
||||
disabled={isLoading}
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Hämtar…' : 'Skapa ändringsverifikation'}
|
||||
</button>
|
||||
)}
|
||||
</CorrectionAffordance>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user