fix(bookkeeping): let a rättelseverifikation be stornoed; unblock aged supplier-invoice deletion (#1204)

* fix(bookkeeping): let a rättelseverifikation be stornoed; unblock aged supplier-invoice deletion

A user who corrected a booking (storno + rättelse) and then discovered the
affärshändelse was already booked by another verifikat had no sanctioned way
out: reverseEntry refused source_type 'correction' alongside 'storno', and
correctEntry rightly rejects a zeroing rättelse (BFL 5 kap 5 §). The same
guard also broke uncategorize-after-rättelse, since bank transactions are
relinked to the correction entry.

- reverseEntry now blocks only 'storno' (storno-of-a-storno keeps the chain
  ambiguity problem); a correction entry is a regular live verifikat and can
  be stornoed, with correction_of_id keeping the chain traceable.
- CANNOT_REVERSE_STORNO copy narrowed to stornos + remediation hint.
- Supplier-invoice DELETE now allows unbooked, unpaid invoices in
  registered/approved/overdue: the daily overdue cron flipped unbooked
  invoices past due_date into a state where deletion was blocked forever.
  Orphan-safety checks (registration JE, payments, accrual schedule) are what
  actually protect the books. UI shows the delete button accordingly.
- LinkVoucherPicker showed customer-side copy (kundfordran/1510) in
  supplier-invoice mode; supplier mode now explains the 2440-debit
  requirement, including why a direct-cost verifikat cannot be linked.

Support case 2026-07-26 (marcus@).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(supplier-invoices): review fixes: fail-closed orphan lookups, hide delete when payments loaded

- The payment and accrual-schedule lookups in DELETE now fail closed: a
  lookup error returns 500 instead of reading as "nothing linked" and
  letting the delete proceed unverified.
- The delete button also requires the loaded payment list to be empty,
  matching the server predicate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: authorize 'approved' in supplier-invoice delete allow-list (compliance-swarm V2.3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-26 12:49:10 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 968161b42b
commit 1270b6daeb
12 changed files with 291 additions and 68 deletions
+2
View File
@@ -385,3 +385,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-25] Reverted the settings panel-sheet redesign on bug/resend-and-invoices back to main: Emil prefers the settings UI as it stands on main. The routed sheet, the sheet/ primitives (SettingsMasterDetail, SettingsAccordion, SettingsFieldRow), the *Subsections.tsx decompositions, the cold-load sheet and the settings_sheet i18n namespace were removed; every app/(dashboard)/settings/* page, components/settings/** file and MainContainer scroll exception now matches origin/main byte for byte. Unrelated branch work (invoice delivery outcomes, Stripe feed-only, article currency/deactivation, PDF logo) is untouched.
[2026-07-25] Settings UI on bug/resend-and-invoices now comes from feat/settings-fonster-redesign (dbae8792, Jakob) instead of the panel-sheet work reverted earlier the same day: Emil chose the Fonster concept (flat hairline rows, help behind "?", sticky dirty-only save bar, 920x680 modal, switches instead of checkboxes). Applied as a patch rather than a merge because the redesign branch forks from b5e3c476 and merging would have dragged that older main in; every file applied cleanly since no settings file changed on main since that fork point. The 10 settings_payments keys the redesign still carries (needs_review_*, reason_*, sync_done_description/transactions) were deliberately NOT restored: the Stripe feed-only commit on this branch deleted both them and their call sites.
[2026-07-26] Fixed cross-user attachment access (Odin Aero support case) at the call sites with service-role clients after company-scoped authorization, instead of rewriting the documents bucket storage policy to be company-scoped like sie-files got in 20260416120000: the documents path layout (documents/{userId}/...) carries no company_id, so a company-scoped policy needs a per-object join against document_attachments on every storage op, and the authorize-then-service-client pattern was already the established model (inline proxy route, v1 download route, MCP tools). Sweep found and fixed the same defect in the metadata/sign route, the integrity probe, verifyIntegrity, invoice-inbox retry-extraction, and cloud-backup archive generation. Known leftover, deliberately unfixed: deleteDocument and the upload-failure cleanups call storage remove() with a user-bound client, which silently no-ops (no DELETE policy, WORM), orphaning storage objects; harmless for compliance, needs a separate decision on whether files should ever be hard-deleted.
[2026-07-26] reverseEntry now blocks only source_type='storno', no longer 'correction': BFL 5 kap 5 § requires traceability, not immunity for rättelseverifikat, and blocking corrections left users with no sanctioned exit when a rättelse duplicated an affärshändelse booked elsewhere (support case 2026-07-26); it also broke uncategorize-after-rättelse since transactions are relinked to the correction entry. Storno-of-storno stays blocked (chain ambiguity).
[2026-07-26] Supplier-invoice DELETE allows 'approved' (not only 'registered'/'overdue'): the overdue cron flips BOTH registered and approved invoices to 'overdue', so excluding 'approved' would make deletability depend on whether the cron ran yet; the orphan-safety checks (no registration verifikat, no payments, no accrual schedule) are the real guard, and an attested but unbooked, unpaid invoice deletes nothing from the books.
+18 -11
View File
@@ -491,15 +491,23 @@ export default function SupplierInvoiceDetailPage() {
size="default"
/>
{invoice.status === 'registered' && !invoice.is_credit_note && (
<>
<Button
onClick={handleApprove}
disabled={isProcessing || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <CheckCircle className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('approve')}
</Button>
<Button
onClick={handleApprove}
disabled={isProcessing || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <CheckCircle className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('approve')}
</Button>
)}
{/* Delete is allowed while nothing would be orphaned: no booking, no
payments (server re-checks). 'approved'/'overdue' are included
because the overdue cron flips unbooked invoices there and a
registered-only gate made them undeletable just by aging. */}
{['registered', 'approved', 'overdue'].includes(invoice.status) &&
!invoice.is_credit_note &&
!invoice.registration_journal_entry_id &&
payments.length === 0 && (
<Button
variant="destructive"
size="icon"
@@ -510,8 +518,7 @@ export default function SupplierInvoiceDetailPage() {
>
{canWrite ? <Trash2 className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
</Button>
</>
)}
)}
{['approved', 'overdue', 'partially_paid'].includes(invoice.status) && (
<>
<Button
@@ -89,6 +89,31 @@ describe('DELETE /api/supplier-invoices/[id]', () => {
expect(mockSupabase.from).toHaveBeenCalledTimes(1)
})
it('blocks deletion when a payment row references the invoice', async () => {
// Belt-and-braces: payments normally move the status past the deletable
// list, but a payment row must block deletion regardless.
enqueue({
data: {
status: 'overdue',
registration_journal_entry_id: null,
is_credit_note: false,
},
})
enqueue({ data: { id: 'pay-1' } }) // supplier_invoice_payments lookup
const response = await deleteRequest()
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { reason: string; paymentId: string } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_DELETE_HAS_BOOKING')
expect(body.error.details.reason).toBe('payments')
expect(body.error.details.paymentId).toBe('pay-1')
// Only the existence fetch + payments lookup ran: no item deletion.
expect(mockSupabase.from).toHaveBeenCalledTimes(2)
})
it('blocks deletion when an accrual schedule references the invoice', async () => {
enqueue({
data: {
@@ -97,6 +122,7 @@ describe('DELETE /api/supplier-invoices/[id]', () => {
is_credit_note: false,
},
})
enqueue({ data: null }) // supplier_invoice_payments lookup: none
// accrual_schedules lookup finds a linked schedule (ON DELETE RESTRICT
// would otherwise fail AFTER the items were already deleted).
enqueue({ data: { id: 'sched-1' } })
@@ -110,10 +136,46 @@ describe('DELETE /api/supplier-invoices/[id]', () => {
expect(body.error.code).toBe('SI_DELETE_HAS_BOOKING')
expect(body.error.details.reason).toBe('accrual_schedule')
expect(body.error.details.scheduleId).toBe('sched-1')
// Only the existence fetch + schedule lookup ran: no item deletion.
// Existence fetch + payments lookup + schedule lookup: no item deletion.
expect(mockSupabase.from).toHaveBeenCalledTimes(3)
})
it('blocks deletion for a paid invoice', async () => {
enqueue({
data: { status: 'paid', registration_journal_entry_id: null, is_credit_note: false },
})
const { status } = await parseJsonResponse(await deleteRequest())
expect(status).toBe(400)
})
it('fails closed when the payment lookup errors', async () => {
// A transient DB/RLS failure must block the delete rather than read as
// "no payment exists".
enqueue({
data: { status: 'registered', registration_journal_entry_id: null, is_credit_note: false },
})
enqueue({ data: null, error: { message: 'permission denied' } })
const { status } = await parseJsonResponse(await deleteRequest())
expect(status).toBe(500)
// Existence fetch + payments lookup only: no item deletion.
expect(mockSupabase.from).toHaveBeenCalledTimes(2)
})
it('fails closed when the accrual-schedule lookup errors', async () => {
enqueue({
data: { status: 'registered', registration_journal_entry_id: null, is_credit_note: false },
})
enqueue({ data: null }) // supplier_invoice_payments lookup: none
enqueue({ data: null, error: { message: 'permission denied' } })
const { status } = await parseJsonResponse(await deleteRequest())
expect(status).toBe(500)
// Existence fetch + payments + schedule lookups: no item deletion.
expect(mockSupabase.from).toHaveBeenCalledTimes(3)
})
it('deletes an unbooked registered invoice', async () => {
enqueue({
data: {
@@ -122,6 +184,30 @@ describe('DELETE /api/supplier-invoices/[id]', () => {
is_credit_note: false,
},
})
enqueue({ data: null }) // supplier_invoice_payments lookup: none
enqueue({ data: null }) // accrual_schedules lookup: none
enqueue({ data: null }) // items delete
enqueue({ data: null }) // invoice delete
const response = await deleteRequest()
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
})
it('deletes an unbooked, unpaid overdue invoice', async () => {
// The overdue cron flips unbooked invoices past due_date from
// registered/approved to 'overdue'; a registered-only gate made them
// permanently undeletable just by aging (support case 2026-07-26).
enqueue({
data: {
status: 'overdue',
registration_journal_entry_id: null,
is_credit_note: false,
},
})
enqueue({ data: null }) // supplier_invoice_payments lookup: none
enqueue({ data: null }) // accrual_schedules lookup: none
enqueue({ data: null }) // items delete
enqueue({ data: null }) // invoice delete
+39 -5
View File
@@ -103,18 +103,27 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
)
}
if (existing.status !== 'registered') {
// 'overdue' and 'approved' are included: the daily cron flips unbooked
// invoices past due_date from registered/approved to 'overdue', and a
// registered-only gate made such an invoice permanently undeletable just by
// aging (support case 2026-07-26). What actually protects the books is the
// orphan-safety checks below (no registration verifikat, no payments, no
// accrual schedule), not the lifecycle label.
if (!['registered', 'approved', 'overdue'].includes(existing.status)) {
return NextResponse.json(
{ error: 'Kan bara ta bort registrerade fakturor' },
{ error: 'Endast obetalda fakturor utan bokföring kan tas bort' },
{ status: 400 }
)
}
// Booked invoices must go through the credit flow (mirrors the credit-note
// guard above). Two independent blockers:
// guard above). Three independent blockers:
// (a) a posted registration verifikat: deleting the row would orphan it
// and silently understate 2440/2641 for the momsdeklaration;
// (b) an accrual schedule: accrual_schedules.supplier_invoice_id is
// (b) a payment row: deleting the invoice would orphan the payment's
// journal-entry link (belt-and-braces: payments normally move the
// status to partially_paid/paid, which the gate above already blocks);
// (c) an accrual schedule: accrual_schedules.supplier_invoice_id is
// ON DELETE RESTRICT, so the invoice DELETE below would fail AFTER the
// items were already deleted, leaving a broken invoice with zero rows.
if (existing.registration_journal_entry_id) {
@@ -123,7 +132,28 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
})
}
const { data: linkedSchedule } = await supabase
// Both orphan-safety lookups fail CLOSED: a lookup error must block the
// delete, otherwise a transient DB/RLS failure would read as "no payment /
// no schedule" and let the delete through unverified.
const { data: linkedPayment, error: paymentLookupError } = await supabase
.from('supplier_invoice_payments')
.select('id')
.eq('company_id', companyId)
.eq('supplier_invoice_id', id)
.limit(1)
.maybeSingle()
if (paymentLookupError) {
return NextResponse.json({ error: getUserErrorMessage(paymentLookupError) }, { status: 500 })
}
if (linkedPayment) {
return errorResponseFromCode('SI_DELETE_HAS_BOOKING', log, {
details: { reason: 'payments', paymentId: linkedPayment.id },
})
}
const { data: linkedSchedule, error: scheduleLookupError } = await supabase
.from('accrual_schedules')
.select('id')
.eq('company_id', companyId)
@@ -131,6 +161,10 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.limit(1)
.maybeSingle()
if (scheduleLookupError) {
return NextResponse.json({ error: getUserErrorMessage(scheduleLookupError) }, { status: 500 })
}
if (linkedSchedule) {
return errorResponseFromCode('SI_DELETE_HAS_BOOKING', log, {
details: { reason: 'accrual_schedule', scheduleId: linkedSchedule.id },
+12 -4
View File
@@ -84,11 +84,19 @@ export default function LinkVoucherPicker({
const { toast } = useToast()
const t = useTranslations('invoice_link_voucher')
// Kontantmetoden links against a bank/cash debit (19xx), not an AR credit:
// describe that. Only the customer-invoice copy varies by method.
// Supplier mode links against an AP debit (2440), kontantmetoden against a
// bank/cash debit (19xx), and the accrual customer mode against an AR credit
// (1510): the intro + empty copy must describe the right side, otherwise the
// empty state tells the user to look for a verifikat that can never match
// (support case 2026-07-26: supplier dialog spoke of kundfordran/1510).
const isSupplier = mode === 'supplier_invoice'
const isCash = mode === 'customer_invoice' && accountingMethod === 'cash'
const introKey = isCash ? 'intro_cash' : 'intro'
const emptyDescriptionKey = isCash ? 'empty_description_cash' : 'empty_description'
const introKey = isSupplier ? 'intro_supplier' : isCash ? 'intro_cash' : 'intro'
const emptyDescriptionKey = isSupplier
? 'empty_description_supplier'
: isCash
? 'empty_description_cash'
: 'empty_description'
const apiBase =
mode === 'supplier_invoice'
+98 -29
View File
@@ -612,10 +612,12 @@ describe('reverseEntry: entry_date defaults to original entry date', () => {
})
})
describe('reverseEntry: rejects reversing a storno or correction', () => {
describe('reverseEntry: storno guard', () => {
// BFL 5 kap 5§: a storno-of-a-storno makes the original verifikat's
// cancellation chain ambiguous. The UI hides "Återför" for these source
// types; the engine is the server-side backstop against a direct API call.
// cancellation chain ambiguous, so stornos are never reversible. A
// correction entry, by contrast, is a regular live verifikation (it can be
// a duplicate of an affärshändelse booked by another verifikat) and must
// stay reversible: the guard covers 'storno' only.
function supabaseReturningOriginal(original: Record<string, unknown>) {
return {
rpc: vi.fn(),
@@ -631,34 +633,101 @@ describe('reverseEntry: rejects reversing a storno or correction', () => {
}
}
for (const sourceType of ['storno', 'correction'] as const) {
it(`throws CannotReverseStornoError for source_type '${sourceType}'`, async () => {
const original = {
id: 'entry-1',
company_id: 'company-1',
status: 'posted',
fiscal_period_id: 'period-1',
voucher_series: 'A',
voucher_number: 3,
entry_date: '2024-11-15',
description: 'Makulering: Hyra november',
source_type: sourceType,
source_id: null,
lines: [
{ account_number: '1930', debit_amount: 10000, credit_amount: 0 },
{ account_number: '5010', debit_amount: 0, credit_amount: 10000 },
],
}
const supabase = supabaseReturningOriginal(original)
it(`throws CannotReverseStornoError for source_type 'storno'`, async () => {
const original = {
id: 'entry-1',
company_id: 'company-1',
status: 'posted',
fiscal_period_id: 'period-1',
voucher_series: 'A',
voucher_number: 3,
entry_date: '2024-11-15',
description: 'Makulering: Hyra november',
source_type: 'storno',
source_id: null,
lines: [
{ account_number: '1930', debit_amount: 10000, credit_amount: 0 },
{ account_number: '5010', debit_amount: 0, credit_amount: 10000 },
],
}
const supabase = supabaseReturningOriginal(original)
await expect(
reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1'),
).rejects.toBeInstanceOf(CannotReverseStornoError)
await expect(
reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1'),
).rejects.toBeInstanceOf(CannotReverseStornoError)
// No reversal was written: the guard fires before any voucher number is drawn.
expect(supabase.rpc).not.toHaveBeenCalled()
})
}
// No reversal was written: the guard fires before any voucher number is drawn.
expect(supabase.rpc).not.toHaveBeenCalled()
})
it(`reverses a correction entry like any regular verifikat`, async () => {
// A rättelseverifikation that turned out to duplicate another booking
// (support case 2026-07-26) is nullified with a normal storno; the
// correction_of_id link keeps the chain traceable.
const original = {
id: 'entry-1',
company_id: 'company-1',
status: 'posted',
fiscal_period_id: 'period-1',
voucher_series: 'A',
voucher_number: 3,
entry_date: '2024-11-15',
description: 'Rättelse: Hyra november',
source_type: 'correction',
source_id: null,
correction_of_id: 'entry-0',
lines: [
{ account_number: '5010', debit_amount: 10000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 10000 },
],
}
const reversal = { id: 'reversal-1', reverses_id: 'entry-1' }
let jeCall = 0
const jeResults = [
{ data: original, error: null },
{ data: reversal, error: null },
{ data: null, error: null },
{ data: [{ id: 'entry-1' }], error: null },
{ data: { ...reversal, lines: [] }, error: null },
]
let insertedEntry: Record<string, unknown> | undefined
function jeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'update']) b[m] = vi.fn().mockReturnValue(b)
b.insert = vi.fn().mockImplementation((payload: unknown) => {
insertedEntry = payload as Record<string, unknown>
return b
})
b.single = vi.fn().mockImplementation(async () => jeResults[jeCall++])
b.then = (resolve: (v: unknown) => void) => resolve(jeResults[jeCall++])
return b
}
const supabase = {
rpc: vi.fn().mockResolvedValue({ data: 4, error: null }),
from: vi.fn().mockImplementation((table: string) => {
if (table === 'journal_entries') return jeBuilder()
if (table === 'chart_of_accounts') {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) b[m] = vi.fn().mockReturnValue(b)
b.then = (resolve: (v: unknown) => void) =>
resolve({ data: [{ id: 'acc-5010', account_number: '5010' }, { id: 'acc-1930', account_number: '1930' }], error: null })
return b
}
if (table === 'journal_entry_lines') return { insert: vi.fn().mockResolvedValue({ error: null }) }
return createMockChain()
}),
}
await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
expect(insertedEntry).toBeDefined()
expect(insertedEntry!.source_type).toBe('storno')
expect(insertedEntry!.reverses_id).toBe('entry-1')
expect(insertedEntry!.description).toBe('Makulering: Rättelse: Hyra november')
})
})
describe('reverseEntry: bank transaction unlink', () => {
+10 -5
View File
@@ -733,11 +733,16 @@ export async function reverseEntry(
throw new CannotReverseNonPostedError(original.status)
}
// A storno or correction entry must never itself be reversed: a
// storno-of-a-storno makes the original verifikat's cancellation chain
// ambiguous (BFL 5 kap 5§). The UI hides "Återför" for these source types;
// this is the server-side backstop against a direct API call.
if (original.source_type === 'storno' || original.source_type === 'correction') {
// A storno entry must never itself be reversed: a storno-of-a-storno makes
// the original verifikat's cancellation chain ambiguous (BFL 5 kap 5§). A
// correction entry, by contrast, is a regular live verifikation and must
// stay reversible: it can be a duplicate (the affärshändelse already booked
// by another verifikat) or plain wrong, and blocking it left users with no
// sanctioned way out (support case 2026-07-26). Its correction_of_id link
// keeps the chain traceable either way; the original it corrected stays
// 'reversed'. The UI hides "Återför" for stornos; this is the server-side
// backstop against a direct API call.
if (original.source_type === 'storno') {
throw new CannotReverseStornoError(original.source_type)
}
+7 -5
View File
@@ -147,16 +147,18 @@ export class CannotReverseNonPostedError extends Error {
/**
* Raised when a storno (reversal) is attempted on an entry that is itself a
* storno or a correction. Reversing such an entry would produce a
* storno-of-a-storno and make the original verifikat's cancellation chain
* ambiguous, violating the traceable-correction requirement of BFL 5 kap 5§.
* The UI hides the "Återför" action for these source types; this is the
* storno. Reversing a storno would produce a storno-of-a-storno and make the
* original verifikat's cancellation chain ambiguous, violating the
* traceable-correction requirement of BFL 5 kap 5§. Correction entries are
* NOT covered: a rättelseverifikation is a regular live verifikat and may be
* stornoed like any other (its correction_of_id link keeps the chain
* traceable). The UI hides the "Återför" action for stornos; this is the
* server-side backstop so a direct API call cannot bypass it.
*/
export class CannotReverseStornoError extends Error {
readonly code = CANNOT_REVERSE_STORNO
constructor(public readonly sourceType: string) {
super('Cannot reverse a storno or correction entry')
super('Cannot reverse a storno entry')
this.name = 'CannotReverseStornoError'
}
}
@@ -137,14 +137,18 @@ describe('getErrorMessage: typed bookkeeping Error instances (issue #337)', () =
})
it('CannotReverseStornoError instance → registry Swedish message (no dynamic branch)', () => {
const msg = getErrorMessage(new CannotReverseStornoError('reversal'))
expect(msg).toBe('En stornering eller rättelse kan inte stornas.')
const msg = getErrorMessage(new CannotReverseStornoError('storno'))
expect(msg).toBe(
'En stornering kan inte stornas. Om verifikationen makulerades av misstag, bokför den på nytt (kopiera originalet).',
)
expect(msg).not.toContain('Cannot reverse')
})
it('locale "en" on a typed instance → registry English message', () => {
const msg = getErrorMessage(new CannotReverseStornoError('reversal'), { locale: 'en' })
expect(msg).toBe('A storno or correction entry cannot be reversed.')
const msg = getErrorMessage(new CannotReverseStornoError('storno'), { locale: 'en' })
expect(msg).toBe(
'A storno entry cannot be reversed. If the entry was cancelled by mistake, re-book it (copy the original).',
)
})
it('regression: plain-object bare envelope with a Swedish message passes through unchanged', () => {
+6 -4
View File
@@ -191,8 +191,10 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
},
CANNOT_REVERSE_STORNO: {
httpStatus: 400,
message_sv: 'En stornering eller rättelse kan inte stornas.',
message_en: 'A storno or correction entry cannot be reversed.',
message_sv:
'En stornering kan inte stornas. Om verifikationen makulerades av misstag, bokför den på nytt (kopiera originalet).',
message_en:
'A storno entry cannot be reversed. If the entry was cancelled by mistake, re-book it (copy the original).',
},
CANNOT_CORRECT_NON_POSTED: {
httpStatus: 400,
@@ -1934,9 +1936,9 @@ const SUPPLIER_INVOICE_WAVE4: Record<string, StructuredErrorEntry> = {
SI_DELETE_HAS_BOOKING: {
httpStatus: 400,
message_sv:
'Leverantörsfakturan är bokförd eller har en periodisering och kan inte tas bort. Skapa en kreditfaktura i stället för att återställa bokföringen.',
'Leverantörsfakturan är bokförd, har registrerade betalningar eller en periodisering och kan inte tas bort. Skapa en kreditfaktura i stället för att återställa bokföringen.',
message_en:
'The supplier invoice has a posted journal entry or an accrual schedule and cannot be deleted. Create a credit note instead to reverse the bookkeeping.',
'The supplier invoice has a posted journal entry, recorded payments, or an accrual schedule and cannot be deleted. Create a credit note instead to reverse the bookkeeping.',
},
SI_PAID_ALREADY: {
httpStatus: 409,
+2
View File
@@ -3457,6 +3457,8 @@
"empty_title": "No matching journal entries found",
"empty_description": "No posted entry credits 1510 in this invoice's currency and date window. Post a new payment instead, or correct the prior bookkeeping first.",
"empty_description_cash": "No posted entry debits a cash/bank account (19xx) in this invoice's currency and date window. Post a new payment instead, or correct the prior bookkeeping first.",
"intro_supplier": "Pick an existing posted journal entry that debits accounts payable (2440). No new entry is created: you only link the existing one as the payment.",
"empty_description_supplier": "No posted entry debits accounts payable (2440) in this invoice's currency and date window. If the cost was booked straight against e.g. the bank account without 2440, it cannot be linked as a payment here: book the invoice from supplier invoices instead, or record a new payment.",
"confirmation": "This links voucher {voucher} ({amount}) as the payment for the invoice.",
"no_new_je_note": "No new bookkeeping is created: the existing journal entry is the payment posting.",
"cancel": "Cancel",
+2
View File
@@ -3457,6 +3457,8 @@
"empty_title": "Inga matchande verifikationer hittades",
"empty_description": "Det finns ingen bokförd verifikation som krediterar 1510 i fakturans valuta och period. Bokför istället en ny betalning, eller rätta tidigare bokföring först.",
"empty_description_cash": "Det finns ingen bokförd verifikation som debiterar ett likvidkonto (19xx) i fakturans valuta och period. Bokför istället en ny betalning, eller rätta tidigare bokföring först.",
"intro_supplier": "Välj en befintlig verifikation som debiterar leverantörsskulder (2440). Ingen ny verifikation skapas: du länkar bara den befintliga som betalning.",
"empty_description_supplier": "Det finns ingen bokförd verifikation som debiterar leverantörsskulder (2440) i fakturans valuta och period. Om kostnaden bokfördes direkt mot t.ex. bank utan 2440 kan den inte länkas som betalning här: bokför istället fakturan från leverantörsfakturor, eller registrera en ny betalning.",
"confirmation": "Detta länkar verifikat {voucher} ({amount}) som betalning för fakturan.",
"no_new_je_note": "Ingen ny bokföring skapas: den befintliga verifikationen utgör betalningsposten.",
"cancel": "Avbryt",