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:
co-authored by
Jakob Wennberg
Claude Opus 5
parent
5b0ca3d874
commit
d73f288927
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,3 +55,50 @@ export function cashPartialBlockReason(opts: {
|
||||
if (Math.round((opts.priorPaidAmount ?? 0) * 100) !== 0) return 'previously_partially_paid'
|
||||
return null
|
||||
}
|
||||
|
||||
/** The booked-ness signals on a supplier invoice being credited. */
|
||||
export interface SupplierCreditNoteOriginal {
|
||||
/** Set when the invoice was booked at registration (faktureringsmetoden). */
|
||||
registration_journal_entry_id?: string | null
|
||||
/** Set when the invoice was booked at payment (kontantmetoden). */
|
||||
payment_journal_entry_id?: string | null
|
||||
status?: string | null
|
||||
paid_at?: string | null
|
||||
paid_amount?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a supplier credit note must post a reversing verifikat.
|
||||
*
|
||||
* The mirror of creditNoteNeedsJournalEntry() on the customer side: a credit
|
||||
* note reverses whatever actually reached the ledger, so the test is "did the
|
||||
* original get booked", not "which accounting method is configured".
|
||||
*
|
||||
* Under faktureringsmetoden the original was booked at registration, so the
|
||||
* reversal always applies. Under kontantmetoden nothing is booked at
|
||||
* registration, and skipping the credit note is correct while the invoice is
|
||||
* still unpaid: there is no entry to reverse and recognition waits for cash.
|
||||
* But once the invoice has been PAID, the payment verifikat has already booked
|
||||
* the expense and claimed the ingående moms (2641, ruta 48). Leaving that
|
||||
* un-reversed overstates both the cost and the VAT deduction for as long as
|
||||
* the credit stands, and the invoice is marked 'credited' with no accounting
|
||||
* trace at all, so nothing links a later refund back to it.
|
||||
*
|
||||
* createSupplierCreditNoteEntry's shape works for both cases: the 2440 debit
|
||||
* leaves a claim on the supplier that the refund payment clears, exactly as
|
||||
* the customer side leaves a 1510 credit for a refund owed to the customer.
|
||||
*/
|
||||
export function supplierCreditNoteNeedsJournalEntry(
|
||||
accountingMethod: string,
|
||||
original: SupplierCreditNoteOriginal | null | undefined,
|
||||
): boolean {
|
||||
if ((accountingMethod || 'accrual') === 'accrual') return true
|
||||
if (!original) return false
|
||||
return (
|
||||
!!original.registration_journal_entry_id ||
|
||||
!!original.payment_journal_entry_id ||
|
||||
original.status === 'paid' ||
|
||||
!!original.paid_at ||
|
||||
Math.round(Math.abs(original.paid_amount ?? 0) * 100) !== 0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
createCreditNoteJournalEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
||||
import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode'
|
||||
import { cashPartialBlockReason, supplierCreditNoteNeedsJournalEntry } from '@/lib/bookkeeping/booking-mode'
|
||||
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
|
||||
import { createJournalEntry, findFiscalPeriod, getSwedishLocalDate, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
|
||||
import {
|
||||
@@ -3787,7 +3787,10 @@ async function commitCreditSupplierInvoice(
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
|
||||
let journalEntryId: string | null = null
|
||||
if (accountingMethod === 'accrual') {
|
||||
// Kontantmetoden skips only while the original is still UNPAID: a paid one
|
||||
// was already booked by its payment verifikat (expense + 2641 ingående
|
||||
// moms), and leaving that un-reversed overstates cost and moms deduction.
|
||||
if (supplierCreditNoteNeedsJournalEntry(accountingMethod, original)) {
|
||||
try {
|
||||
const je = await createSupplierCreditNoteEntry(
|
||||
supabase,
|
||||
|
||||
Reference in New Issue
Block a user