fix(supplier-invoices): reverse credit notes on paid kontantmetoden invoices (#1430)

Under kontantmetoden the credit flow skipped the reversing verifikat
entirely, gated on accounting_method === 'accrual' in all three surfaces
(dashboard route, v1 route, pending-operations commit).

That is right only while the original is still UNPAID: nothing reached the
ledger, so there is no entry to reverse and recognition waits for cash. But
a PAID original was already booked by its payment verifikat (expense +
2641 ingaende moms). Crediting it marked the invoice 'credited' with zero
accounting trace, leaving both the cost and the moms deduction overstated
and nothing to link a later refund back to.

Adds supplierCreditNoteNeedsJournalEntry(), the mirror of the customer
side's creditNoteNeedsJournalEntry(): reverse whenever the original
actually reached the ledger, whatever the configured method.
createSupplierCreditNoteEntry's existing shape already suits the cash case,
the 2440 debit leaves a claim on the supplier that the refund clears, just
as the customer side leaves a 1510 credit for a refund owed.

The v1 route's GDPR-minimised projection dropped exactly the booked-ness
columns this needs; they are restored with a comment explaining why, since
status alone misses a part-paid-but-booked original (rows predating #1413).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-06 12:06:00 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Opus 5
parent 5b0ca3d874
commit d73f288927
7 changed files with 207 additions and 17 deletions
+67 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { booksInvoicesOnIssue, cashPartialBlockReason } from '../booking-mode'
import { booksInvoicesOnIssue, cashPartialBlockReason, supplierCreditNoteNeedsJournalEntry } from '../booking-mode'
describe('booksInvoicesOnIssue (#967)', () => {
it('books at issue for accrual companies by default', () => {
@@ -66,3 +66,69 @@ describe('cashPartialBlockReason', () => {
expect(cashPartialBlockReason({ ...base, priorPaidAmount: undefined })).toBeNull()
})
})
describe('supplierCreditNoteNeedsJournalEntry', () => {
const unpaid = {
registration_journal_entry_id: null,
payment_journal_entry_id: null,
status: 'registered',
paid_at: null,
paid_amount: 0,
}
it('always reverses under faktureringsmetoden, even for an unpaid original', () => {
expect(supplierCreditNoteNeedsJournalEntry('accrual', unpaid)).toBe(true)
// Empty/absent accounting_method falls back to accrual, matching the rest
// of the module.
expect(supplierCreditNoteNeedsJournalEntry('', unpaid)).toBe(true)
})
it('skips under kontantmetoden while the original is still unpaid', () => {
// Nothing reached the ledger: there is no entry to reverse and
// recognition correctly waits for the refund.
expect(supplierCreditNoteNeedsJournalEntry('cash', unpaid)).toBe(false)
})
it('reverses under kontantmetoden once the payment booked the expense', () => {
// The payment verifikat already booked expense + 2641 ingående moms;
// skipping the reversal would overstate both.
expect(
supplierCreditNoteNeedsJournalEntry('cash', {
...unpaid,
status: 'paid',
paid_at: '2026-03-12',
paid_amount: 781,
payment_journal_entry_id: 'je-1',
}),
).toBe(true)
})
it('reverses on any single booked-ness signal in isolation', () => {
// Each signal must stand alone: rows written by different payment paths
// set different subsets of these fields.
expect(supplierCreditNoteNeedsJournalEntry('cash', { ...unpaid, payment_journal_entry_id: 'je-1' })).toBe(true)
expect(supplierCreditNoteNeedsJournalEntry('cash', { ...unpaid, registration_journal_entry_id: 'je-2' })).toBe(true)
expect(supplierCreditNoteNeedsJournalEntry('cash', { ...unpaid, status: 'paid' })).toBe(true)
expect(supplierCreditNoteNeedsJournalEntry('cash', { ...unpaid, paid_at: '2026-03-12' })).toBe(true)
})
it('catches a part-paid original that predates the #1413 guard', () => {
// status is still 'partially_paid', but a payment entry exists, so the
// expense IS on the ledger. status alone would miss this.
expect(
supplierCreditNoteNeedsJournalEntry('cash', {
...unpaid,
status: 'partially_paid',
paid_amount: 781,
payment_journal_entry_id: 'je-3',
}),
).toBe(true)
})
it('ignores sub-öre noise and missing rows', () => {
expect(supplierCreditNoteNeedsJournalEntry('cash', { ...unpaid, paid_amount: 0.004 })).toBe(false)
expect(supplierCreditNoteNeedsJournalEntry('cash', { ...unpaid, paid_amount: null })).toBe(false)
expect(supplierCreditNoteNeedsJournalEntry('cash', null)).toBe(false)
expect(supplierCreditNoteNeedsJournalEntry('cash', undefined)).toBe(false)
})
})