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
@@ -802,3 +802,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
|||||||
[2026-08-06] Session replay masking narrowed from mask-everything to pattern-based (founder-approved): fully masked replays were wall-to-wall asterisks and useless for support debugging. lib/analytics/replay-masking.ts masks currency-shaped text, person-/organisationsnummer (text and typed input) and password inputs; data-ph-mask still force-masks tagged PII, data-ph-unmask stays honored for chrome, and everything else including typed input is now visible in replays. Privacy policy and RoPA updated in the same change; supersedes the 2026-07-27 maskTextSelector '*' decision.
|
[2026-08-06] Session replay masking narrowed from mask-everything to pattern-based (founder-approved): fully masked replays were wall-to-wall asterisks and useless for support debugging. lib/analytics/replay-masking.ts masks currency-shaped text, person-/organisationsnummer (text and typed input) and password inputs; data-ph-mask still force-masks tagged PII, data-ph-unmask stays honored for chrome, and everything else including typed input is now visible in replays. Privacy policy and RoPA updated in the same change; supersedes the 2026-07-27 maskTextSelector '*' decision.
|
||||||
[2026-08-06] ROT/RUT payout strings were placed in the invoice_editor namespace while RotRutPayoutDialog and the invoices page read useTranslations('invoices'), so all 44 labels rendered as raw "invoices.rot_rut_*" key paths in production since #1380. Moved the keys to invoices rather than repointing the components, since the dialog belongs to the invoice list, not the editor. Message files are edited textually, never via JSON.parse/stringify: they contain duplicate keys a round trip would silently drop. Same bug class fixed in TemplateBookDialog (bookkeeping) and Correction/StrikeLines dialogs (journal_detail) by adding the strings to the namespace each component reads, matching the existing precedent that toast_posted_* is duplicated across journal_list and journal_detail. Added i18n/__tests__/message-keys.test.ts, which resolves every literal t() key against both locales: next-intl has no build-time check and fails by rendering the key path, so nothing caught this before users did.
|
[2026-08-06] ROT/RUT payout strings were placed in the invoice_editor namespace while RotRutPayoutDialog and the invoices page read useTranslations('invoices'), so all 44 labels rendered as raw "invoices.rot_rut_*" key paths in production since #1380. Moved the keys to invoices rather than repointing the components, since the dialog belongs to the invoice list, not the editor. Message files are edited textually, never via JSON.parse/stringify: they contain duplicate keys a round trip would silently drop. Same bug class fixed in TemplateBookDialog (bookkeeping) and Correction/StrikeLines dialogs (journal_detail) by adding the strings to the namespace each component reads, matching the existing precedent that toast_posted_* is duplicated across journal_list and journal_detail. Added i18n/__tests__/message-keys.test.ts, which resolves every literal t() key against both locales: next-intl has no build-time check and fails by rendering the key path, so nothing caught this before users did.
|
||||||
[2026-08-06] The ROT/RUT payout button is hidden from the invoices header unless the company has an invoice with deduction_total > 0 or rot_rut_enabled is on in tax settings. ROT/RUT concerns only companies selling eligible work to consumers, and a payout can never precede the invoice that created the claim, so the derived signal cannot hide the action from someone who needs it. Read from the company_settings row the page already fetches for ore_rounding (no extra round trip); deliberately not scoped to the fiscal-year filter, since a begäran is claimed the year after payment. ?rot-rut=1 still opens the dialog, so the feature is hidden, not removed.
|
[2026-08-06] The ROT/RUT payout button is hidden from the invoices header unless the company has an invoice with deduction_total > 0 or rot_rut_enabled is on in tax settings. ROT/RUT concerns only companies selling eligible work to consumers, and a payout can never precede the invoice that created the claim, so the derived signal cannot hide the action from someone who needs it. Read from the company_settings row the page already fetches for ore_rounding (no extra round trip); deliberately not scoped to the fiscal-year filter, since a begäran is claimed the year after payment. ?rot-rut=1 still opens the dialog, so the feature is hidden, not removed.
|
||||||
|
[2026-08-06] Supplier credit notes under kontantmetoden now reverse when the ORIGINAL was already booked (paid), not only under faktureringsmetoden: skipping left the expense and the 2641 ingaende moms deduction overstated with no accounting trace. Mirrors the customer-side creditNoteNeedsJournalEntry(). The v1 route's GDPR-minimised projection had to re-add registration_journal_entry_id/payment_journal_entry_id/paid_at/paid_amount: status alone misses a part-paid-but-booked original.
|
||||||
|
|||||||
@@ -158,4 +158,60 @@ describe('POST /api/supplier-invoices/[id]/credit', () => {
|
|||||||
'Leverantör AB',
|
'Leverantör AB',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
it('skips the reversing entry under kontantmetoden while the original is unpaid', async () => {
|
||||||
|
// Nothing reached the ledger at registration, so there is no entry to
|
||||||
|
// reverse: recognition correctly waits for the refund.
|
||||||
|
const creditNote = makeSupplierInvoice({
|
||||||
|
id: 'credit-1',
|
||||||
|
is_credit_note: true,
|
||||||
|
credited_invoice_id: 'invoice-1',
|
||||||
|
})
|
||||||
|
enqueueMany([
|
||||||
|
{ data: { ...original, status: 'registered', paid_amount: 0, paid_at: null, payment_journal_entry_id: null, registration_journal_entry_id: null }, error: null },
|
||||||
|
{ data: 2, error: null },
|
||||||
|
{ data: creditNote, error: null },
|
||||||
|
{ data: null, error: null },
|
||||||
|
{ data: { accounting_method: 'cash' }, error: null },
|
||||||
|
{ data: null, error: null },
|
||||||
|
])
|
||||||
|
|
||||||
|
const response = await POST(
|
||||||
|
createMockRequest('/api/supplier-invoices/invoice-1/credit', { method: 'POST' }),
|
||||||
|
createMockRouteParams({ id: 'invoice-1' }),
|
||||||
|
)
|
||||||
|
const { status } = await parseJsonResponse(response)
|
||||||
|
|
||||||
|
expect(status).toBe(200)
|
||||||
|
expect(createCreditEntryMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reverses under kontantmetoden once the payment already booked the expense', async () => {
|
||||||
|
// The payment verifikat booked expense + 2641 ingående moms. Skipping the
|
||||||
|
// reversal here would leave both the cost and the moms deduction
|
||||||
|
// overstated for as long as the credit stands.
|
||||||
|
const creditNote = makeSupplierInvoice({
|
||||||
|
id: 'credit-1',
|
||||||
|
is_credit_note: true,
|
||||||
|
credited_invoice_id: 'invoice-1',
|
||||||
|
})
|
||||||
|
enqueueMany([
|
||||||
|
{ data: { ...original, status: 'paid', paid_amount: 1250, paid_at: '2026-03-12', payment_journal_entry_id: 'je-payment' }, error: null },
|
||||||
|
{ data: 2, error: null },
|
||||||
|
{ data: creditNote, error: null },
|
||||||
|
{ data: null, error: null },
|
||||||
|
{ data: { accounting_method: 'cash' }, error: null },
|
||||||
|
{ data: null, error: null },
|
||||||
|
{ data: null, error: null },
|
||||||
|
])
|
||||||
|
createCreditEntryMock.mockResolvedValue({ id: 'journal-1' })
|
||||||
|
|
||||||
|
const response = await POST(
|
||||||
|
createMockRequest('/api/supplier-invoices/invoice-1/credit', { method: 'POST' }),
|
||||||
|
createMockRouteParams({ id: 'invoice-1' }),
|
||||||
|
)
|
||||||
|
const { status } = await parseJsonResponse(response)
|
||||||
|
|
||||||
|
expect(status).toBe(200)
|
||||||
|
expect(createCreditEntryMock).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
|||||||
import { eventBus } from '@/lib/events'
|
import { eventBus } from '@/lib/events'
|
||||||
import { ensureInitialized } from '@/lib/init'
|
import { ensureInitialized } from '@/lib/init'
|
||||||
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||||
|
import { supplierCreditNoteNeedsJournalEntry } from '@/lib/bookkeeping/booking-mode'
|
||||||
import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service'
|
import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service'
|
||||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||||
@@ -106,10 +107,13 @@ export const POST = withRouteContext(
|
|||||||
|
|
||||||
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
|
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
|
||||||
|
|
||||||
// Cash method: skip, no original registration entry to reverse;
|
// Kontantmetoden skips only while the original is still UNPAID: nothing
|
||||||
// recognition is deferred until refund.
|
// reached the ledger, so there is nothing to reverse and recognition
|
||||||
|
// rightly waits for cash. A PAID original was already booked by its
|
||||||
|
// payment verifikat (expense + 2641 ingående moms), and leaving that
|
||||||
|
// un-reversed overstates both the cost and the moms deduction.
|
||||||
let journalEntryId: string | null = null
|
let journalEntryId: string | null = null
|
||||||
if (accountingMethod === 'accrual') {
|
if (supplierCreditNoteNeedsJournalEntry(accountingMethod, original)) {
|
||||||
try {
|
try {
|
||||||
// Pass the ORIGINAL items: deferred lines carry their periodisering
|
// Pass the ORIGINAL items: deferred lines carry their periodisering
|
||||||
// fields there, so the credit entry reverses against the same 17xx
|
// fields there, so the credit entry reverses against the same 17xx
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
|||||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||||
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
|
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
|
||||||
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||||
|
import { supplierCreditNoteNeedsJournalEntry } from '@/lib/bookkeeping/booking-mode'
|
||||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||||
import { eventBus } from '@/lib/events'
|
import { eventBus } from '@/lib/events'
|
||||||
@@ -47,18 +48,25 @@ const SI_RESPONSE_COLUMNS =
|
|||||||
// flow are projected. `user_id` and `company_id` were dropped earlier; this
|
// flow are projected. `user_id` and `company_id` were dropped earlier; this
|
||||||
// round drops `notes` (the original SI's free-text notes are never copied
|
// round drops `notes` (the original SI's free-text notes are never copied
|
||||||
// onto the credit note and never inspected) plus several housekeeping
|
// onto the credit note and never inspected) plus several housekeeping
|
||||||
// fields (`paid_at`, `payment_journal_entry_id`, `transaction_id`,
|
// fields (`transaction_id`, `document_id`, `payment_reference`,
|
||||||
// `document_id`, `payment_reference`, `paid_amount`, `delivery_date`,
|
// `delivery_date`, `received_date`, `is_credit_note`, `reversed_at`,
|
||||||
// `received_date`, `is_credit_note`, `reversed_at`, `created_at`,
|
// `created_at`, `updated_at`) that the credit handler never reads. SEK-
|
||||||
// `updated_at`) that the credit handler never reads. SEK-conversion fields
|
// conversion fields (`subtotal_sek` / `vat_amount_sek` / `total_sek`) ARE
|
||||||
// (`subtotal_sek` / `vat_amount_sek` / `total_sek`) ARE read: they're
|
// read: they're copied verbatim onto the credit-note row so the 2440
|
||||||
// copied verbatim onto the credit-note row so the 2440 reversal nets
|
// reversal nets correctly.
|
||||||
// correctly.
|
//
|
||||||
|
// `registration_journal_entry_id`, `payment_journal_entry_id`, `paid_at` and
|
||||||
|
// `paid_amount` were dropped in that round but are read again now: they are
|
||||||
|
// the booked-ness signals supplierCreditNoteNeedsJournalEntry() needs to
|
||||||
|
// decide whether a kontantmetoden credit note must reverse an entry the
|
||||||
|
// payment already posted. `status` alone is too weak, it misses a
|
||||||
|
// part-paid-but-booked original (rows predating the #1413 guard).
|
||||||
const SI_FULL_COLUMNS = `
|
const SI_FULL_COLUMNS = `
|
||||||
id, supplier_id, supplier_invoice_number, invoice_date, status,
|
id, supplier_id, supplier_invoice_number, invoice_date, status,
|
||||||
currency, exchange_rate,
|
currency, exchange_rate,
|
||||||
subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek,
|
subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek,
|
||||||
vat_treatment, reverse_charge, remaining_amount,
|
vat_treatment, reverse_charge, remaining_amount,
|
||||||
|
registration_journal_entry_id, payment_journal_entry_id, paid_at, paid_amount,
|
||||||
is_credit_note, credited_invoice_id, arrival_number, default_dimensions,
|
is_credit_note, credited_invoice_id, arrival_number, default_dimensions,
|
||||||
supplier:suppliers(id, name, supplier_type),
|
supplier:suppliers(id, name, supplier_type),
|
||||||
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions)
|
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions)
|
||||||
@@ -154,6 +162,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
|||||||
reverse_charge: boolean
|
reverse_charge: boolean
|
||||||
remaining_amount: number
|
remaining_amount: number
|
||||||
paid_amount: number
|
paid_amount: number
|
||||||
|
// Booked-ness signals for supplierCreditNoteNeedsJournalEntry().
|
||||||
|
registration_journal_entry_id: string | null
|
||||||
|
payment_journal_entry_id: string | null
|
||||||
|
paid_at: string | null
|
||||||
is_credit_note: boolean
|
is_credit_note: boolean
|
||||||
credited_invoice_id: string | null
|
credited_invoice_id: string | null
|
||||||
supplier_invoice_number: string
|
supplier_invoice_number: string
|
||||||
@@ -338,9 +350,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accrual: post the reversing JE. Cash basis: skip (no original
|
// Post the reversing JE whenever the original actually reached the ledger.
|
||||||
// registration entry to reverse; refund is recognized when the bank
|
// Kontantmetoden skips only while the original is still UNPAID: a paid one
|
||||||
// transaction is booked).
|
// was booked by its payment verifikat (expense + 2641 ingående moms), so
|
||||||
|
// skipping there would overstate both the cost and the moms deduction.
|
||||||
const { data: settings } = await ctx.supabase
|
const { data: settings } = await ctx.supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
.select('accounting_method')
|
.select('accounting_method')
|
||||||
@@ -350,7 +363,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
|||||||
?? 'accrual') as AccountingMethod
|
?? 'accrual') as AccountingMethod
|
||||||
|
|
||||||
let journalEntryId: string | null = null
|
let journalEntryId: string | null = null
|
||||||
if (accountingMethod === 'accrual') {
|
if (supplierCreditNoteNeedsJournalEntry(accountingMethod, typed)) {
|
||||||
try {
|
try {
|
||||||
const entry = await createSupplierCreditNoteEntry(
|
const entry = await createSupplierCreditNoteEntry(
|
||||||
ctx.supabase,
|
ctx.supabase,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { booksInvoicesOnIssue, cashPartialBlockReason } from '../booking-mode'
|
import { booksInvoicesOnIssue, cashPartialBlockReason, supplierCreditNoteNeedsJournalEntry } from '../booking-mode'
|
||||||
|
|
||||||
describe('booksInvoicesOnIssue (#967)', () => {
|
describe('booksInvoicesOnIssue (#967)', () => {
|
||||||
it('books at issue for accrual companies by default', () => {
|
it('books at issue for accrual companies by default', () => {
|
||||||
@@ -66,3 +66,69 @@ describe('cashPartialBlockReason', () => {
|
|||||||
expect(cashPartialBlockReason({ ...base, priorPaidAmount: undefined })).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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -55,3 +55,50 @@ export function cashPartialBlockReason(opts: {
|
|||||||
if (Math.round((opts.priorPaidAmount ?? 0) * 100) !== 0) return 'previously_partially_paid'
|
if (Math.round((opts.priorPaidAmount ?? 0) * 100) !== 0) return 'previously_partially_paid'
|
||||||
return null
|
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,
|
createCreditNoteJournalEntry,
|
||||||
} from '@/lib/bookkeeping/invoice-entries'
|
} from '@/lib/bookkeeping/invoice-entries'
|
||||||
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
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 { ensureManualCashAccount } from '@/lib/cash-accounts/service'
|
||||||
import { createJournalEntry, findFiscalPeriod, getSwedishLocalDate, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
|
import { createJournalEntry, findFiscalPeriod, getSwedishLocalDate, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
|
||||||
import {
|
import {
|
||||||
@@ -3787,7 +3787,10 @@ async function commitCreditSupplierInvoice(
|
|||||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||||
|
|
||||||
let journalEntryId: string | null = null
|
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 {
|
try {
|
||||||
const je = await createSupplierCreditNoteEntry(
|
const je = await createSupplierCreditNoteEntry(
|
||||||
supabase,
|
supabase,
|
||||||
|
|||||||
Reference in New Issue
Block a user