Files
accounted/lib/invoices/issue-credit-note.ts
T
Mattsson 072aedeaf9 Fix/supp ag fb (#1023)
* fix: prevent credit notes from entering payment flow

* fix: persist and display customer personal numbers

* feat: configure automatic invoice reminder days

* fix: issue credit notes through send flow

* chore: add repository agent guidance

* feat(mcp): route tools across user companies

* fix(articles): delete unused register entries

* feat(invoices): improve issued invoice actions

* feat(supplier-invoices): retain uploaded source documents

* docs: record implementation decisions

* feat: enhance customer personal number handling and validation

- Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers.
- Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema.
- Implemented masking and encryption for personal numbers to enhance data protection.
- Introduced new utility functions for masking and encrypting personal numbers.
- Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries.
- Enhanced error handling and logging for credit note issuance and invoice processing.
- Updated tests to cover new credit note creation guards and personal number handling.

* test: enhance list companies test with supabase query mocks
2026-07-15 15:53:15 +02:00

281 lines
8.7 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service'
import type { Logger } from '@/lib/logger'
import type { AccountingMethod, CreditNote, EntityType } from '@/types'
export interface CreditNoteOriginalInvoice {
id: string
invoice_number: string | null
status: string
journal_entry_id?: string | null
paid_at?: string | null
paid_amount?: number | null
total?: number | null
}
export interface CreditNoteIssueFailure {
step: 'journal_entry' | 'journal_link' | 'accrual_schedules' | 'original_status'
reason: string
}
interface IssueCreditNoteInput {
supabase: SupabaseClient
companyId: string
userId: string
creditNote: CreditNote & { customer?: { name?: string | null } | null }
originalInvoice: CreditNoteOriginalInvoice
entityType: EntityType
accountingMethod: AccountingMethod
log: Logger
}
export interface IssueCreditNoteResult {
complete: boolean
journalEntryId: string | null
journalEntryRequired: boolean
repairRequired: boolean
failures: CreditNoteIssueFailure[]
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Okänt fel'
}
/**
* Faktureringsmetoden always books the credit on issue. Kontantmetoden only
* books it when the original sale has already reached the ledger, for example
* through a completed payment or a year-end receivable entry.
*/
export function creditNoteNeedsJournalEntry(
accountingMethod: AccountingMethod,
originalInvoice: CreditNoteOriginalInvoice,
): boolean {
return (
accountingMethod === 'accrual' ||
!!originalInvoice.journal_entry_id ||
originalInvoice.status === 'paid' ||
!!originalInvoice.paid_at ||
Math.abs(originalInvoice.paid_amount ?? 0) > 0
)
}
async function getOriginalVoucherRef(
supabase: SupabaseClient,
companyId: string,
journalEntryId: string | null | undefined,
log: Logger,
): Promise<string | undefined> {
if (!journalEntryId) return undefined
const { data, error } = await supabase
.from('journal_entries')
.select('voucher_series, voucher_number')
.eq('id', journalEntryId)
.eq('company_id', companyId)
.maybeSingle()
if (error) {
log.warn('failed to load original voucher reference for credit note', error)
return undefined
}
if (!data?.voucher_series || data.voucher_number == null) return undefined
return `${data.voucher_series}-${data.voucher_number}`
}
async function findExistingCreditJournalEntry(
supabase: SupabaseClient,
companyId: string,
creditNote: CreditNote,
): Promise<string | null> {
if (creditNote.journal_entry_id) return creditNote.journal_entry_id
const { data, error } = await supabase
.from('journal_entries')
.select('id')
.eq('company_id', companyId)
.eq('source_type', 'credit_note')
.eq('source_id', creditNote.id)
.eq('status', 'posted')
.maybeSingle()
if (error) throw error
return data?.id ?? null
}
/**
* Completes the accounting side of credit-note issuance after the caller has
* won the draft-to-sent compare-and-set. Every step is idempotent so a sent
* credit note with incomplete bookkeeping can be repaired without creating a
* second immutable voucher.
*/
export async function issueCreditNote(input: IssueCreditNoteInput): Promise<IssueCreditNoteResult> {
const {
supabase,
companyId,
userId,
creditNote,
originalInvoice,
entityType,
accountingMethod,
log,
} = input
const failures: CreditNoteIssueFailure[] = []
const journalEntryRequired = creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
let journalEntryId: string | null = null
const issuedCreditNote = { ...creditNote, status: 'sent' as const }
if (journalEntryRequired) {
try {
journalEntryId = await findExistingCreditJournalEntry(
supabase,
companyId,
issuedCreditNote,
)
if (!journalEntryId) {
const originalVoucherRef = await getOriginalVoucherRef(
supabase,
companyId,
originalInvoice.journal_entry_id,
log,
)
const journalEntry = await createCreditNoteJournalEntry(
supabase,
companyId,
userId,
issuedCreditNote,
entityType,
creditNote.customer?.name ?? undefined,
originalVoucherRef,
)
journalEntryId = journalEntry?.id ?? null
}
} catch (error) {
// A concurrent issuer may have won the unique posted-source guard after
// our initial lookup. Re-read and reuse that immutable voucher.
try {
journalEntryId = await findExistingCreditJournalEntry(
supabase,
companyId,
issuedCreditNote,
)
} catch (recoveryError) {
log.error('failed to recover credit note journal entry after create conflict', recoveryError, {
creditNoteId: creditNote.id,
})
}
if (!journalEntryId) {
log.error('failed to create or recover credit note journal entry on issue', error, {
creditNoteId: creditNote.id,
})
failures.push({ step: 'journal_entry', reason: errorMessage(error) })
}
}
if (!journalEntryId) {
if (failures.length === 0) {
failures.push({
step: 'journal_entry',
reason: 'Ingen öppen bokföringsperiod hittades för kreditfakturans datum.',
})
}
return { complete: false, journalEntryId, journalEntryRequired, repairRequired: false, failures }
}
if (creditNote.journal_entry_id !== journalEntryId) {
const { data: linkedRows, error: linkError } = await supabase
.from('invoices')
.update({ journal_entry_id: journalEntryId })
.eq('id', creditNote.id)
.eq('company_id', companyId)
.eq('status', 'sent')
.select('id')
if (linkError || !linkedRows || linkedRows.length === 0) {
log.error('failed to link credit note to journal entry', linkError ?? undefined, {
creditNoteId: creditNote.id,
journalEntryId,
})
failures.push({
step: 'journal_link',
reason: linkError?.message ?? 'Kreditfakturan kunde inte kopplas till verifikatet.',
})
return { complete: false, journalEntryId, journalEntryRequired, repairRequired: true, failures }
}
}
if (accountingMethod === 'accrual') {
try {
const cancelResult = await cancelSchedulesForSource(
supabase,
companyId,
userId,
{ invoiceId: originalInvoice.id },
{ reversalDate: creditNote.invoice_date },
)
if (cancelResult.failedReversals > 0) {
failures.push({
step: 'accrual_schedules',
reason:
'En eller flera periodiseringsverifikat kunde inte vändas. ' +
'Kontrollera Bokföring > Periodiseringar.',
})
}
} catch (error) {
log.warn('failed to cancel accrual schedules for credited invoice', error)
failures.push({ step: 'accrual_schedules', reason: errorMessage(error) })
}
if (failures.length > 0) {
return { complete: false, journalEntryId, journalEntryRequired, repairRequired: true, failures }
}
}
}
let originalStatusChanged = false
if (originalInvoice.status !== 'credited') {
const { data: updatedOriginal, error: originalStatusError } = await supabase
.from('invoices')
.update({ status: 'credited' })
.eq('id', originalInvoice.id)
.eq('company_id', companyId)
.in('status', ['sent', 'paid', 'overdue'])
.select('id')
if (originalStatusError || !updatedOriginal || updatedOriginal.length === 0) {
log.error('failed to mark original invoice as credited', originalStatusError ?? undefined, {
originalInvoiceId: originalInvoice.id,
creditNoteId: creditNote.id,
})
failures.push({
step: 'original_status',
reason: originalStatusError?.message ?? 'Originalfakturan kunde inte markeras som krediterad.',
})
return {
complete: false,
journalEntryId,
journalEntryRequired,
repairRequired: journalEntryRequired && !!journalEntryId,
failures,
}
}
originalStatusChanged = true
}
if (originalStatusChanged) {
try {
await eventBus.emit({
type: 'credit_note.created',
payload: { creditNote: issuedCreditNote, companyId, userId },
})
} catch (error) {
log.warn('credit_note.created emit failed after completed issuance', error)
}
}
return { complete: true, journalEntryId, journalEntryRequired, repairRequired: false, failures }
}