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
@@ -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