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:
@@ -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). */}
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
@@ -1397,6 +1397,16 @@ export const MarkSupplierInvoicePaidSchema = z.object({
|
||||
})).min(2).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* "Inlagd i banken" (#2220): a boolean mark, not a payment. `entered: true`
|
||||
* records that the user typed the payment into the bank by hand; `false`
|
||||
* takes the mark back. No amount, no date: the payment itself is still
|
||||
* recorded by mark-paid or the bank match, which also clears the mark.
|
||||
*/
|
||||
export const SupplierInvoiceBankEnteredSchema = z.object({
|
||||
entered: z.boolean(),
|
||||
})
|
||||
|
||||
export const UpdateSupplierInvoiceSchema = z.object({
|
||||
supplier_invoice_number: z.string().min(1).optional(),
|
||||
invoice_date: isoDate.optional(),
|
||||
|
||||
@@ -3025,6 +3025,13 @@ const SUPPLIER_INVOICE_WAVE4: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Leverantörsfakturan kan inte markeras som betald i nuvarande status.',
|
||||
message_en: 'Supplier invoice is not in a payable state.',
|
||||
},
|
||||
SI_BANK_ENTERED_NOT_PAYABLE: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Fakturan kan bara markeras som inlagd i banken när den är godkänd och har något kvar att betala.',
|
||||
message_en:
|
||||
'The invoice can only be marked as entered at the bank while it is approved and has an outstanding amount.',
|
||||
},
|
||||
SI_PAID_PERIOD_LOCKED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Bokföringen är låst. Betalningen kan inte registreras.',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canApproveSupplierInvoice,
|
||||
canMarkSupplierInvoiceBankEntered,
|
||||
findChangedVerifikatFields,
|
||||
findLockedVerifikatFields,
|
||||
isOverduePayable,
|
||||
@@ -193,3 +194,22 @@ describe('findChangedVerifikatFields', () => {
|
||||
expect(findChangedVerifikatFields({ due_date: '2026-09-30' }, ROW)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('canMarkSupplierInvoiceBankEntered', () => {
|
||||
// "Inlagd i banken" (#2220) is the step right before "Markera som betald",
|
||||
// so it is offered on exactly the rows that action is offered on.
|
||||
it.each(['approved', 'overdue', 'partially_paid'])('allows a payable %s invoice', (status) => {
|
||||
expect(canMarkSupplierInvoiceBankEntered({ status })).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['registered', 'paid', 'credited', 'reversed', 'disputed'])(
|
||||
'refuses a %s invoice',
|
||||
(status) => {
|
||||
expect(canMarkSupplierInvoiceBankEntered({ status })).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
it('refuses a credit note whatever its status', () => {
|
||||
expect(canMarkSupplierInvoiceBankEntered({ status: 'approved', is_credit_note: true })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,6 +95,32 @@ export function canApproveSupplierInvoice(invoice: {
|
||||
return invoice.status === 'registered' || invoice.status === 'overdue'
|
||||
}
|
||||
|
||||
/**
|
||||
* Statuses in which "Inlagd i banken" (#2220) can be recorded: the invoice
|
||||
* has passed attest and money is still outstanding. Exactly the rows the
|
||||
* detail page offers "Markera som betald" for, because the mark is the step
|
||||
* right before that one. Kept in sync with the CAS predicate in
|
||||
* app/api/supplier-invoices/[id]/bank-entered/route.ts.
|
||||
*/
|
||||
export const BANK_ENTERED_SUPPLIER_INVOICE_STATUSES = [
|
||||
'approved',
|
||||
'overdue',
|
||||
'partially_paid',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* True when the user may mark the invoice as entered at the bank by hand. A
|
||||
* credit note is never a payment instruction, so it can never be "in the
|
||||
* bank". Clearing the mark is always allowed and does not go through here.
|
||||
*/
|
||||
export function canMarkSupplierInvoiceBankEntered(invoice: {
|
||||
status: string
|
||||
is_credit_note?: boolean | null
|
||||
}): boolean {
|
||||
if (invoice.is_credit_note) return false
|
||||
return (BANK_ENTERED_SUPPLIER_INVOICE_STATUSES as readonly string[]).includes(invoice.status)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields that are copied onto the registration verifikat when it is posted:
|
||||
*
|
||||
|
||||
+10
-3
@@ -827,7 +827,7 @@
|
||||
"status_picker_aria": "Filter by status",
|
||||
"search_placeholder": "Search supplier invoices …",
|
||||
"status_paid_date": "Paid {date}",
|
||||
"help_body": "Approval attests the invoice for payment. Select invoices with the checkbox and create a payment file to upload in your internet bank. Payments are reconciled automatically when they are matched against the bank.",
|
||||
"help_body": "Approval attests the invoice for payment. Select invoices with the checkbox and create a payment file to upload in your internet bank, or tick In bank on the row once you have entered the payment by hand. Payments are reconciled automatically when they are matched against the bank, and the tick then clears itself.",
|
||||
"bulkbar_selected": "{count, plural, one {invoice selected} other {invoices selected}}",
|
||||
"bulk_create_file": "Create payment file",
|
||||
"bulk_select_all": "Select all payable ({count})",
|
||||
@@ -843,7 +843,10 @@
|
||||
"group_supplier": "Supplier",
|
||||
"group_month": "Month",
|
||||
"group_none": "No grouping",
|
||||
"group_unknown": "Missing"
|
||||
"group_unknown": "Missing",
|
||||
"bank_entered_label": "In bank",
|
||||
"bank_entered_aria": "Mark as entered at the bank",
|
||||
"bank_entered_failed_title": "Could not save the mark"
|
||||
},
|
||||
"supplier_payment_files": {
|
||||
"dialog_title": "Create payment file",
|
||||
@@ -4918,7 +4921,11 @@
|
||||
"duplicate_payment_show_voucher": "Show verifikat",
|
||||
"bank_transaction_fallback": "Bank transaction",
|
||||
"go_to": "Go to",
|
||||
"create_voucher_anyway": "Create new verifikat anyway"
|
||||
"create_voucher_anyway": "Create new verifikat anyway",
|
||||
"bank_entered_label": "Entered at bank",
|
||||
"bank_entered_aria": "Mark as entered at the bank",
|
||||
"bank_entered_meta": "Entered at bank {date}",
|
||||
"bank_entered_failed_title": "Could not save the mark"
|
||||
},
|
||||
"supplier_detail": {
|
||||
"back": "Back",
|
||||
|
||||
+10
-3
@@ -827,7 +827,7 @@
|
||||
"status_picker_aria": "Filtrera på status",
|
||||
"search_placeholder": "Sök leverantörsfaktura …",
|
||||
"status_paid_date": "Betald {date}",
|
||||
"help_body": "Godkännandet attesterar fakturan för betalning. Markera fakturor med kryssrutan och skapa en betalfil som laddas upp i internetbanken. Betalningar prickas av automatiskt när de matchas mot banken.",
|
||||
"help_body": "Godkännandet attesterar fakturan för betalning. Markera fakturor med kryssrutan och skapa en betalfil som laddas upp i internetbanken, eller bocka i I banken på raden när du lagt in betalningen för hand. Betalningar prickas av automatiskt när de matchas mot banken, och bocken försvinner då av sig själv.",
|
||||
"bulkbar_selected": "{count, plural, one {faktura vald} other {fakturor valda}}",
|
||||
"bulk_create_file": "Skapa betalfil",
|
||||
"bulk_select_all": "Välj alla betalbara ({count})",
|
||||
@@ -843,7 +843,10 @@
|
||||
"group_supplier": "Leverantör",
|
||||
"group_month": "Månad",
|
||||
"group_none": "Ingen gruppering",
|
||||
"group_unknown": "Saknas"
|
||||
"group_unknown": "Saknas",
|
||||
"bank_entered_label": "I banken",
|
||||
"bank_entered_aria": "Markera som inlagd i banken",
|
||||
"bank_entered_failed_title": "Kunde inte spara markeringen"
|
||||
},
|
||||
"supplier_payment_files": {
|
||||
"dialog_title": "Skapa betalfil",
|
||||
@@ -4918,7 +4921,11 @@
|
||||
"duplicate_payment_show_voucher": "Visa verifikation",
|
||||
"bank_transaction_fallback": "Banktransaktion",
|
||||
"go_to": "Gå till",
|
||||
"create_voucher_anyway": "Skapa ny verifikation ändå"
|
||||
"create_voucher_anyway": "Skapa ny verifikation ändå",
|
||||
"bank_entered_label": "Inlagd i banken",
|
||||
"bank_entered_aria": "Markera som inlagd i banken",
|
||||
"bank_entered_meta": "Inlagd i banken {date}",
|
||||
"bank_entered_failed_title": "Kunde inte spara markeringen"
|
||||
},
|
||||
"supplier_detail": {
|
||||
"back": "Tillbaka",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
-- "Inlagd i banken" for supplier invoices paid by hand (#2220).
|
||||
-- pg-test: tests/pg/supplier-invoice-bank-entered.pg.test.ts
|
||||
--
|
||||
-- A user who types payments into the internet bank instead of uploading a
|
||||
-- betalfil has no way to see which invoices are already handled: the payment
|
||||
-- instruction sits at the bank, the money has not left the account, and the
|
||||
-- invoice is still 'approved'. Betalfil users already have this fact as an
|
||||
-- active supplier_payment_batch item; that model cannot carry a manual mark
|
||||
-- without contortion (the batch is an immutable pain.001 snapshot with NOT
|
||||
-- NULL debtor, payee and reference columns, and the create RPC refuses an
|
||||
-- invoice whose supplier has no payee or whose company has no IBAN), so the
|
||||
-- manual mark is one nullable timestamp on the invoice itself.
|
||||
--
|
||||
-- The mark is a mellanlage between attesterad and betald, never a status:
|
||||
-- it books nothing, changes no amount, and must vanish on its own when the
|
||||
-- payment actually lands. That last part is enforced here rather than in
|
||||
-- each write path (mark-paid, bank match, v1 mark-paid, MCP, batch
|
||||
-- settlement all update the same row): a BEFORE UPDATE trigger clears the
|
||||
-- mark whenever paid_amount rises or the row reaches 'paid'. An UPDATE that
|
||||
-- writes bank_entered_at explicitly in the same statement keeps its value,
|
||||
-- so the mark-as-entered route itself can never be overridden by the
|
||||
-- trigger. A reversal (paid_amount going down) leaves the column alone: the
|
||||
-- mark was already consumed by the payment being reversed, and re-marking is
|
||||
-- the user's call.
|
||||
|
||||
ALTER TABLE public.supplier_invoices
|
||||
ADD COLUMN IF NOT EXISTS bank_entered_at timestamptz;
|
||||
|
||||
COMMENT ON COLUMN public.supplier_invoices.bank_entered_at IS
|
||||
'Set when the user marks the payment as entered at the bank by hand (#2220). Cleared by clear_supplier_invoice_bank_entered() when a payment lands. Display-only: books nothing.';
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.clear_supplier_invoice_bank_entered()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF OLD.bank_entered_at IS NOT NULL
|
||||
AND NEW.bank_entered_at IS NOT DISTINCT FROM OLD.bank_entered_at
|
||||
AND (
|
||||
NEW.paid_amount > OLD.paid_amount + 0.005
|
||||
OR (NEW.status = 'paid' AND OLD.status IS DISTINCT FROM 'paid')
|
||||
) THEN
|
||||
NEW.bank_entered_at := NULL;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS clear_supplier_invoice_bank_entered ON public.supplier_invoices;
|
||||
CREATE TRIGGER clear_supplier_invoice_bank_entered
|
||||
BEFORE UPDATE OF paid_amount, status ON public.supplier_invoices
|
||||
FOR EACH ROW EXECUTE FUNCTION public.clear_supplier_invoice_bank_entered();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -428,6 +428,7 @@ export function makeSupplierInvoice(
|
||||
transaction_id: null,
|
||||
document_id: null,
|
||||
paid_with_private_funds: false,
|
||||
bank_entered_at: null,
|
||||
notes: null,
|
||||
created_at: '2024-06-02T00:00:00Z',
|
||||
updated_at: '2024-06-02T00:00:00Z',
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool, withUserContext } from './setup'
|
||||
import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
|
||||
|
||||
// pg-real coverage for 20260906210200_supplier_invoice_bank_entered.sql
|
||||
// (#2220): the nullable bank_entered_at column and the
|
||||
// clear_supplier_invoice_bank_entered trigger that drops the mark when a
|
||||
// payment lands (paid_amount up, or the row reaching 'paid') and leaves it
|
||||
// alone for every other write: the overdue cron flip, metadata edits, a
|
||||
// payment reversal, and an UPDATE that writes the column explicitly.
|
||||
|
||||
async function insertSupplier(companyId: string, userId: string): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.suppliers (id, user_id, company_id, name, bankgiro)
|
||||
VALUES ($1, $2, $3, 'Derome Bygg AB', '5050-1055')`,
|
||||
[id, userId, companyId],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertSupplierInvoice(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
supplierId: string,
|
||||
overrides: { status?: string; bankEnteredAt?: string | null } = {},
|
||||
): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.supplier_invoices
|
||||
(id, user_id, company_id, supplier_id, arrival_number,
|
||||
supplier_invoice_number, invoice_date, due_date,
|
||||
subtotal, vat_amount, total, remaining_amount, status, approved_at,
|
||||
bank_entered_at)
|
||||
VALUES ($1, $2, $3, $4, floor(random() * 1000000)::int,
|
||||
$5, '2026-06-23', '2026-07-07',
|
||||
590, 147.5, 737.5, 737.5, $6, '2026-06-24T08:00:00Z', $7)`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
companyId,
|
||||
supplierId,
|
||||
`CD-${id.slice(0, 8)}`,
|
||||
overrides.status ?? 'approved',
|
||||
overrides.bankEnteredAt === undefined ? '2026-09-06T10:00:00Z' : overrides.bankEnteredAt,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function readMark(invoiceId: string): Promise<string | null> {
|
||||
const { rows } = await getPool().query<{ bank_entered_at: Date | null }>(
|
||||
`SELECT bank_entered_at FROM public.supplier_invoices WHERE id = $1`,
|
||||
[invoiceId],
|
||||
)
|
||||
return rows[0].bank_entered_at ? rows[0].bank_entered_at.toISOString() : null
|
||||
}
|
||||
|
||||
async function seed(overrides: { status?: string; bankEnteredAt?: string | null } = {}) {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const supplierId = await insertSupplier(companyId, userId)
|
||||
const invoiceId = await insertSupplierInvoice(companyId, userId, supplierId, overrides)
|
||||
return { userId, companyId, invoiceId }
|
||||
}
|
||||
|
||||
describe('supplier_invoices.bank_entered_at (#2220)', () => {
|
||||
it('defaults to NULL', async () => {
|
||||
const { invoiceId } = await seed({ bankEnteredAt: null })
|
||||
expect(await readMark(invoiceId)).toBeNull()
|
||||
})
|
||||
|
||||
it('is cleared when a full payment lands (paid_amount up, status paid)', async () => {
|
||||
const { invoiceId } = await seed()
|
||||
// The mark-paid and bank-match routes write exactly these fields.
|
||||
await getPool().query(
|
||||
`UPDATE public.supplier_invoices
|
||||
SET status = 'paid', paid_amount = 737.5, remaining_amount = 0,
|
||||
paid_at = now()
|
||||
WHERE id = $1`,
|
||||
[invoiceId],
|
||||
)
|
||||
expect(await readMark(invoiceId)).toBeNull()
|
||||
})
|
||||
|
||||
it('is cleared when a partial payment lands (paid_amount up, still open)', async () => {
|
||||
const { invoiceId } = await seed()
|
||||
await getPool().query(
|
||||
`UPDATE public.supplier_invoices
|
||||
SET status = 'partially_paid', paid_amount = 200, remaining_amount = 537.5
|
||||
WHERE id = $1`,
|
||||
[invoiceId],
|
||||
)
|
||||
expect(await readMark(invoiceId)).toBeNull()
|
||||
})
|
||||
|
||||
it('is cleared when the row reaches paid even without paid_amount moving', async () => {
|
||||
const { invoiceId } = await seed()
|
||||
await getPool().query(
|
||||
`UPDATE public.supplier_invoices SET status = 'paid' WHERE id = $1`,
|
||||
[invoiceId],
|
||||
)
|
||||
expect(await readMark(invoiceId)).toBeNull()
|
||||
})
|
||||
|
||||
it('survives the overdue cron flip and the flip back', async () => {
|
||||
const { invoiceId } = await seed()
|
||||
await getPool().query(
|
||||
`UPDATE public.supplier_invoices SET status = 'overdue' WHERE id = $1`,
|
||||
[invoiceId],
|
||||
)
|
||||
expect(await readMark(invoiceId)).toBe('2026-09-06T10:00:00.000Z')
|
||||
await getPool().query(
|
||||
`UPDATE public.supplier_invoices SET status = 'approved' WHERE id = $1`,
|
||||
[invoiceId],
|
||||
)
|
||||
expect(await readMark(invoiceId)).toBe('2026-09-06T10:00:00.000Z')
|
||||
})
|
||||
|
||||
it('survives a metadata edit that touches neither status nor paid_amount', async () => {
|
||||
const { invoiceId } = await seed()
|
||||
await getPool().query(
|
||||
`UPDATE public.supplier_invoices SET due_date = '2026-07-21', notes = 'x' WHERE id = $1`,
|
||||
[invoiceId],
|
||||
)
|
||||
expect(await readMark(invoiceId)).toBe('2026-09-06T10:00:00.000Z')
|
||||
})
|
||||
|
||||
it('is left alone by a payment reversal (paid_amount going down)', async () => {
|
||||
// payment-sync's reversal shape. In practice the mark was already consumed
|
||||
// by the payment being reversed; the trigger only ever clears on money
|
||||
// landing, never on money being taken back, so a re-mark is the user's call.
|
||||
const { invoiceId } = await seed({ status: 'partially_paid' })
|
||||
await getPool().query(
|
||||
`UPDATE public.supplier_invoices SET paid_amount = 200, remaining_amount = 537.5 WHERE id = $1`,
|
||||
[invoiceId],
|
||||
)
|
||||
expect(await readMark(invoiceId)).toBeNull()
|
||||
await getPool().query(
|
||||
`UPDATE public.supplier_invoices SET bank_entered_at = '2026-09-07T10:00:00Z' WHERE id = $1`,
|
||||
[invoiceId],
|
||||
)
|
||||
await getPool().query(
|
||||
`UPDATE public.supplier_invoices
|
||||
SET status = 'approved', paid_amount = 0, remaining_amount = 737.5, paid_at = NULL
|
||||
WHERE id = $1`,
|
||||
[invoiceId],
|
||||
)
|
||||
expect(await readMark(invoiceId)).toBe('2026-09-07T10:00:00.000Z')
|
||||
})
|
||||
|
||||
it('does not override a value written explicitly in the same statement', async () => {
|
||||
const { invoiceId } = await seed()
|
||||
await getPool().query(
|
||||
`UPDATE public.supplier_invoices
|
||||
SET status = 'paid', paid_amount = 737.5, remaining_amount = 0,
|
||||
bank_entered_at = '2026-09-08T10:00:00Z'
|
||||
WHERE id = $1`,
|
||||
[invoiceId],
|
||||
)
|
||||
expect(await readMark(invoiceId)).toBe('2026-09-08T10:00:00.000Z')
|
||||
})
|
||||
|
||||
it('is writable by a company member through the existing UPDATE policy', async () => {
|
||||
// The mark-as-entered route runs as the user (cookie client, RLS on), so
|
||||
// the column must be reachable through supplier_invoices_update.
|
||||
const { userId, invoiceId } = await seed({ bankEnteredAt: null })
|
||||
const updated = await withUserContext(userId, async (client) => {
|
||||
const { rows } = await client.query<{ bank_entered_at: Date | null }>(
|
||||
`UPDATE public.supplier_invoices
|
||||
SET bank_entered_at = '2026-09-06T12:00:00Z'
|
||||
WHERE id = $1
|
||||
RETURNING bank_entered_at`,
|
||||
[invoiceId],
|
||||
)
|
||||
return rows
|
||||
})
|
||||
expect(updated).toHaveLength(1)
|
||||
expect(updated[0].bank_entered_at?.toISOString()).toBe('2026-09-06T12:00:00.000Z')
|
||||
})
|
||||
|
||||
it('is invisible to a member of another company', async () => {
|
||||
const { invoiceId } = await seed({ bankEnteredAt: null })
|
||||
const outsider = await insertAuthUser()
|
||||
const { companyId: otherCompanyId } = await seedCompany()
|
||||
await insertCompanyMember({ companyId: otherCompanyId, userId: outsider, role: 'owner' })
|
||||
const updated = await withUserContext(outsider, async (client) => {
|
||||
const { rowCount } = await client.query(
|
||||
`UPDATE public.supplier_invoices SET bank_entered_at = now() WHERE id = $1`,
|
||||
[invoiceId],
|
||||
)
|
||||
return rowCount
|
||||
})
|
||||
expect(updated).toBe(0)
|
||||
expect(await readMark(invoiceId)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1224,6 +1224,13 @@ export interface SupplierInvoice {
|
||||
// creation and mark-paid is rejected by the existing status guard.
|
||||
paid_with_private_funds: boolean
|
||||
|
||||
/**
|
||||
* "Inlagd i banken" (#2220): the user entered this payment in the internet
|
||||
* bank by hand; money not yet gone. A mellanlage between attesterad and
|
||||
* betald, never a status. Cleared by a DB trigger when a payment lands.
|
||||
*/
|
||||
bank_entered_at: string | null
|
||||
|
||||
notes: string | null
|
||||
|
||||
// Default dimensions bag ({sie_dim_no: code}, e.g. {"1":"KS01","6":"P001"})
|
||||
|
||||
Reference in New Issue
Block a user