feat: voucher linking, recovery ops, and salary overrides (#591)
* feat: voucher linking, recovery ops, and salary overrides Adds reversible/correction-style write paths that customers and agents have been asking for, plus per-run salary employee overrides. Invoice → voucher linking - POST /api/invoices/[id]/link-to-voucher and GET /api/invoices/[id]/voucher-candidates - lib/invoices/voucher-matching.ts with full + pg test coverage - LinkVoucherPicker UI in PaymentBookingDialog - pending_operations.operation_type expanded with link_invoice_voucher (medium risk) and a (journal_entry_id, invoice_id) unique guard - MCP: gnubok_find_voucher_candidates_for_invoice and gnubok_link_invoice_to_voucher tools SIE undo - POST /api/import/sie/[id]/undo + undo_sie_import RPC - sie_imports.status gains 'undone' - ImportResultStep surfaces the action; structured error SIE_UNDO_FAILED Edit-recreate journal entries - POST /api/bookkeeping/journal-entries/[id]/edit-recreate - Bookkeeping detail page wires it into the existing edit flow Delete-last-voucher clears IB link - Trigger + pg test ensure deleting the last voucher of a period nulls the opening_balance_journal_entry_id link so a re-import lands cleanly Salary employee overrides - salary_run_employees gains per-run override fields + migration - lib/salary/effective-values.ts centralises resolved values; all payslip, payment, AGI, KU, and booking routes read through it - SalaryOverridePanel on the employee detail page Account classifier - lib/bookkeeping/account-classifier.ts + tests; AddAccountDialog uses it - backfill-import-accounts script updated Misc - toast: minor styling tweak - AGI generate-declaration: respect effective values - structured-errors: new LINK_INVOICE_VOUCHER namespace Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add link_invoice_voucher operation type to pending_operations * feat: refactor salary run calculations and update error handling for SIE imports * fix: PR review feedback on voucher linking and SIE recovery pg-real (blocking): - tests/pg/delete-last-voucher-ib: drop posted_at = now() from the seed UPDATE — journal_entries has no posted_at column. - lib/invoices/__tests__/voucher-matching.pg: seed the posted voucher before closing the fiscal period so enforce_period_lock doesn't block the INSERT during setup. voucher-matching error codes and rollback: - Add LINK_VOUCHER_DB_ERROR (HTTP 500) and return it on real invoice UPDATE / payment INSERT failures. Previously these returned LINK_VOUCHER_VOUCHER_NOT_FOUND (404) which the pending-op dispatcher auto-rejects on transient DB errors. - Log rollback failures explicitly so an invoice left in a half-linked state (advanced status, no payment row) surfaces for manual reconciliation instead of disappearing silently. resyncNextPeriodOpeningBalance ordering: - Create the new IB first, relink the period FK, then storno the old IB. Previously the storno ran first; if createJournalEntry failed the next period was left with a reversed IB and nothing to replace it, and executeSIEImport swallows the error as a non-fatal warning. replace_period_opening_balance_link: - Tighten role check to owner/admin (was owner/admin/member). Matches delete_last_voucher and undo_sie_import. Data minimisation: - /api/invoices/[id]/voucher-candidates and the matching MCP tools now project only the invoice and customer fields the matcher reads, instead of returning the full customer row. Schema bounds: - SalaryEmployeeOverrideSchema caps each numeric override at 10 MSEK to catch typos before they reach the ledger or AGI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): supply user_id when seeding voucher_sequences voucher_sequences.user_id is NOT NULL (per the multi-tenant refactor in 20260330130000). The previous test seed only set company_id / fiscal_period_id / voucher_series, which made the seed fail with a constraint violation on the latest pg-real run. Pass the same userId used elsewhere in the seed helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): scope delete-last-voucher RPC assertions inside the tx withUserContext always ROLLBACKs, so any DELETE the RPC performs is discarded when the callback returns. The previous test then queried journal_entries via a fresh getPool() connection that only saw the pre-RPC committed seed state — hence "expected '1' to be '0'". Move every post-RPC assertion (entry count, period FK clear, opening_balances_set flip, audit log entry, sie_imports clear) inside the same withUserContext callback so they observe the uncommitted state before ROLLBACK fires. Also fix the sie_imports INSERT: the column is `filename`, not `file_name`, and `sie_type` is NOT NULL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): assert against the IB-marker audit row directly DELETE on journal_entries fires two audit_log writes: the generic write_audit_log() trigger row ("Deleted journal_entries record") and the delete_last_voucher RPC's explicit "(was period IB)" entry. Both land at the same statement_timestamp(), so ORDER BY created_at DESC LIMIT 1 returned the trigger row non-deterministically in CI. Switch to a presence check with a LIKE filter on the IB marker so the test verifies what it actually cares about — that the RPC's IB-aware audit row exists. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(db): set company_id on delete_last_voucher audit_log rows 20260528120000_delete_last_voucher_clears_ib_link.sql inserts directly into audit_log without setting company_id. audit_log's SELECT policy filters company_id IN user_company_ids(), so those rows landed with company_id=NULL and were invisible to every reader — only the generic write_audit_log() trigger row remained visible. That broke BFL audit- trail intent: the "(was period IB)" provenance row was never readable. Republish delete_last_voucher with p_company_id populated on both audit_log INSERTs (draft path and posted path). Behavior is otherwise unchanged; the pg-real test for the IB-clear flow now sees the RPC-written marker row as expected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
Emil
parent
cb7eac90b1
commit
ccdfed5fea
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
||||
import type { BASAccount } from '@/types'
|
||||
import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
|
||||
import type { SIEAccount, SIEAccountMappingRecord } from '../types'
|
||||
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
|
||||
import {
|
||||
suggestMappings,
|
||||
validateMappings,
|
||||
@@ -15,14 +16,7 @@ import {
|
||||
|
||||
function makeBASAccount(number: string, name: string): BASAccount {
|
||||
const classNum = parseInt(number.charAt(0), 10)
|
||||
const accountType =
|
||||
classNum <= 1
|
||||
? 'asset'
|
||||
: classNum === 2
|
||||
? 'liability'
|
||||
: classNum === 3
|
||||
? 'revenue'
|
||||
: 'expense'
|
||||
const classified = classifyAccount(number)
|
||||
return {
|
||||
id: `bas-${number}`,
|
||||
user_id: 'user-1',
|
||||
@@ -31,8 +25,8 @@ function makeBASAccount(number: string, name: string): BASAccount {
|
||||
account_name: name,
|
||||
account_class: classNum,
|
||||
account_group: number.substring(0, 2),
|
||||
account_type: accountType,
|
||||
normal_balance: classNum <= 1 || classNum >= 4 ? 'debit' : 'credit',
|
||||
account_type: classified.account_type,
|
||||
normal_balance: classified.normal_balance,
|
||||
plan_type: 'k1',
|
||||
is_active: true,
|
||||
is_system_account: false,
|
||||
|
||||
+269
-12
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import type {
|
||||
ParsedSIEFile,
|
||||
AccountMapping,
|
||||
@@ -20,6 +20,7 @@ import type { CreateJournalEntryLineInput } from '@/types'
|
||||
import { mappingsToMap, getMappingStats } from './account-mapper'
|
||||
import { calculateFileHash } from './sie-parser'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
|
||||
import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
|
||||
import { populateTemplatesFromSieVouchers } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
|
||||
@@ -196,6 +197,60 @@ export async function replaceSIEImport(
|
||||
return { success: true, deletedEntries: deletedCount as number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo a completed SIE import by hard-deleting its entries (transaction
|
||||
* vouchers + opening_balance) and resetting voucher_sequences, without
|
||||
* requiring a replacement file. Marks sie_imports.status='undone'.
|
||||
*
|
||||
* Pre-flight checks mirror replaceSIEImport so the user gets a Swedish
|
||||
* error message before the RPC raises. The RPC itself is idempotent on
|
||||
* status — calling twice surfaces the "not in completed status" error.
|
||||
*/
|
||||
export async function undoSIEImport(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
importId: string
|
||||
): Promise<{ success: boolean; deletedEntries: number; error?: string }> {
|
||||
const { data: importRecord } = await supabase
|
||||
.from('sie_imports')
|
||||
.select('status, fiscal_period_id')
|
||||
.eq('id', importId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!importRecord) {
|
||||
return { success: false, deletedEntries: 0, error: 'Import hittades inte' }
|
||||
}
|
||||
|
||||
if (importRecord.status !== 'completed') {
|
||||
return { success: false, deletedEntries: 0, error: `Kan bara ångra slutförda importer (status: ${importRecord.status})` }
|
||||
}
|
||||
|
||||
if (importRecord.fiscal_period_id) {
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('is_closed, locked_at')
|
||||
.eq('id', importRecord.fiscal_period_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (period?.is_closed || period?.locked_at) {
|
||||
return { success: false, deletedEntries: 0, error: 'Kan inte ångra import i ett låst eller stängt räkenskapsår. Öppna perioden först.' }
|
||||
}
|
||||
}
|
||||
|
||||
const { data: deletedCount, error: rpcError } = await supabase.rpc('undo_sie_import', {
|
||||
p_company_id: companyId,
|
||||
p_import_id: importId,
|
||||
})
|
||||
|
||||
if (rpcError) {
|
||||
return { success: false, deletedEntries: 0, error: `Kunde inte ångra import: ${rpcError.message}` }
|
||||
}
|
||||
|
||||
return { success: true, deletedEntries: deletedCount as number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up orphan in-flight import records for a given file hash.
|
||||
*
|
||||
@@ -596,6 +651,170 @@ export async function linkOpeningBalanceEntryToPeriod(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pragmatic IB resync.
|
||||
*
|
||||
* Backfill scenario: user already imported 2026 (or set its IB manually),
|
||||
* then later imports 2025. The previously-set 2026 IB no longer matches
|
||||
* the 2025 UB we just computed — resync it by stornoing the old IB and
|
||||
* creating a fresh one from the just-imported #UB.
|
||||
*
|
||||
* Returns:
|
||||
* - { resynced: true, ...details } when storno + new IB succeeded
|
||||
* - { resynced: false, reason } when there's no next period, no existing
|
||||
* IB to replace, or the next period is locked/closed
|
||||
*
|
||||
* Caller is responsible for surfacing the result in ImportResult.
|
||||
*/
|
||||
export async function resyncNextPeriodOpeningBalance(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
justImportedPeriodEnd: string,
|
||||
parsed: ParsedSIEFile,
|
||||
accountMap: Map<string, string>
|
||||
): Promise<
|
||||
| {
|
||||
resynced: true
|
||||
nextPeriodId: string
|
||||
nextPeriodName: string
|
||||
stornoEntryId: string
|
||||
newOpeningBalanceEntryId: string
|
||||
}
|
||||
| { resynced: false; reason: string; nextPeriodName?: string }
|
||||
> {
|
||||
const { data: nextPeriod } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end, is_closed, locked_at, opening_balance_entry_id, opening_balances_set')
|
||||
.eq('company_id', companyId)
|
||||
.gt('period_start', justImportedPeriodEnd)
|
||||
.order('period_start', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (!nextPeriod) {
|
||||
return { resynced: false, reason: 'no_next_period' }
|
||||
}
|
||||
|
||||
if (!nextPeriod.opening_balance_entry_id) {
|
||||
// No existing IB on the next period — caller has nothing to resync; the
|
||||
// user's first IB for the next period will be derived from the import
|
||||
// we just completed via getOpeningBalances() fallback.
|
||||
return { resynced: false, reason: 'next_period_has_no_ib', nextPeriodName: nextPeriod.name }
|
||||
}
|
||||
|
||||
if (nextPeriod.is_closed || nextPeriod.locked_at) {
|
||||
return {
|
||||
resynced: false,
|
||||
reason: 'next_period_locked',
|
||||
nextPeriodName: nextPeriod.name,
|
||||
}
|
||||
}
|
||||
|
||||
// Build the new IB lines from the just-imported year's #UB (yearIndex=0
|
||||
// closing balances). Each balance carries the source account number; map
|
||||
// through accountMap so chart renames in the target company are honored.
|
||||
const currentYearUB = parsed.closingBalances.filter((b) => b.yearIndex === 0)
|
||||
if (currentYearUB.length === 0) {
|
||||
return { resynced: false, reason: 'no_closing_balances', nextPeriodName: nextPeriod.name }
|
||||
}
|
||||
|
||||
const newLines: CreateJournalEntryLineInput[] = []
|
||||
for (const balance of currentYearUB) {
|
||||
const targetAccount = accountMap.get(balance.account) ?? balance.account
|
||||
if (balance.amount > 0) {
|
||||
newLines.push({
|
||||
account_number: targetAccount,
|
||||
debit_amount: balance.amount,
|
||||
credit_amount: 0,
|
||||
line_description: `IB ${balance.account} (resynk efter import)`,
|
||||
})
|
||||
} else if (balance.amount < 0) {
|
||||
newLines.push({
|
||||
account_number: targetAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.abs(balance.amount),
|
||||
line_description: `IB ${balance.account} (resynk efter import)`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (newLines.length === 0) {
|
||||
return { resynced: false, reason: 'empty_new_ib', nextPeriodName: nextPeriod.name }
|
||||
}
|
||||
|
||||
// Balance check: if the new IB doesn't balance (excluded accounts, etc.),
|
||||
// book the difference to 2099 the same way createOpeningBalanceEntry does.
|
||||
const totalDebit = newLines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const totalCredit = newLines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
const diff = Math.round((totalDebit - totalCredit) * 100) / 100
|
||||
if (Math.abs(diff) > 0.01) {
|
||||
if (diff > 0) {
|
||||
newLines.push({
|
||||
account_number: '2099',
|
||||
debit_amount: 0,
|
||||
credit_amount: diff,
|
||||
line_description: 'Avrundningsdifferens vid IB-resynk',
|
||||
})
|
||||
} else {
|
||||
newLines.push({
|
||||
account_number: '2099',
|
||||
debit_amount: Math.abs(diff),
|
||||
credit_amount: 0,
|
||||
line_description: 'Avrundningsdifferens vid IB-resynk',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ordering note: create the new IB FIRST, then storno the old one. If we
|
||||
// stornoed first and the createJournalEntry call failed, the next period
|
||||
// would be left with a reversed IB and nothing to replace it — and
|
||||
// executeSIEImport swallows our error as a non-fatal warning. By creating
|
||||
// first we guarantee the worst case is "new IB exists but not yet linked",
|
||||
// which getOpeningBalances() can still reason about.
|
||||
|
||||
// Build the new IB entry on the next period.
|
||||
const newEntry = await createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: nextPeriod.id,
|
||||
entry_date: nextPeriod.period_start as string,
|
||||
description: 'Ingående balanser (resynk efter prior-year SIE-import)',
|
||||
source_type: 'opening_balance',
|
||||
voucher_series: 'A',
|
||||
lines: newLines,
|
||||
})
|
||||
|
||||
// Atomically swap the period FK pointer (two-step around the
|
||||
// immutability trigger).
|
||||
const { error: relinkError } = await supabase.rpc('replace_period_opening_balance_link', {
|
||||
p_company_id: companyId,
|
||||
p_period_id: nextPeriod.id,
|
||||
p_new_entry_id: newEntry.id,
|
||||
})
|
||||
|
||||
if (relinkError) {
|
||||
throw new Error(`Failed to relink opening balance on next period: ${relinkError.message}`)
|
||||
}
|
||||
|
||||
// Now that the period points at the new IB, storno the old one. If this
|
||||
// throws, the period is already on the correct entry — the orphaned old
|
||||
// entry shows up as a stray verifikat but the FK stays consistent.
|
||||
const storno = await reverseEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
nextPeriod.opening_balance_entry_id,
|
||||
nextPeriod.period_start as string,
|
||||
)
|
||||
|
||||
return {
|
||||
resynced: true,
|
||||
nextPeriodId: nextPeriod.id,
|
||||
nextPeriodName: nextPeriod.name,
|
||||
stornoEntryId: storno.id,
|
||||
newOpeningBalanceEntryId: newEntry.id,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create journal entries from vouchers using batch insert for performance.
|
||||
*
|
||||
@@ -1342,10 +1561,7 @@ async function ensureAccountExists(
|
||||
// Fallback: derive metadata from account number
|
||||
const classNum = parseInt(accountNumber.charAt(0), 10)
|
||||
const group = accountNumber.substring(0, 2)
|
||||
const accountType = classNum === 1 ? 'asset'
|
||||
: classNum === 2 ? (group === '21' ? 'untaxed_reserves' : (group === '20' ? 'equity' : 'liability'))
|
||||
: classNum === 3 ? 'revenue'
|
||||
: 'expense'
|
||||
const classified = classifyAccount(accountNumber)
|
||||
|
||||
await supabase.from('chart_of_accounts').insert({
|
||||
user_id: userId,
|
||||
@@ -1354,8 +1570,8 @@ async function ensureAccountExists(
|
||||
account_name: accountName,
|
||||
account_class: classNum,
|
||||
account_group: group,
|
||||
account_type: accountType,
|
||||
normal_balance: classNum <= 1 || classNum >= 4 ? 'debit' : 'credit',
|
||||
account_type: classified.account_type,
|
||||
normal_balance: classified.normal_balance,
|
||||
sru_code: computeSRUCode(accountNumber),
|
||||
plan_type: 'full_bas',
|
||||
is_active: true,
|
||||
@@ -1699,9 +1915,7 @@ export async function executeSIEImport(
|
||||
}
|
||||
const classNum = parseInt(num.charAt(0), 10)
|
||||
const group = num.substring(0, 2)
|
||||
const accountType = classNum === 1 ? 'asset'
|
||||
: classNum === 2 ? (group === '21' ? 'untaxed_reserves' : (group === '20' ? 'equity' : 'liability'))
|
||||
: classNum === 3 ? 'revenue' : 'expense'
|
||||
const classified = classifyAccount(num)
|
||||
return {
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
@@ -1709,8 +1923,8 @@ export async function executeSIEImport(
|
||||
account_name: targetNameMap.get(num) || `Konto ${num}`,
|
||||
account_class: classNum,
|
||||
account_group: group,
|
||||
account_type: accountType,
|
||||
normal_balance: classNum <= 1 || classNum >= 4 ? 'debit' : 'credit',
|
||||
account_type: classified.account_type,
|
||||
normal_balance: classified.normal_balance,
|
||||
sru_code: computeSRUCode(num),
|
||||
plan_type: 'full_bas' as const,
|
||||
is_active: true,
|
||||
@@ -2063,6 +2277,49 @@ export async function executeSIEImport(
|
||||
result.warnings.push('Kunde inte spara kontomappningar — påverkar inte importerade data')
|
||||
}
|
||||
|
||||
// Pragmatic IB resync: if a chronologically-later fiscal period already
|
||||
// exists with its own opening_balance entry, the customer is doing a
|
||||
// prior-year backfill. Sync the next period's IB to match the UB we
|
||||
// just imported so reports stay consistent.
|
||||
if (result.success && fiscalYearEnd && result.fiscalPeriodId && parsed.closingBalances.length > 0) {
|
||||
try {
|
||||
const resync = await resyncNextPeriodOpeningBalance(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
fiscalYearEnd,
|
||||
parsed,
|
||||
accountMap,
|
||||
)
|
||||
if (resync.resynced) {
|
||||
result.nextPeriodIBResync = {
|
||||
nextPeriodId: resync.nextPeriodId,
|
||||
nextPeriodName: resync.nextPeriodName,
|
||||
stornoEntryId: resync.stornoEntryId,
|
||||
newOpeningBalanceEntryId: resync.newOpeningBalanceEntryId,
|
||||
}
|
||||
result.journalEntriesCreated += 2 // storno + new IB
|
||||
result.journalEntryIds.push(resync.stornoEntryId, resync.newOpeningBalanceEntryId)
|
||||
result.warnings.push(
|
||||
`Ingående balanser för ${resync.nextPeriodName} synkades om mot den just importerade utgående balansen.`,
|
||||
)
|
||||
} else if (resync.reason === 'next_period_locked' && resync.nextPeriodName) {
|
||||
result.nextPeriodIBResyncSkipped = {
|
||||
reason: 'locked',
|
||||
nextPeriodName: resync.nextPeriodName,
|
||||
}
|
||||
result.warnings.push(
|
||||
`Nästa räkenskapsår (${resync.nextPeriodName}) är låst — ingående balanser kunde inte synkas om automatiskt. Lås upp perioden och importera igen för att synka.`,
|
||||
)
|
||||
}
|
||||
} catch (resyncError) {
|
||||
console.error('[sie-import] IB resync failed (non-fatal):', resyncError)
|
||||
result.warnings.push(
|
||||
`Ingående balanser för nästa räkenskapsår kunde inte synkas om automatiskt: ${resyncError instanceof Error ? resyncError.message : 'okänt fel'}. Kontrollera och justera manuellt.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate systemdokumentation (MigrationDocumentation)
|
||||
const mappingStats = getMappingStats(mappings)
|
||||
const documentation: MigrationDocumentation = {
|
||||
|
||||
@@ -301,6 +301,20 @@ export interface ImportResult {
|
||||
// (Fortnox re-sync flow), the prior import's id and the count of journal
|
||||
// entries that were deleted as a result.
|
||||
replacedPriorImport?: { importId: string; deletedEntries: number } | null
|
||||
|
||||
// If a prior-year backfill triggered IB resync on the immediately-following
|
||||
// fiscal period (storno + recreate of its opening_balance entry), the
|
||||
// details of what happened — populated only when the resync ran.
|
||||
nextPeriodIBResync?: {
|
||||
nextPeriodId: string
|
||||
nextPeriodName: string
|
||||
stornoEntryId: string
|
||||
newOpeningBalanceEntryId: string
|
||||
} | null
|
||||
|
||||
// If the next period's IB needed resync but we couldn't do it (locked,
|
||||
// closed, or no existing IB), the human-readable reason.
|
||||
nextPeriodIBResyncSkipped?: { reason: string; nextPeriodName: string } | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user