feat(supplier-invoices): Inlagd i banken mark for payments entered by hand (#2220) (#2356)

A user who types payments into the internet bank instead of uploading a
betalfil had no way to see which invoices were already handled. Adds a
nullable supplier_invoices.bank_entered_at, a POST
/api/supplier-invoices/{id}/bank-entered route, a labelled checkbox on
the list (trailing slot, once attested) and on the detail header, and a
BEFORE UPDATE trigger that clears the mark when a payment lands, so
every payment path (mark-paid, bank match, v1, MCP) retires it without
knowing it exists. Markera som betald stays a separate action.


Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 21:05:43 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 6906bc4aa2
commit 6a5fd6cd00
14 changed files with 770 additions and 12 deletions
@@ -7,6 +7,7 @@ import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { DetailSection, DefRow, DefEmpty } from '@/components/ui/detail-section'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { HelpPopover } from '@/components/ui/help-popover'
@@ -35,7 +36,10 @@ import { DocumentViewButton } from '@/components/bookkeeping/DocumentViewButton'
import { useCompanySettings } from '@/components/settings/useSettings'
import { formatAmount, formatCurrency } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
import {
canApproveSupplierInvoice,
canMarkSupplierInvoiceBankEntered,
} from '@/lib/supplier-invoices/lifecycle'
import { DetailPager } from '@/components/common/DetailPager'
import { listContextKey } from '@/lib/navigation/list-context'
import { useCompanyOptional } from '@/contexts/CompanyContext'
@@ -117,7 +121,7 @@ export default function SupplierInvoiceDetailPage() {
// shows the spinner while the others only disable. A single boolean put
// identical pending feedback (none) on every button at once.
const [processingAction, setProcessingAction] = useState<
'approve' | 'book' | 'mark_paid' | 'credit' | 'uncredit' | 'delete' | null
'approve' | 'book' | 'mark_paid' | 'bank_entered' | 'credit' | 'uncredit' | 'delete' | null
>(null)
const isProcessing = processingAction !== null
const [duplicateCandidates, setDuplicateCandidates] = useState<
@@ -335,6 +339,35 @@ export default function SupplierInvoiceDetailPage() {
}
}
// "Inlagd i banken" (#2220): the payment was typed into the bank by hand.
// A mark, not a payment: nothing is booked, so no refetch is needed; the
// server's timestamp is the only thing that changed.
async function handleBankEntered(entered: boolean) {
setProcessingAction('bank_entered')
try {
const res = await fetch(`/api/supplier-invoices/${params.id}/bank-entered`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entered }),
})
const result = await res.json()
if (!res.ok) {
toast({ title: t('bank_entered_failed_title'), description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
// A refusal usually means the row moved (paid meanwhile): re-read.
await fetchInvoice()
} else {
const updated = result?.data as { bank_entered_at?: string | null } | undefined
setInvoice((prev) =>
prev ? { ...prev, bank_entered_at: updated?.bank_entered_at ?? null } : prev,
)
}
} catch (err) {
toast({ title: t('bank_entered_failed_title'), description: getErrorMessage(err, { context: 'supplier_invoice' }), variant: 'destructive' })
} finally {
setProcessingAction(null)
}
}
// #967: deferred booking: create the registration verifikat afterwards.
async function handleBook() {
setProcessingAction('book')
@@ -578,6 +611,10 @@ export default function SupplierInvoiceDetailPage() {
invoice.supplier?.name ?? null,
docNumber ? t('arrival_header', { number: invoice.arrival_number }) : null,
t('created_at', { date: formatDate(invoice.created_at) }),
// The mellanlage (#2220) reads as a dated fact here, next to the bock.
invoice.bank_entered_at
? t('bank_entered_meta', { date: formatDate(invoice.bank_entered_at) })
: null,
].filter(Boolean)
// Attest keys off approved_at, not the status: the overdue cron flips
@@ -585,6 +622,10 @@ export default function SupplierInvoiceDetailPage() {
// alone left them with no way through attest (#1206).
const canApprove = canApproveSupplierInvoice(invoice) && !invoice.is_credit_note
const canMarkPaid = ['approved', 'overdue', 'partially_paid'].includes(invoice.status)
// "Inlagd i banken" (#2220) sits on the same rows as Markera som betald:
// it is the step right before it. Viewers see the bock only when it is set.
const showBankEntered =
canMarkSupplierInvoiceBankEntered(invoice) && (canWrite || !!invoice.bank_entered_at)
const canCredit = canMarkPaid && invoice.status !== 'partially_paid'
const canUncredit = invoice.status === 'credited' && !invoice.is_credit_note
// Delete is allowed while nothing would be orphaned: no booking, no
@@ -687,6 +728,28 @@ export default function SupplierInvoiceDetailPage() {
{t('approve')}
</Button>
)}
{/* The bock (#2220): "I have entered this payment in the bank".
A labelled checkbox rather than a button, because it is a fact
the user records, not an action that posts anything. */}
{showBankEntered && (
<label
className={cn(
'inline-flex h-9 select-none items-center gap-2 px-2 text-[13px]',
canWrite && !isProcessing ? 'cursor-pointer' : 'cursor-default',
processingAction === 'bank_entered' && 'opacity-50',
)}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
<Checkbox
checked={!!invoice.bank_entered_at}
disabled={isProcessing || !canWrite}
onCheckedChange={(value) => void handleBankEntered(value === true)}
aria-label={t('bank_entered_aria')}
className="border-foreground"
/>
{t('bank_entered_label')}
</label>
)}
{/* Attest gates payment: while attest is still pending, Markera
betald steps back to a secondary so the header keeps one next
step (an aged-but-unattested invoice can have both). */}
+76 -4
View File
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
import { DataListEmpty } from '@/components/ui/data-list'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS, HOVER_REVEAL_CLASS } from '@/components/ui/dry-table'
import { useRangeSelect } from '@/lib/hooks/use-range-select'
import { FyPicker } from '@/components/common/FyPicker'
import { ContextPicker } from '@/components/common/ContextPicker'
@@ -24,7 +24,10 @@ import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
import {
canApproveSupplierInvoice,
canMarkSupplierInvoiceBankEntered,
} from '@/lib/supplier-invoices/lifecycle'
import {
sortSupplierInvoiceList,
type SupplierInvoiceListSort,
@@ -189,6 +192,9 @@ export default function SupplierInvoicesPage() {
const [fyPeriodId, setFyPeriodId] = useState<string | null>(null)
const [fyPeriod, setFyPeriod] = useState<FiscalPeriod | null>(null)
const [approvingId, setApprovingId] = useState<string | null>(null)
// "Inlagd i banken" (#2220) in flight for one row: the bock disables while
// the server confirms, so a double click cannot flip it twice.
const [bankEnteringId, setBankEnteringId] = useState<string | null>(null)
// Payment-file bulk selection + the "already in an active betalfil" chip map.
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// Radix' onCheckedChange carries no mouse event: the preceding click records
@@ -470,6 +476,39 @@ export default function SupplierInvoicesPage() {
}
}
// "Inlagd i banken" (#2220): the payment was typed into the bank by hand.
// A mark, not a payment: the server writes one timestamp and the DB clears
// it when the payment lands, so the row patch here is the whole story.
async function handleBankEntered(id: string, entered: boolean) {
setBankEnteringId(id)
try {
const res = await fetch(`/api/supplier-invoices/${id}/bank-entered`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entered }),
})
const result = await res.json()
if (!res.ok) {
toast({ title: t('bank_entered_failed_title'), description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
// The refusal usually means the row moved (paid meanwhile): re-read
// rather than leave a bock the server does not agree with.
fetchInvoices()
return
}
const updated = result?.data as { bank_entered_at?: string | null } | undefined
setInvoices((prev) =>
prev.map((inv) =>
inv.id === id ? { ...inv, bank_entered_at: updated?.bank_entered_at ?? null } : inv,
),
)
} catch {
toast({ title: t('bank_entered_failed_title'), description: getErrorMessage(null, { context: 'supplier_invoice' }), variant: 'destructive' })
fetchInvoices()
} finally {
setBankEnteringId(null)
}
}
return (
<div className="space-y-8">
{/* Page header (concept scene 21): title + help + primary action.
@@ -620,7 +659,10 @@ export default function SupplierInvoicesPage() {
the wrapper scrolls sideways, Leverantör collapses to its header
width and Status is cut at the edge. That is why the list carries
one date (förfaller: the payer's date and the default order) and a
short Kvar header; fakturadatum lives in the detail view. */
short Kvar header; fakturadatum lives in the detail view. The
trailing column is one slot for the row's next step: Godkänn while
attest is pending, the I banken bock (#2220) once attested; it is
sized for the bock plus its label, the wider of the two. */
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
@@ -675,7 +717,7 @@ export default function SupplierInvoicesPage() {
sort={sort}
onSort={updateSort}
/>
<th className={cn(TH_CLASS, 'w-[96px]')} aria-hidden="true"></th>
<th className={cn(TH_CLASS, 'w-[108px]')} aria-hidden="true"></th>
</tr>
</thead>
<tbody className="stagger-enter">
@@ -692,6 +734,14 @@ export default function SupplierInvoicesPage() {
const canApprove =
canApproveSupplierInvoice(inv) && !inv.is_credit_note && canWrite
const selectable = canWrite && isBatchSelectable(inv)
// "Inlagd i banken" (#2220) shares the trailing slot with
// Godkänn, so it appears once attest is done. Viewers see the
// bock only when it is set (state, not a control).
const bankEntered = !!inv.bank_entered_at
const showBankEntered =
!canApprove &&
canMarkSupplierInvoiceBankEntered(inv) &&
(canWrite || bankEntered)
// Same shape as the customer list: the section header is a
// sibling row decided from the previous row's key.
const groupKey = rowGroupKeys.get(inv.id) ?? null
@@ -808,6 +858,28 @@ export default function SupplierInvoicesPage() {
{t('approve')}
</button>
)}
{/* The bock (#2220): hover-revealed until set, then it
stays, because a set bock is the state the payer
scans for. */}
{showBankEntered && (
<label
className={cn(
'inline-flex select-none items-center gap-2 text-[12.5px] text-muted-foreground',
canWrite ? 'cursor-pointer' : 'cursor-default',
bankEntered ? 'opacity-100' : HOVER_REVEAL_CLASS,
bankEnteringId === inv.id && 'pointer-events-none opacity-50',
)}
>
<Checkbox
checked={bankEntered}
disabled={!canWrite}
onCheckedChange={(value) => handleBankEntered(inv.id, value === true)}
aria-label={t('bank_entered_aria')}
className="border-foreground duration-150"
/>
{t('bank_entered_label')}
</label>
)}
</td>
</tr>
</Fragment>
@@ -0,0 +1,197 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
makeSupplierInvoice,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { eventBus } from '@/lib/events'
import { POST } from '../route'
/**
* "Inlagd i banken" (#2220): a mark, not a payment. The route writes one
* nullable timestamp and nothing else; the trigger covered by
* tests/pg/supplier-invoice-bank-entered.pg.test.ts clears it when a payment
* lands.
*/
describe('POST /api/supplier-invoices/[id]/bank-entered', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
})
function post(body: unknown) {
return POST(
createMockRequest('/api/supplier-invoices/si-1/bank-entered', {
method: 'POST',
body,
}),
createMockRouteParams({ id: 'si-1' }),
)
}
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await post({ entered: true })
expect(response.status).toBe(401)
expect(mockSupabase.from).not.toHaveBeenCalled()
})
it('returns 400 when the body carries no boolean', async () => {
const response = await post({ entered: 'yes' })
expect(response.status).toBe(400)
expect(mockSupabase.from).not.toHaveBeenCalled()
})
it('returns 404 when the invoice does not exist in the company', async () => {
enqueue({ data: null })
const response = await post({ entered: true })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('SI_NOT_FOUND')
// Company scoping on the read (defense in depth alongside RLS).
expect(findCalls('supplier_invoices', 'eq')).toContainEqual(['company_id', 'company-1'])
expect(findCall('supplier_invoices', 'update')).toBeUndefined()
})
it('marks an approved invoice and returns the timestamp', async () => {
enqueue({ data: makeSupplierInvoice({ id: 'si-1', status: 'approved' }) })
enqueue({ data: { id: 'si-1', bank_entered_at: '2026-09-06T10:00:00.000Z' } })
const response = await post({ entered: true })
const { status, body } = await parseJsonResponse<{
data: { id: string; bank_entered_at: string | null }
}>(response)
expect(status).toBe(200)
expect(body.data).toEqual({ id: 'si-1', bank_entered_at: '2026-09-06T10:00:00.000Z' })
const payload = findCall('supplier_invoices', 'update')?.[0] as Record<string, unknown>
// Only the mark is written: no status, amount or payment field moves.
expect(Object.keys(payload)).toEqual(['bank_entered_at'])
expect(payload.bank_entered_at).toEqual(expect.any(String))
// Compare-and-set on the eligibility the read established.
expect(findCall('supplier_invoices', 'in')).toEqual([
'status',
['approved', 'overdue', 'partially_paid'],
])
expect(findCalls('supplier_invoices', 'eq')).toContainEqual(['is_credit_note', false])
})
it('keeps the first timestamp when marking an already-marked invoice', async () => {
enqueue({
data: makeSupplierInvoice({
id: 'si-1',
status: 'overdue',
bank_entered_at: '2026-09-01T08:00:00.000Z',
}),
})
enqueue({ data: { id: 'si-1', bank_entered_at: '2026-09-01T08:00:00.000Z' } })
const response = await post({ entered: true })
expect(response.status).toBe(200)
const payload = findCall('supplier_invoices', 'update')?.[0] as Record<string, unknown>
expect(payload.bank_entered_at).toBe('2026-09-01T08:00:00.000Z')
})
it('clears the mark without any status guard', async () => {
enqueue({
data: makeSupplierInvoice({
id: 'si-1',
status: 'paid',
bank_entered_at: '2026-09-01T08:00:00.000Z',
}),
})
enqueue({ data: { id: 'si-1', bank_entered_at: null } })
const response = await post({ entered: false })
const { status, body } = await parseJsonResponse<{
data: { id: string; bank_entered_at: string | null }
}>(response)
expect(status).toBe(200)
expect(body.data.bank_entered_at).toBeNull()
expect(findCall('supplier_invoices', 'update')?.[0]).toEqual({ bank_entered_at: null })
expect(findCall('supplier_invoices', 'in')).toBeUndefined()
})
it.each([
['registered', false],
['paid', false],
['credited', false],
['approved', true],
])('refuses to mark a %s invoice (credit note: %s)', async (invoiceStatus, isCreditNote) => {
enqueue({
data: makeSupplierInvoice({
id: 'si-1',
status: invoiceStatus as 'registered',
is_credit_note: isCreditNote,
}),
})
const response = await post({ entered: true })
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { currentStatus: string } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_BANK_ENTERED_NOT_PAYABLE')
expect(body.error.details.currentStatus).toBe(invoiceStatus)
expect(findCall('supplier_invoices', 'update')).toBeUndefined()
})
it('refuses when the compare-and-set matches no row (payment landed meanwhile)', async () => {
enqueue({ data: makeSupplierInvoice({ id: 'si-1', status: 'approved' }) })
enqueue({ data: null })
const response = await post({ entered: true })
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { reason: string } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_BANK_ENTERED_NOT_PAYABLE')
expect(body.error.details.reason).toBe('race')
})
it('surfaces a database error from the update', async () => {
enqueue({ data: makeSupplierInvoice({ id: 'si-1', status: 'approved' }) })
enqueue({ data: null, error: { code: '42501', message: 'permission denied' } })
const response = await post({ entered: true })
expect(response.status).toBeGreaterThanOrEqual(400)
expect(response.status).not.toBe(200)
})
})
@@ -0,0 +1,89 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { SupplierInvoiceBankEnteredSchema } from '@/lib/api/schemas'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import {
BANK_ENTERED_SUPPLIER_INVOICE_STATUSES,
canMarkSupplierInvoiceBankEntered,
} from '@/lib/supplier-invoices/lifecycle'
/**
* "Inlagd i banken" (#2220): record that the user entered this payment in
* the internet bank by hand, or take that mark back.
*
* This is a mark, not a payment. It books nothing, changes no amount and no
* status; the payment is still recorded by mark-paid or the bank match, and
* the clear_supplier_invoice_bank_entered trigger drops the mark the moment
* one of those lands. Betalfil users get the same fact from their active
* batch instead and never need this route.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'supplier_invoice.bank_entered',
async (request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
const opLog = log.child({ supplierInvoiceId: id })
const validation = await validateBody(request, SupplierInvoiceBankEnteredSchema, {
log: opLog,
operation: 'supplier_invoice.bank_entered',
})
if (!validation.success) return validation.response
const { entered } = validation.data
const { data: invoice } = await supabase
.from('supplier_invoices')
.select('id, status, is_credit_note, bank_entered_at')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (!invoice) {
return errorResponseFromCode('SI_NOT_FOUND', opLog, { requestId })
}
if (entered && !canMarkSupplierInvoiceBankEntered(invoice)) {
return errorResponseFromCode('SI_BANK_ENTERED_NOT_PAYABLE', opLog, {
requestId,
details: { currentStatus: invoice.status },
})
}
// Idempotent: marking an already-marked invoice keeps the first timestamp
// (that is when it went into the bank). Clearing is allowed in any
// status: a stale mark on a settled row is never worth refusing.
const bankEnteredAt = entered
? ((invoice.bank_entered_at as string | null) ?? new Date().toISOString())
: null
let update = supabase
.from('supplier_invoices')
.update({ bank_entered_at: bankEnteredAt })
.eq('id', id)
.eq('company_id', companyId)
if (entered) {
// Compare-and-set on the eligibility we just read: a payment that lands
// between the read and this write turns into zero matched rows instead
// of a mark on a paid invoice.
update = update
.in('status', [...BANK_ENTERED_SUPPLIER_INVOICE_STATUSES])
.eq('is_credit_note', false)
}
const { data, error } = await update.select('id, bank_entered_at').maybeSingle()
if (error) {
opLog.error('supplier_invoices bank_entered_at update failed', error)
return errorResponse(error, opLog, { requestId })
}
if (!data) {
return errorResponseFromCode('SI_BANK_ENTERED_NOT_PAYABLE', opLog, {
requestId,
details: { reason: 'race' },
})
}
return NextResponse.json({ data })
},
{ requireWrite: true },
)