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:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
6906bc4aa2
commit
6a5fd6cd00
@@ -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 },
|
||||
)
|
||||
Reference in New Issue
Block a user