d73f288927
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>
105 lines
4.7 KiB
TypeScript
105 lines
4.7 KiB
TypeScript
/**
|
|
* #967 "Registrera men bokför inte": whether issuing an invoice (registering
|
|
* a supplier invoice, sending a customer invoice) books it inline.
|
|
*
|
|
* Inline booking happens only under faktureringsmetoden (accrual) with
|
|
* defer_invoice_booking off. Kontantmetoden companies never book at issue
|
|
* (they book at payment), and deferred companies book via the explicit
|
|
* "Bokför" routes (POST /api/supplier-invoices/[id]/book,
|
|
* POST /api/invoices/[id]/book) instead.
|
|
*
|
|
* The payment flows need no gate of their own: both mark-paid paths already
|
|
* route on whether a live journal-entry link exists, so an invoice that is
|
|
* still unbooked when paid gets the full cash-style entry at payment.
|
|
*/
|
|
export function booksInvoicesOnIssue(
|
|
settings:
|
|
| { accounting_method?: string | null; defer_invoice_booking?: boolean | null }
|
|
| null
|
|
| undefined
|
|
): boolean {
|
|
// No settings row: match the historical default (accrual, book at issue).
|
|
if (!settings) return true
|
|
return (settings.accounting_method || 'accrual') === 'accrual' && !settings.defer_invoice_booking
|
|
}
|
|
|
|
/**
|
|
* Kontantmetoden guard for the GENERATED payment entries on never-booked
|
|
* invoices. createInvoiceCashEntry and createSupplierInvoiceCashEntry always
|
|
* book the FULL invoice (revenue or expense + VAT + a full-total settlement
|
|
* leg); neither takes a payment amount. A generated cash entry is therefore
|
|
* only valid when the payment settles the invoice in full from a fully
|
|
* unpaid state:
|
|
*
|
|
* - a partial payment would book the whole invoice against a smaller bank
|
|
* movement and declare the whole VAT at once, but bokslutsmetoden reports
|
|
* moms at payment, so each installment's moms belongs to its own receipt
|
|
* period;
|
|
* - completing a previously part-paid invoice would book the full total a
|
|
* second time on the settlement account.
|
|
*
|
|
* Callers reject with INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED (customer) or
|
|
* SI_CASH_PARTIAL_UNSUPPORTED (supplier) until per-installment recognition
|
|
* exists. Invoices already booked at issue are never affected: their payment
|
|
* is a plain clearing entry against 1510/2440, which handles partials fine.
|
|
*/
|
|
export function cashPartialBlockReason(opts: {
|
|
invoiceAlreadyBooked: boolean
|
|
accountingMethod: string
|
|
priorPaidAmount: number | null | undefined
|
|
paysRemainingInFull: boolean
|
|
}): 'partial_payment' | 'previously_partially_paid' | null {
|
|
if (opts.invoiceAlreadyBooked) return null
|
|
if ((opts.accountingMethod || 'accrual') !== 'cash') return null
|
|
if (!opts.paysRemainingInFull) return 'partial_payment'
|
|
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
|
|
)
|
|
}
|