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
@@ -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 },
)