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>
135 lines
5.4 KiB
TypeScript
135 lines
5.4 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { booksInvoicesOnIssue, cashPartialBlockReason, supplierCreditNoteNeedsJournalEntry } from '../booking-mode'
|
|
|
|
describe('booksInvoicesOnIssue (#967)', () => {
|
|
it('books at issue for accrual companies by default', () => {
|
|
expect(booksInvoicesOnIssue({ accounting_method: 'accrual' })).toBe(true)
|
|
expect(booksInvoicesOnIssue({ accounting_method: 'accrual', defer_invoice_booking: false })).toBe(true)
|
|
})
|
|
|
|
it('defers when defer_invoice_booking is on', () => {
|
|
expect(booksInvoicesOnIssue({ accounting_method: 'accrual', defer_invoice_booking: true })).toBe(false)
|
|
})
|
|
|
|
it('never books at issue under the cash method, regardless of the flag', () => {
|
|
expect(booksInvoicesOnIssue({ accounting_method: 'cash' })).toBe(false)
|
|
expect(booksInvoicesOnIssue({ accounting_method: 'cash', defer_invoice_booking: true })).toBe(false)
|
|
})
|
|
|
|
it('treats missing settings as the historical accrual default', () => {
|
|
expect(booksInvoicesOnIssue(null)).toBe(true)
|
|
expect(booksInvoicesOnIssue(undefined)).toBe(true)
|
|
expect(booksInvoicesOnIssue({})).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('cashPartialBlockReason', () => {
|
|
const base = {
|
|
invoiceAlreadyBooked: false,
|
|
accountingMethod: 'cash',
|
|
priorPaidAmount: 0,
|
|
paysRemainingInFull: true,
|
|
}
|
|
|
|
it('allows a full settlement from a fully unpaid state', () => {
|
|
expect(cashPartialBlockReason(base)).toBeNull()
|
|
})
|
|
|
|
it('blocks a partial payment on a never-booked cash invoice', () => {
|
|
expect(cashPartialBlockReason({ ...base, paysRemainingInFull: false })).toBe('partial_payment')
|
|
})
|
|
|
|
it('blocks completing a previously part-paid never-booked invoice', () => {
|
|
expect(cashPartialBlockReason({ ...base, priorPaidAmount: 500 })).toBe(
|
|
'previously_partially_paid',
|
|
)
|
|
})
|
|
|
|
it('never blocks invoices that were booked at issue (clearing entry handles partials)', () => {
|
|
expect(
|
|
cashPartialBlockReason({ ...base, invoiceAlreadyBooked: true, paysRemainingInFull: false }),
|
|
).toBeNull()
|
|
})
|
|
|
|
it('never blocks under the accrual method, including the null-settings fallback', () => {
|
|
expect(
|
|
cashPartialBlockReason({ ...base, accountingMethod: 'accrual', paysRemainingInFull: false }),
|
|
).toBeNull()
|
|
expect(
|
|
cashPartialBlockReason({ ...base, accountingMethod: '', paysRemainingInFull: false }),
|
|
).toBeNull()
|
|
})
|
|
|
|
it('ignores sub-öre noise in the prior paid amount', () => {
|
|
expect(cashPartialBlockReason({ ...base, priorPaidAmount: 0.004 })).toBeNull()
|
|
expect(cashPartialBlockReason({ ...base, priorPaidAmount: null })).toBeNull()
|
|
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)
|
|
})
|
|
})
|