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
@@ -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:
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user