db8983ba9e
* feat(arcim-migration): Briox provider with SIE-over-API import - Briox auth via account ID + application token (no app-level credentials); both tokens rotate on refresh and are persisted - New sie-fetcher pulls the general ledger as SIE through the provider API for Fortnox, Briox and Bjorn Lunden - Wizard stops on a failed SIE import and surfaces the real errors instead of proceeding to the misleading migrate-guard message - PROVIDER_SIE_ONLY_FORTNOX renamed to PROVIDER_SIE_NOT_SUPPORTED; new PROVIDER_TOKEN_INVALID for rejected provider credentials Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): per-line accruals (periodisering) on invoices and supplier invoices Defer revenue/costs per invoice line to 29xx/17xx interim accounts with automatic monthly dissolution (nightly cron + catch-up at registration), schedule cancellation on credit, year-end auto-detect exclusion for already-scheduled invoices, invoice-inbox service-period extraction for prefill, and an MCP tool to list schedules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bokslut): iXBRL arsredovisning generation and Bolagsverket digital filing Generate the annual report as iXBRL from a generated taxonomy registry (K2 element lists, taxonomy:generate/check scripts + CI guard), expose it via the fiscal-period API, and add the bolagsverket extension for digital submission to eget utrymme with webhook-driven status tracking (submissions table + pg tests, lifecycle events, year-end wizard UI). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(mcp): raise origin-guard test timeout to 20s The dynamic import pulls in the full server module; the parse alone flirts with the 5s default under full-suite parallel load. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add new scripts and documentation for K2 AB taxonomy generation and validation - Introduced `generate-taxonomy-registry.ts` to automate the generation of the iXBRL taxonomy concept registry from official element lists and tuple models. - Added `validate-ixbrl.mjs` for validating generated iXBRL reports against the official taxonomy package using Arelle. - Included new documentation files: - `k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx` - `tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx` - `taxonomi-paket-2024-09-12_rev20250312.zip` * Add tests for bookkeeping accruals dissolution and supplier invoices - Implement tests for the POST /api/bookkeeping/accruals/[id]/dissolve route, covering success and error scenarios. - Add tests for the DELETE /api/supplier-invoices/[id] route, including authentication checks and validation of invoice deletion conditions. - Introduce tests for the Arcim migration provider client, ensuring token handling and error classification. - Create tests for the Bolagsverket extension, validating submission role enforcement and environment settings. - Add Zod schemas for Bolagsverket response payloads to ensure proper validation. - Implement tests for MCP server's list accrual schedules, confirming registration and scope mapping. - Add consistency tests for IXBRL document generation, ensuring duplicate facts and XML escaping are handled correctly. - Introduce typed domain errors for accrual schedules to improve error handling in the service. - Add tests for resolving consent with Briox token refresh concurrency, ensuring proper token management and error handling. * fix(tests): update payload size guard comments to reflect recent changes in tool descriptions and ceiling adjustments * fix(gitattributes): mark generated JSON files in bokslut taxonomy as linguist-generated * feat(migrations): add backfill for invoices.journal_entry_id and fallback for next_voucher_number user_id * feat(bokslut): enhance compliance and financial processing features with new submission details and security measures --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
692 lines
22 KiB
TypeScript
692 lines
22 KiB
TypeScript
/**
|
|
* Periodisering (accrual schedule) service.
|
|
*
|
|
* A schedule spreads one invoice line's net amount over the calendar months
|
|
* of a service period. The origin entry (supplier invoice registration /
|
|
* customer invoice revenue entry) books the net to an interim account
|
|
* (17xx/29xx); this service creates and posts the monthly dissolution
|
|
* entries:
|
|
*
|
|
* expense: Dr target (5xxx/6xxx) / Cr balance (17xx)
|
|
* revenue: Dr balance (29xx) / Cr target (3xxx)
|
|
*
|
|
* All entries go through the engine (source_type 'accrual') — never direct
|
|
* inserts. Posting dates are max(period_month, posting_floor_date,
|
|
* company lock date + 1) so catch-up months book correctly and the interim
|
|
* account never goes negative.
|
|
*/
|
|
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import type {
|
|
AccrualDirection,
|
|
AccrualSchedule,
|
|
AccrualScheduleInstallment,
|
|
CreateJournalEntryLineInput,
|
|
} from '@/types'
|
|
import {
|
|
createJournalEntry,
|
|
findFiscalPeriod,
|
|
reverseEntry,
|
|
} from '@/lib/bookkeeping/engine'
|
|
import {
|
|
CannotReverseNonPostedError,
|
|
EntryAlreadyReversedError,
|
|
} from '@/lib/bookkeeping/errors'
|
|
import {
|
|
AccrualNothingToDissolveError,
|
|
AccrualScheduleNotActiveError,
|
|
AccrualScheduleNotFoundError,
|
|
} from '@/lib/bookkeeping/accruals/errors'
|
|
import {
|
|
computeInstallments,
|
|
dayAfter,
|
|
firstOfMonth,
|
|
maxIsoDate,
|
|
} from '@/lib/bookkeeping/accruals/compute'
|
|
import { roundOre, sumOre } from '@/lib/money'
|
|
import { createLogger } from '@/lib/logger'
|
|
|
|
const log = createLogger('bookkeeping.accruals')
|
|
|
|
export interface AccrualScheduleSpec {
|
|
direction: AccrualDirection
|
|
supplierInvoiceId?: string
|
|
supplierInvoiceItemId?: string | null
|
|
invoiceId?: string
|
|
invoiceItemId?: string | null
|
|
/** Interim account: 17xx (expense) / 29xx (revenue). */
|
|
balanceAccount: string
|
|
/** The P&L account the amount dissolves to. */
|
|
targetAccount: string
|
|
/** Net amount in SEK as booked on the origin entry (ex VAT). */
|
|
totalAmountSek: number
|
|
periodStart: string
|
|
periodEnd: string
|
|
description: string
|
|
}
|
|
|
|
export interface PostDueResult {
|
|
posted: number
|
|
failed: number
|
|
skipped: number
|
|
errors: Array<{ installmentId: string; message: string }>
|
|
}
|
|
|
|
type ScheduleRow = AccrualSchedule
|
|
type InstallmentRow = AccrualScheduleInstallment & { schedule?: ScheduleRow | null }
|
|
|
|
function todayIso(): string {
|
|
return new Date().toISOString().slice(0, 10)
|
|
}
|
|
|
|
function getErrorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error)
|
|
}
|
|
|
|
async function fetchLockDateFloor(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
): Promise<string | null> {
|
|
const { data } = await supabase
|
|
.from('company_settings')
|
|
.select('bookkeeping_locked_through')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
const lockedThrough = (data as { bookkeeping_locked_through?: string | null } | null)
|
|
?.bookkeeping_locked_through
|
|
return lockedThrough ? dayAfter(lockedThrough) : null
|
|
}
|
|
|
|
/**
|
|
* Earliest OPEN fiscal period starting after `date`. Used to clamp a posting
|
|
* date forward when the computed date falls inside a closed period (bokslut
|
|
* done) — same spirit as the company lock-date floor.
|
|
*/
|
|
async function findNextOpenPeriodStart(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
date: string,
|
|
): Promise<{ fiscalPeriodId: string; periodStart: string } | null> {
|
|
const { data } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('id, period_start')
|
|
.eq('company_id', companyId)
|
|
.eq('is_closed', false)
|
|
.gt('period_start', date)
|
|
.order('period_start', { ascending: true })
|
|
.limit(1)
|
|
.maybeSingle()
|
|
const row = data as { id: string; period_start: string } | null
|
|
return row ? { fiscalPeriodId: row.id, periodStart: row.period_start } : null
|
|
}
|
|
|
|
function dissolutionLines(
|
|
schedule: Pick<ScheduleRow, 'direction' | 'balance_account' | 'target_account'>,
|
|
amount: number,
|
|
lineDescription: string,
|
|
): CreateJournalEntryLineInput[] {
|
|
if (schedule.direction === 'expense') {
|
|
return [
|
|
{
|
|
account_number: schedule.target_account,
|
|
debit_amount: amount,
|
|
credit_amount: 0,
|
|
line_description: lineDescription,
|
|
},
|
|
{
|
|
account_number: schedule.balance_account,
|
|
debit_amount: 0,
|
|
credit_amount: amount,
|
|
line_description: lineDescription,
|
|
},
|
|
]
|
|
}
|
|
return [
|
|
{
|
|
account_number: schedule.balance_account,
|
|
debit_amount: amount,
|
|
credit_amount: 0,
|
|
line_description: lineDescription,
|
|
},
|
|
{
|
|
account_number: schedule.target_account,
|
|
debit_amount: 0,
|
|
credit_amount: amount,
|
|
line_description: lineDescription,
|
|
},
|
|
]
|
|
}
|
|
|
|
/**
|
|
* Create a schedule + its monthly installments for one deferred invoice
|
|
* line, then immediately post every installment whose month has already
|
|
* begun (catch-up). Catch-up failures do not throw — they are recorded on
|
|
* the installment (last_error) and retried by the daily cron.
|
|
*/
|
|
export async function createAccrualSchedule(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
spec: AccrualScheduleSpec,
|
|
options: {
|
|
originJournalEntryId?: string | null
|
|
/** Entry date of the origin entry; floors all dissolution dates. */
|
|
postingFloorDate: string
|
|
/** Skip synchronous catch-up posting (tests / bulk imports). */
|
|
postCatchUp?: boolean
|
|
today?: string
|
|
},
|
|
): Promise<AccrualSchedule> {
|
|
const plan = computeInstallments(spec.totalAmountSek, spec.periodStart, spec.periodEnd)
|
|
|
|
const { data: schedule, error: scheduleError } = await supabase
|
|
.from('accrual_schedules')
|
|
.insert({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
direction: spec.direction,
|
|
supplier_invoice_id: spec.supplierInvoiceId ?? null,
|
|
supplier_invoice_item_id: spec.supplierInvoiceItemId ?? null,
|
|
invoice_id: spec.invoiceId ?? null,
|
|
invoice_item_id: spec.invoiceItemId ?? null,
|
|
balance_account: spec.balanceAccount,
|
|
target_account: spec.targetAccount,
|
|
total_amount: roundOre(spec.totalAmountSek),
|
|
period_start: spec.periodStart,
|
|
period_end: spec.periodEnd,
|
|
months: plan.length,
|
|
origin_journal_entry_id: options.originJournalEntryId ?? null,
|
|
posting_floor_date: options.postingFloorDate,
|
|
status: 'active',
|
|
description: spec.description,
|
|
})
|
|
.select('*')
|
|
.single()
|
|
|
|
if (scheduleError || !schedule) {
|
|
throw new Error(
|
|
`Failed to create accrual schedule: ${scheduleError?.message ?? 'no row returned'}`,
|
|
)
|
|
}
|
|
|
|
const scheduleRow = schedule as ScheduleRow
|
|
|
|
const { error: installmentError } = await supabase
|
|
.from('accrual_schedule_installments')
|
|
.insert(
|
|
plan.map((installment) => ({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
schedule_id: scheduleRow.id,
|
|
period_month: installment.period_month,
|
|
amount: installment.amount,
|
|
status: 'pending',
|
|
})),
|
|
)
|
|
|
|
if (installmentError) {
|
|
// Schedule without installments is inert but confusing — clean it up.
|
|
await supabase.from('accrual_schedules').delete().eq('id', scheduleRow.id)
|
|
throw new Error(`Failed to create accrual installments: ${installmentError.message}`)
|
|
}
|
|
|
|
if (options.postCatchUp !== false) {
|
|
const result = await postDueInstallments(supabase, companyId, {
|
|
userId,
|
|
scheduleId: scheduleRow.id,
|
|
today: options.today,
|
|
})
|
|
if (result.failed > 0) {
|
|
log.warn('accrual catch-up posting failed for some installments', {
|
|
companyId,
|
|
scheduleId: scheduleRow.id,
|
|
failed: result.failed,
|
|
})
|
|
}
|
|
}
|
|
|
|
return scheduleRow
|
|
}
|
|
|
|
/**
|
|
* Post every pending installment whose calendar month has begun.
|
|
* Used by the daily cron, the manual "Bokför förfallna" action, and the
|
|
* synchronous catch-up at schedule creation (scheduleId filter).
|
|
*
|
|
* Each installment books independently; one failure never blocks the rest.
|
|
*/
|
|
export async function postDueInstallments(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
options: {
|
|
/** Falls back to each schedule's creator for cron runs. */
|
|
userId?: string
|
|
scheduleId?: string
|
|
today?: string
|
|
} = {},
|
|
): Promise<PostDueResult> {
|
|
const today = options.today ?? todayIso()
|
|
const result: PostDueResult = { posted: 0, failed: 0, skipped: 0, errors: [] }
|
|
|
|
let query = supabase
|
|
.from('accrual_schedule_installments')
|
|
.select('*, schedule:accrual_schedules(*)')
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'pending')
|
|
.lte('period_month', firstOfMonth(today))
|
|
.order('period_month', { ascending: true })
|
|
|
|
if (options.scheduleId) {
|
|
query = query.eq('schedule_id', options.scheduleId)
|
|
}
|
|
|
|
const { data, error } = await query
|
|
if (error) {
|
|
throw new Error(`Failed to load due accrual installments: ${error.message}`)
|
|
}
|
|
|
|
const due = ((data ?? []) as InstallmentRow[]).filter(
|
|
(installment) => installment.schedule?.status === 'active',
|
|
)
|
|
if (due.length === 0) return result
|
|
|
|
const lockFloor = await fetchLockDateFloor(supabase, companyId)
|
|
const touchedSchedules = new Set<string>()
|
|
|
|
for (const installment of due) {
|
|
const schedule = installment.schedule as ScheduleRow
|
|
try {
|
|
let entryDate = maxIsoDate(
|
|
installment.period_month,
|
|
schedule.posting_floor_date,
|
|
lockFloor,
|
|
)
|
|
let fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
|
|
if (!fiscalPeriodId) {
|
|
// The computed date can fall inside a CLOSED fiscal period (bokslut
|
|
// done while the company lock date lags behind). Clamp forward to the
|
|
// start of the earliest open period — same spirit as the lockFloor —
|
|
// instead of retrying the same impossible date forever.
|
|
const clamped = await findNextOpenPeriodStart(supabase, companyId, entryDate)
|
|
if (!clamped) {
|
|
throw new Error(
|
|
`Ingen öppen räkenskapsperiod för ${entryDate} eller senare — ` +
|
|
'skapa nästa räkenskapsår för att kunna bokföra periodiseringen',
|
|
)
|
|
}
|
|
entryDate = clamped.periodStart
|
|
fiscalPeriodId = clamped.fiscalPeriodId
|
|
}
|
|
|
|
const monthLabel = installment.period_month.slice(0, 7)
|
|
const description = schedule.description
|
|
? `Periodisering ${monthLabel}: ${schedule.description}`
|
|
: `Periodisering ${monthLabel}`
|
|
|
|
const entry = await createJournalEntry(
|
|
supabase,
|
|
companyId,
|
|
options.userId ?? schedule.user_id,
|
|
{
|
|
fiscal_period_id: fiscalPeriodId,
|
|
entry_date: entryDate,
|
|
description,
|
|
source_type: 'accrual',
|
|
source_id: schedule.id,
|
|
lines: dissolutionLines(schedule, installment.amount, description),
|
|
},
|
|
)
|
|
|
|
// CAS claim: only the runner that flips pending→posted keeps its entry.
|
|
// A concurrent runner (cron + manual button) loses the race and stornos
|
|
// its own entry so the ledger nets to a single dissolution.
|
|
const { data: claimed, error: claimError } = await supabase
|
|
.from('accrual_schedule_installments')
|
|
.update({
|
|
status: 'posted',
|
|
journal_entry_id: entry.id,
|
|
posted_at: new Date().toISOString(),
|
|
last_error: null,
|
|
})
|
|
.eq('id', installment.id)
|
|
.eq('status', 'pending')
|
|
.select('id')
|
|
|
|
if (claimError) {
|
|
// The journal entry is already committed but the installment was NOT
|
|
// flipped to posted — without a storno the next run would book the
|
|
// same month twice. Mirror the dissolveScheduleNow handling: reverse
|
|
// our own entry best-effort, then record the failure (the catch below
|
|
// writes last_error on the installment).
|
|
let reversalNote = ''
|
|
try {
|
|
await reverseEntry(
|
|
supabase,
|
|
companyId,
|
|
options.userId ?? schedule.user_id,
|
|
entry.id,
|
|
)
|
|
} catch (reversalError) {
|
|
log.error('failed to reverse accrual entry after claim error', reversalError, {
|
|
companyId,
|
|
installmentId: installment.id,
|
|
entryId: entry.id,
|
|
})
|
|
reversalNote =
|
|
`; storno av verifikat ${entry.id} misslyckades också: ` +
|
|
getErrorMessage(reversalError)
|
|
}
|
|
throw new Error(
|
|
`Failed to mark installment posted: ${claimError.message}${reversalNote}`,
|
|
)
|
|
}
|
|
if (!claimed || claimed.length === 0) {
|
|
log.warn('accrual installment claimed concurrently; reversing duplicate entry', {
|
|
companyId,
|
|
installmentId: installment.id,
|
|
entryId: entry.id,
|
|
})
|
|
await reverseEntry(
|
|
supabase,
|
|
companyId,
|
|
options.userId ?? schedule.user_id,
|
|
entry.id,
|
|
)
|
|
result.skipped++
|
|
continue
|
|
}
|
|
|
|
touchedSchedules.add(schedule.id)
|
|
result.posted++
|
|
} catch (error) {
|
|
const message = getErrorMessage(error)
|
|
log.error('failed to post accrual installment', error, {
|
|
companyId,
|
|
installmentId: installment.id,
|
|
scheduleId: schedule.id,
|
|
})
|
|
result.failed++
|
|
result.errors.push({ installmentId: installment.id, message })
|
|
await supabase
|
|
.from('accrual_schedule_installments')
|
|
.update({ last_error: message })
|
|
.eq('id', installment.id)
|
|
.eq('status', 'pending')
|
|
}
|
|
}
|
|
|
|
for (const scheduleId of touchedSchedules) {
|
|
await completeScheduleIfDone(supabase, companyId, scheduleId)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
async function completeScheduleIfDone(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
scheduleId: string,
|
|
): Promise<void> {
|
|
const { count, error } = await supabase
|
|
.from('accrual_schedule_installments')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('company_id', companyId)
|
|
.eq('schedule_id', scheduleId)
|
|
.eq('status', 'pending')
|
|
|
|
if (error || count === null || count > 0) return
|
|
|
|
await supabase
|
|
.from('accrual_schedules')
|
|
.update({ status: 'completed' })
|
|
.eq('id', scheduleId)
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'active')
|
|
}
|
|
|
|
/**
|
|
* Dissolve everything that remains on a schedule in one entry, dated today
|
|
* (clamped by lock date / posting floor). Used when the service period ends
|
|
* early or the user simply wants the rest expensed now.
|
|
*/
|
|
export async function dissolveScheduleNow(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
scheduleId: string,
|
|
options: { today?: string } = {},
|
|
): Promise<{ journalEntryId: string; amount: number }> {
|
|
const today = options.today ?? todayIso()
|
|
|
|
const { data: scheduleData, error: scheduleError } = await supabase
|
|
.from('accrual_schedules')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.eq('id', scheduleId)
|
|
.single()
|
|
|
|
if (scheduleError || !scheduleData) {
|
|
throw new AccrualScheduleNotFoundError()
|
|
}
|
|
const schedule = scheduleData as ScheduleRow
|
|
if (schedule.status !== 'active') {
|
|
throw new AccrualScheduleNotActiveError(schedule.status)
|
|
}
|
|
|
|
const { data: pendingData, error: pendingError } = await supabase
|
|
.from('accrual_schedule_installments')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.eq('schedule_id', scheduleId)
|
|
.eq('status', 'pending')
|
|
|
|
if (pendingError) {
|
|
throw new Error(`Failed to load installments: ${pendingError.message}`)
|
|
}
|
|
const pending = (pendingData ?? []) as InstallmentRow[]
|
|
if (pending.length === 0) {
|
|
await completeScheduleIfDone(supabase, companyId, scheduleId)
|
|
throw new AccrualNothingToDissolveError()
|
|
}
|
|
|
|
const amount = sumOre(pending.map((installment) => installment.amount))
|
|
|
|
const lockFloor = await fetchLockDateFloor(supabase, companyId)
|
|
const entryDate = maxIsoDate(today, schedule.posting_floor_date, lockFloor)
|
|
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
|
|
if (!fiscalPeriodId) {
|
|
throw new Error(`Ingen öppen räkenskapsperiod för ${entryDate}`)
|
|
}
|
|
|
|
const description = schedule.description
|
|
? `Periodisering, slutupplösning: ${schedule.description}`
|
|
: 'Periodisering, slutupplösning'
|
|
|
|
const entry = await createJournalEntry(supabase, companyId, userId, {
|
|
fiscal_period_id: fiscalPeriodId,
|
|
entry_date: entryDate,
|
|
description,
|
|
source_type: 'accrual',
|
|
source_id: schedule.id,
|
|
lines: dissolutionLines(schedule, amount, description),
|
|
})
|
|
|
|
const pendingIds = pending.map((installment) => installment.id)
|
|
const { data: claimed, error: claimError } = await supabase
|
|
.from('accrual_schedule_installments')
|
|
.update({
|
|
status: 'posted',
|
|
journal_entry_id: entry.id,
|
|
posted_at: new Date().toISOString(),
|
|
last_error: null,
|
|
})
|
|
.in('id', pendingIds)
|
|
.eq('status', 'pending')
|
|
.select('id')
|
|
|
|
if (claimError || !claimed || claimed.length !== pendingIds.length) {
|
|
// Concurrent posting changed the set under us — undo our combined entry
|
|
// and let the caller retry against the new state. If the storno itself
|
|
// fails, the combined entry stands while some installments point at the
|
|
// cron's entries — the interim account would dissolve twice. Surface
|
|
// both failures and pin the alert on the installments so the
|
|
// periodiseringar UI shows the stuck state instead of nothing.
|
|
try {
|
|
await reverseEntry(supabase, companyId, userId, entry.id)
|
|
} catch (reversalError) {
|
|
const message = getErrorMessage(reversalError)
|
|
log.error('failed to reverse combined dissolution entry after lost claim race', reversalError, {
|
|
companyId,
|
|
scheduleId,
|
|
entryId: entry.id,
|
|
})
|
|
await supabase
|
|
.from('accrual_schedule_installments')
|
|
.update({
|
|
last_error: `Storno av samlad upplösning (verifikat ${entry.id}) misslyckades — kontrollera interimskontot: ${message}`.slice(0, 2_000),
|
|
})
|
|
.in('id', pendingIds)
|
|
.eq('company_id', companyId)
|
|
throw new Error(
|
|
`Periodiseringen ändrades samtidigt och vändningen av det samlade verifikatet misslyckades: ${message}`,
|
|
)
|
|
}
|
|
throw new Error('Periodiseringen ändrades samtidigt — försök igen')
|
|
}
|
|
|
|
await supabase
|
|
.from('accrual_schedules')
|
|
.update({ status: 'completed' })
|
|
.eq('id', scheduleId)
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'active')
|
|
|
|
return { journalEntryId: entry.id, amount }
|
|
}
|
|
|
|
/**
|
|
* Cancel all schedules belonging to a credited/cancelled invoice:
|
|
* pending installments are cancelled, already-posted dissolutions are
|
|
* reversed (storno), and the schedule is marked cancelled. The caller's
|
|
* credit-note entry reverses the interim account at its full original
|
|
* amount, so the net of origin + dissolutions + stornos + credit is zero.
|
|
*
|
|
* A schedule is only marked cancelled when ALL its posted dissolutions
|
|
* reversed cleanly — otherwise it stays 'active' (so the UI keeps showing
|
|
* the un-reversed remainder instead of remaining=0 while 17xx/29xx is still
|
|
* unbalanced) and the stuck installments get a descriptive last_error.
|
|
* Callers should surface `failedReversals > 0` as a warning.
|
|
*/
|
|
export async function cancelSchedulesForSource(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
source: { supplierInvoiceId?: string; invoiceId?: string },
|
|
options: { reversalDate?: string } = {},
|
|
): Promise<{
|
|
cancelledSchedules: number
|
|
reversedEntries: number
|
|
failedReversals: number
|
|
}> {
|
|
if (!source.supplierInvoiceId && !source.invoiceId) {
|
|
throw new Error('cancelSchedulesForSource requires a source invoice id')
|
|
}
|
|
|
|
let query = supabase
|
|
.from('accrual_schedules')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.neq('status', 'cancelled')
|
|
query = source.supplierInvoiceId
|
|
? query.eq('supplier_invoice_id', source.supplierInvoiceId)
|
|
: query.eq('invoice_id', source.invoiceId as string)
|
|
|
|
const { data, error } = await query
|
|
if (error) {
|
|
throw new Error(`Failed to load accrual schedules: ${error.message}`)
|
|
}
|
|
const schedules = (data ?? []) as ScheduleRow[]
|
|
|
|
let cancelledSchedules = 0
|
|
let reversedEntries = 0
|
|
let failedReversals = 0
|
|
|
|
for (const schedule of schedules) {
|
|
// Cancel pending months first so the posting cron cannot book new
|
|
// dissolutions for a credited invoice while (or after) we storno the
|
|
// already-posted ones.
|
|
await supabase
|
|
.from('accrual_schedule_installments')
|
|
.update({ status: 'cancelled' })
|
|
.eq('company_id', companyId)
|
|
.eq('schedule_id', schedule.id)
|
|
.eq('status', 'pending')
|
|
|
|
const { data: postedData } = await supabase
|
|
.from('accrual_schedule_installments')
|
|
.select('id, journal_entry_id')
|
|
.eq('company_id', companyId)
|
|
.eq('schedule_id', schedule.id)
|
|
.eq('status', 'posted')
|
|
|
|
let scheduleFailures = 0
|
|
for (const installment of (postedData ?? []) as Array<{
|
|
id: string
|
|
journal_entry_id: string | null
|
|
}>) {
|
|
if (!installment.journal_entry_id) continue
|
|
try {
|
|
await reverseEntry(
|
|
supabase,
|
|
companyId,
|
|
userId,
|
|
installment.journal_entry_id,
|
|
options.reversalDate,
|
|
)
|
|
reversedEntries++
|
|
} catch (error) {
|
|
// A dissolution stornoed by an earlier (partially failed) cancel run
|
|
// is fine — idempotent re-credit, treated as success.
|
|
if (
|
|
error instanceof EntryAlreadyReversedError ||
|
|
(error instanceof CannotReverseNonPostedError &&
|
|
error.currentStatus === 'reversed')
|
|
) {
|
|
continue
|
|
}
|
|
scheduleFailures++
|
|
const message = getErrorMessage(error)
|
|
log.warn('could not reverse accrual dissolution during cancel', {
|
|
companyId,
|
|
scheduleId: schedule.id,
|
|
journalEntryId: installment.journal_entry_id,
|
|
message,
|
|
})
|
|
// Posted installments freeze their financial fields, but last_error
|
|
// stays writable — surface the stuck storno in the periodiseringar UI.
|
|
await supabase
|
|
.from('accrual_schedule_installments')
|
|
.update({
|
|
last_error: `Storno vid kreditering misslyckades: ${message}`,
|
|
})
|
|
.eq('id', installment.id)
|
|
.eq('company_id', companyId)
|
|
}
|
|
}
|
|
|
|
if (scheduleFailures > 0) {
|
|
failedReversals += scheduleFailures
|
|
continue
|
|
}
|
|
|
|
await supabase
|
|
.from('accrual_schedules')
|
|
.update({ status: 'cancelled' })
|
|
.eq('id', schedule.id)
|
|
.eq('company_id', companyId)
|
|
cancelledSchedules++
|
|
}
|
|
|
|
return { cancelledSchedules, reversedEntries, failedReversals }
|
|
}
|