adf58a51c0
* feat: prompt to activate missing BAS accounts at commit
Booking to an account not in the active chart previously threw a
generic 400 "Account(s) not found: 5010" and the user had to leave
the form to enable the account via /bookkeeping > BAS-katalog.
- New AccountsNotInChartError thrown from resolveAccountIds in the
engine (and the parallel resolver in core/storno-service). The
query also now filters on is_active=true, so deactivated accounts
are treated the same as never-added ones.
- API routes that call the engine (journal-entries, reverse, correct,
transactions/book + match-invoice + match-supplier-invoice +
uncategorize, invoices/mark-paid, supplier-invoices + mark-paid +
credit, salary/runs/correct, import/opening-balance/execute,
pending-operations/commit) catch the typed error and return a
structured 400: { error: { code: ACCOUNTS_NOT_IN_CHART,
account_numbers, message } }.
- /api/bookkeeping/accounts/activate now also reactivates rows that
already exist but are is_active=false, not only INSERTs. Returns
{ activated, reactivated, skipped, unknown }.
- New GET /api/bookkeeping/accounts/bas-lookup?numbers=... resolves
BAS names client-side so the dialog can show "5010 · Lokalhyra"
without bundling the full 1,276-account catalog.
- ActivateAccountsDialog lists the missing accounts (BAS names + any
unknown non-BAS numbers) and confirms with a single action.
- useSubmitWithAccountActivation wraps an async submit: on
ACCOUNTS_NOT_IN_CHART it opens the dialog, activates on confirm,
then retries the original submit so the user never re-enters data.
- AccountCombobox accepts any 4-digit numeric value, not just items
from the active chart — the activation dialog handles the rest.
- JournalEntryForm wired to the hook + dialog. Other submit surfaces
now surface a clear Swedish message ("Följande konton behöver
aktiveras: …") via getErrorMessage; wiring the dialog into those
is an additive follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: sync CLAUDE.md with current codebase state
Catch-up on growth since the last CLAUDE.md revision:
- Integrations list now includes AWS Bedrock, Upstash Redis,
Google Drive, Recharts, PDF.js, @react-pdf/renderer, xlsx,
fuse.js, ics.
- Extension table reflects cloud-backup enabled; adds
inbox-smart-match and example-logger; reorders to match current
extensions.config.json.
- Updated counts: 36 event types (was 30+), 35 MCP tools (was 26),
~60 tables (was ~47), 118 migrations (was 93), 19 report
endpoints (was 16), 20 report generators (was 17).
- lib/ directory table now covers salary, providers,
company-lookup, processing-history, support.ts; removes the
deleted settings/ subdir.
- App routes table adds /salary/*, /help, /settings/salary,
/settings/backup.
- API endpoints table adds /api/salary/*, /api/support/contact,
/api/account/delete, /api/audit-trail/*, /api/log,
/api/currency/rate, top-level extension routes.
- Tables section adds Salary, Third-party providers, Inbox &
Migration groups; removes salary_payments (replaced by
salary_runs + salary_line_items).
- Skills list updated to enumerate the Swedish domain skills by
name instead of the old single /swedish-bookkeeping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address PR review feedback on account activation
Seven fixes based on Greptile + Swedish compliance review on #308.
- ActivateAccountsDialog: disable the confirm button when any
entered number isn't a valid BAS account. Previously activation
would succeed for the knowns and the retry would immediately
fail again on the unknowns, giving a confusing double-toast UX.
- pending-operations/commit: revert commitSendInvoice and
commitMarkInvoiceSent to swallow AccountsNotInChartError
silently. The prior PR upgrade made these blocking, which
regressed invoice delivery for users whose AR accounts are
inactive — and since the activation dialog isn't wired into
those flows yet, there's no one-click recovery. The silent
catches now append an InvoiceJournalEntrySkipped event to
processing_history so the missing verifikation is actionable
in audit trails rather than silently understating the
momsdeklaration (revenue / utgående moms unposted).
- engine.reverseEntry: resolve account IDs with includeInactive=true
so storno of an already-committed entry goes through even when
the user has since deactivated one of its accounts. Blocking
the reversal would leave the original entry uncorrected in
violation of BFL 5 kap 5§ (rättelse must be documented). The
default (includeInactive=false) still applies to createDraftEntry
so new bookings to inactive accounts continue to trigger the
activation dialog.
- supplier-invoices POST + credit: roll back the just-inserted
supplier_invoices row (items cascade-delete) on any JE failure,
not only AccountsNotInChartError. An orphan supplier_invoices
row without a registration / credit JE leaves leverantörsskuld
(2440) and ingående moms (2641) unposted — a silent
understatement / overstatement in the momsdeklaration (ML
2023:200 / BFL 5 kap). The catch now returns a clear Swedish
error message for non-activation failures (typically period
lock or DB error) instead of silently logging.
Test mocks for chart_of_accounts updated for the new query chain
(eq.in.eq instead of eq.eq.in after the is_active conditional).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
589 lines
19 KiB
TypeScript
589 lines
19 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { eventBus } from '@/lib/events'
|
|
import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
|
|
import type {
|
|
CreateJournalEntryInput,
|
|
CreateJournalEntryLineInput,
|
|
JournalEntry,
|
|
JournalEntryLine,
|
|
} from '@/types'
|
|
|
|
/**
|
|
* Validate that a set of journal entry lines is balanced (debits = credits)
|
|
*/
|
|
export function validateBalance(lines: CreateJournalEntryLineInput[]): {
|
|
valid: boolean
|
|
totalDebit: number
|
|
totalCredit: number
|
|
} {
|
|
const totalDebit = lines.reduce((sum, l) => sum + (l.debit_amount || 0), 0)
|
|
const totalCredit = lines.reduce((sum, l) => sum + (l.credit_amount || 0), 0)
|
|
|
|
// Round to avoid floating point issues (2 decimal places for SEK)
|
|
const roundedDebit = Math.round(totalDebit * 100) / 100
|
|
const roundedCredit = Math.round(totalCredit * 100) / 100
|
|
|
|
return {
|
|
valid: roundedDebit === roundedCredit && roundedDebit > 0,
|
|
totalDebit: roundedDebit,
|
|
totalCredit: roundedCredit,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the next voucher number for a company/period/series
|
|
* Uses the concurrent-safe INSERT ON CONFLICT implementation in the database
|
|
*/
|
|
export async function getNextVoucherNumber(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
fiscalPeriodId: string,
|
|
series: string = 'A'
|
|
): Promise<number> {
|
|
|
|
const { data, error } = await supabase.rpc('next_voucher_number', {
|
|
p_company_id: companyId,
|
|
p_fiscal_period_id: fiscalPeriodId,
|
|
p_series: series,
|
|
})
|
|
|
|
if (error) {
|
|
throw new Error(`Failed to get next voucher number: ${error.message}`)
|
|
}
|
|
|
|
return data as number
|
|
}
|
|
|
|
/**
|
|
* Resolve account IDs from account numbers for a company.
|
|
*
|
|
* By default only active accounts are returned — inactive / never-added
|
|
* accounts surface as "missing" so callers throw AccountsNotInChartError.
|
|
*
|
|
* Pass `{ includeInactive: true }` for reversals: the accounts on an already-
|
|
* committed entry were legitimately active at commit time, and BFL 5 kap 5§
|
|
* requires storno to be possible even if a user has since deactivated one of
|
|
* those accounts. Blocking the reversal would leave the original entry
|
|
* uncorrected with no audit trail.
|
|
*/
|
|
async function resolveAccountIds(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
lines: CreateJournalEntryLineInput[],
|
|
options: { includeInactive?: boolean } = {}
|
|
): Promise<Map<string, string>> {
|
|
const accountNumbers = [...new Set(lines.map((l) => l.account_number))]
|
|
|
|
let query = supabase
|
|
.from('chart_of_accounts')
|
|
.select('id, account_number')
|
|
.eq('company_id', companyId)
|
|
.in('account_number', accountNumbers)
|
|
|
|
if (!options.includeInactive) {
|
|
query = query.eq('is_active', true)
|
|
}
|
|
|
|
const { data: accounts, error } = await query
|
|
|
|
if (error) {
|
|
throw new Error(`Failed to resolve account IDs: ${error.message}`)
|
|
}
|
|
|
|
const map = new Map<string, string>()
|
|
for (const account of accounts || []) {
|
|
map.set(account.account_number, account.id)
|
|
}
|
|
|
|
return map
|
|
}
|
|
|
|
/**
|
|
* Find the fiscal period for a given date
|
|
*/
|
|
export async function findFiscalPeriod(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
date: string
|
|
): Promise<string | null> {
|
|
|
|
// Overlapping periods are prevented by a DB exclusion constraint
|
|
// (migration 042). limit(1) is kept as a defensive measure.
|
|
const { data, error } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.lte('period_start', date)
|
|
.gte('period_end', date)
|
|
.eq('is_closed', false)
|
|
.order('period_start', { ascending: false })
|
|
.limit(1)
|
|
|
|
if (error || !data || data.length === 0) {
|
|
return null
|
|
}
|
|
|
|
return data[0].id
|
|
}
|
|
|
|
/**
|
|
* Build line insert objects from input lines, resolving account IDs and
|
|
* including tax_code, cost_center, project dimensions
|
|
*/
|
|
function buildLineInserts(
|
|
entryId: string,
|
|
lines: CreateJournalEntryLineInput[],
|
|
accountIdMap: Map<string, string>
|
|
) {
|
|
return lines.map((line, index) => ({
|
|
journal_entry_id: entryId,
|
|
account_number: line.account_number,
|
|
account_id: accountIdMap.get(line.account_number) || null,
|
|
debit_amount: Math.round((line.debit_amount || 0) * 100) / 100,
|
|
credit_amount: Math.round((line.credit_amount || 0) * 100) / 100,
|
|
currency: line.currency || 'SEK',
|
|
amount_in_currency: line.amount_in_currency ? Math.round(line.amount_in_currency * 100) / 100 : null,
|
|
exchange_rate: line.exchange_rate || null,
|
|
line_description: line.line_description || null,
|
|
tax_code: line.tax_code || null,
|
|
cost_center: line.cost_center || null,
|
|
project: line.project || null,
|
|
sort_order: index,
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Create a draft journal entry with lines (no voucher number assigned yet)
|
|
* The entry stays in 'draft' status until commitEntry() is called.
|
|
*/
|
|
export async function createDraftEntry(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
input: CreateJournalEntryInput
|
|
): Promise<JournalEntry> {
|
|
// Validate balance
|
|
const balance = validateBalance(input.lines)
|
|
if (!balance.valid) {
|
|
throw new Error(
|
|
`Journal entry is not balanced: debits (${balance.totalDebit}) != credits (${balance.totalCredit})`
|
|
)
|
|
}
|
|
|
|
// Validate that entry_date falls within the selected fiscal period
|
|
const { data: period, error: periodError } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('name, period_start, period_end')
|
|
.eq('id', input.fiscal_period_id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (periodError || !period) {
|
|
throw new Error('Fiscal period not found')
|
|
}
|
|
|
|
if (input.entry_date < period.period_start || input.entry_date > period.period_end) {
|
|
throw new Error(
|
|
`Entry date ${input.entry_date} is outside fiscal period "${period.name}" (${period.period_start} - ${period.period_end})`
|
|
)
|
|
}
|
|
|
|
// Resolve account IDs
|
|
const accountIdMap = await resolveAccountIds(supabase, companyId, input.lines)
|
|
|
|
// Validate all account numbers resolved to IDs
|
|
const allAccountNumbers = [...new Set(input.lines.map(l => l.account_number))]
|
|
const missingAccounts = allAccountNumbers.filter(num => !accountIdMap.has(num))
|
|
if (missingAccounts.length > 0) {
|
|
throw new AccountsNotInChartError(missingAccounts)
|
|
}
|
|
|
|
// Insert journal entry header as draft (voucher_number = 0, will be assigned on commit)
|
|
const { data: entry, error: entryError } = await supabase
|
|
.from('journal_entries')
|
|
.insert({
|
|
company_id: companyId,
|
|
user_id: userId,
|
|
fiscal_period_id: input.fiscal_period_id,
|
|
voucher_number: 0,
|
|
voucher_series: input.voucher_series || 'A',
|
|
entry_date: input.entry_date,
|
|
description: input.description,
|
|
source_type: input.source_type,
|
|
source_id: input.source_id || null,
|
|
notes: input.notes || null,
|
|
status: 'draft',
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (entryError || !entry) {
|
|
throw new Error(`Failed to create draft journal entry: ${entryError?.message}`)
|
|
}
|
|
|
|
// Insert journal entry lines with dimensions
|
|
const lineInserts = buildLineInserts(entry.id, input.lines, accountIdMap)
|
|
|
|
const { error: linesError } = await supabase
|
|
.from('journal_entry_lines')
|
|
.insert(lineInserts)
|
|
|
|
if (linesError) {
|
|
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', entry.id)
|
|
throw new Error(`Failed to create journal entry lines: ${linesError.message}`)
|
|
}
|
|
|
|
// Fetch complete entry with lines
|
|
const { data: completeEntry } = await supabase
|
|
.from('journal_entries')
|
|
.select('*, lines:journal_entry_lines(*)')
|
|
.eq('id', entry.id)
|
|
.single()
|
|
|
|
const result = completeEntry as JournalEntry
|
|
|
|
await eventBus.emit({
|
|
type: 'journal_entry.drafted',
|
|
payload: { entry: result, userId, companyId },
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Commit a draft entry: assigns voucher number and transitions to 'posted'
|
|
* Uses the atomic commit_journal_entry RPC so the voucher number increment
|
|
* and status update happen in one transaction. If the balance trigger rejects
|
|
* the entry, the sequence increment rolls back — no burned numbers.
|
|
*/
|
|
export async function commitEntry(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
entryId: string,
|
|
commitMethod?: string,
|
|
rubricVersion?: string
|
|
): Promise<JournalEntry> {
|
|
|
|
// Atomic: increment voucher sequence + update status in one transaction.
|
|
// Rolls back the sequence if the balance trigger or any constraint fails.
|
|
const { data: rpcResult, error: commitError } = await supabase.rpc('commit_journal_entry', {
|
|
p_company_id: companyId,
|
|
p_entry_id: entryId,
|
|
p_commit_method: commitMethod ?? null,
|
|
p_rubric_version: rubricVersion ?? null,
|
|
})
|
|
|
|
if (commitError) {
|
|
throw new Error(`Failed to commit journal entry: ${commitError.message}`)
|
|
}
|
|
|
|
// Fetch complete posted entry with lines
|
|
const { data: completeEntry } = await supabase
|
|
.from('journal_entries')
|
|
.select('*, lines:journal_entry_lines(*)')
|
|
.eq('id', entryId)
|
|
.single()
|
|
|
|
const result = completeEntry as JournalEntry
|
|
|
|
await eventBus.emit({
|
|
type: 'journal_entry.committed',
|
|
payload: { entry: result, userId, companyId },
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Create a journal entry with lines (verifikation)
|
|
* Convenience wrapper: creates draft + commits in one step.
|
|
* The voucher number is only assigned after lines are successfully inserted,
|
|
* preventing gaps in the voucher sequence (BFL 5 kap. 7§).
|
|
*
|
|
* If commitEntry fails (e.g. balance trigger rejection, period lock, RPC error),
|
|
* the orphan draft is cancelled so callers don't leave an undeletable stuck draft.
|
|
* The commit RPC is atomic — no voucher number is burned on failure.
|
|
*/
|
|
export async function createJournalEntry(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
input: CreateJournalEntryInput,
|
|
commitMethod?: string,
|
|
rubricVersion?: string
|
|
): Promise<JournalEntry> {
|
|
const draft = await createDraftEntry(supabase, companyId, userId, input)
|
|
try {
|
|
return await commitEntry(supabase, companyId, userId, draft.id, commitMethod, rubricVersion)
|
|
} catch (commitError) {
|
|
// CAS guard: only cancel if still in draft. If the RPC actually posted
|
|
// before failing downstream, immutability trigger blocks draft→cancelled
|
|
// on a posted row anyway — the filter just avoids firing the trigger.
|
|
try {
|
|
await supabase
|
|
.from('journal_entries')
|
|
.update({ status: 'cancelled' })
|
|
.eq('id', draft.id)
|
|
.eq('status', 'draft')
|
|
} catch {
|
|
// Swallow rollback failure — surface the original commit error
|
|
}
|
|
throw commitError
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current date in Swedish timezone (Europe/Stockholm).
|
|
* Avoids UTC date shift when server runs in a different timezone.
|
|
*/
|
|
export function getSwedishLocalDate(): string {
|
|
return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Stockholm' }).format(new Date())
|
|
}
|
|
|
|
/**
|
|
* Create a reversal entry for an existing journal entry
|
|
* Sets reversed_by_id/reverses_id links for compliance tracking
|
|
*/
|
|
export async function reverseEntry(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
entryId: string,
|
|
reversalDate?: string
|
|
): Promise<JournalEntry> {
|
|
|
|
// Fetch original entry with lines
|
|
const { data: original, error } = await supabase
|
|
.from('journal_entries')
|
|
.select('*, lines:journal_entry_lines(*)')
|
|
.eq('id', entryId)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (error || !original) {
|
|
throw new Error('Journal entry not found')
|
|
}
|
|
|
|
if (original.status !== 'posted') {
|
|
throw new Error('Can only reverse posted entries')
|
|
}
|
|
|
|
const lines = (original.lines as JournalEntryLine[]) || []
|
|
|
|
// Create reversed lines (swap debit and credit, preserve dimensions)
|
|
const reversedLines: CreateJournalEntryLineInput[] = lines.map((line) => ({
|
|
account_number: line.account_number,
|
|
debit_amount: line.credit_amount,
|
|
credit_amount: line.debit_amount,
|
|
line_description: `Reversal: ${line.line_description || ''}`,
|
|
currency: line.currency,
|
|
amount_in_currency: line.amount_in_currency
|
|
? -line.amount_in_currency
|
|
: undefined,
|
|
exchange_rate: line.exchange_rate || undefined,
|
|
tax_code: line.tax_code || undefined,
|
|
cost_center: line.cost_center || undefined,
|
|
project: line.project || undefined,
|
|
}))
|
|
|
|
const entryDate = reversalDate || getSwedishLocalDate()
|
|
|
|
// Get voucher number for the reversal
|
|
const voucherNumber = await getNextVoucherNumber(
|
|
supabase,
|
|
companyId,
|
|
original.fiscal_period_id,
|
|
original.voucher_series || 'A'
|
|
)
|
|
|
|
// Resolve account IDs — include inactive rows. The accounts on the
|
|
// original committed entry were active at commit time; if the user has
|
|
// since toggled one off, the storno must still be allowed to go through
|
|
// (BFL 5 kap 5§). Only a truly missing chart row (rare: would require
|
|
// the row to have been deleted) still throws AccountsNotInChartError.
|
|
const accountIdMap = await resolveAccountIds(supabase, companyId, reversedLines, { includeInactive: true })
|
|
|
|
const reversalAccountNumbers = [...new Set(reversedLines.map(l => l.account_number))]
|
|
const missingReversalAccounts = reversalAccountNumbers.filter(num => !accountIdMap.has(num))
|
|
if (missingReversalAccounts.length > 0) {
|
|
throw new AccountsNotInChartError(missingReversalAccounts)
|
|
}
|
|
|
|
// Create reversal entry with reverses_id link
|
|
const { data: reversalEntry, error: reversalError } = await supabase
|
|
.from('journal_entries')
|
|
.insert({
|
|
company_id: companyId,
|
|
user_id: userId,
|
|
fiscal_period_id: original.fiscal_period_id,
|
|
voucher_number: voucherNumber,
|
|
voucher_series: original.voucher_series || 'A',
|
|
entry_date: entryDate,
|
|
description: `Makulering: ${original.description}`,
|
|
source_type: 'storno',
|
|
source_id: original.source_id || null,
|
|
reverses_id: entryId,
|
|
status: 'draft',
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (reversalError || !reversalEntry) {
|
|
throw new Error(`Failed to create reversal entry: ${reversalError?.message}`)
|
|
}
|
|
|
|
// Insert reversal lines with dimensions
|
|
const lineInserts = buildLineInserts(reversalEntry.id, reversedLines, accountIdMap)
|
|
|
|
const { error: linesError } = await supabase
|
|
.from('journal_entry_lines')
|
|
.insert(lineInserts)
|
|
|
|
if (linesError) {
|
|
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id)
|
|
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
|
|
throw new Error(`Failed to create reversal lines: ${linesError.message}`)
|
|
}
|
|
|
|
// Post the reversal entry
|
|
const { error: postError } = await supabase
|
|
.from('journal_entries')
|
|
.update({ status: 'posted' })
|
|
.eq('id', reversalEntry.id)
|
|
|
|
if (postError) {
|
|
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id)
|
|
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
|
|
throw new Error(`Failed to post reversal entry: ${postError.message}`)
|
|
}
|
|
|
|
// Mark original as reversed with reversed_by_id link (CAS guard: only if still 'posted')
|
|
const { data: updatedOriginal, error: casError } = await supabase
|
|
.from('journal_entries')
|
|
.update({
|
|
status: 'reversed',
|
|
reversed_by_id: reversalEntry.id,
|
|
})
|
|
.eq('id', entryId)
|
|
.eq('status', 'posted')
|
|
.select('id')
|
|
|
|
if (casError || !updatedOriginal || updatedOriginal.length === 0) {
|
|
// Another concurrent reversal already changed the status — mark the orphaned
|
|
// reversal as cancelled so it's excluded from reports but remains traceable.
|
|
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id)
|
|
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
|
|
throw new Error('Entry was already reversed by a concurrent operation')
|
|
}
|
|
|
|
// If this was a payment entry, sync the linked invoice/supplier-invoice status
|
|
const paymentSourceTypes = [
|
|
'invoice_paid', 'invoice_cash_payment',
|
|
'supplier_invoice_paid', 'supplier_invoice_cash_payment',
|
|
]
|
|
|
|
if (paymentSourceTypes.includes(original.source_type) && original.source_id) {
|
|
// The GL reversal is already handled above (line-by-line mirror of the original
|
|
// verifikation per BFL 5 kap 5§). Here we sync the business-level invoice state.
|
|
// Payment amounts come from the payments table, not from GL line inspection —
|
|
// this works identically for kontantmetod and faktureringsmetod.
|
|
const entryId = original.id
|
|
|
|
if (original.source_type.startsWith('supplier_invoice')) {
|
|
const { data: payment } = await supabase
|
|
.from('supplier_invoice_payments')
|
|
.select('amount')
|
|
.eq('journal_entry_id', entryId)
|
|
.single()
|
|
|
|
const { data: supplierInvoice } = await supabase
|
|
.from('supplier_invoices')
|
|
.select('paid_amount, total_amount, due_date')
|
|
.eq('id', original.source_id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (supplierInvoice && payment) {
|
|
const newPaidAmount = Math.round((supplierInvoice.paid_amount - payment.amount) * 100) / 100
|
|
const newRemaining = Math.round((supplierInvoice.total_amount - Math.max(0, newPaidAmount)) * 100) / 100
|
|
let newStatus: string
|
|
if (newPaidAmount > 0) {
|
|
newStatus = 'partially_paid'
|
|
} else if (supplierInvoice.due_date && new Date(supplierInvoice.due_date) < new Date()) {
|
|
newStatus = 'overdue'
|
|
} else {
|
|
newStatus = 'approved'
|
|
}
|
|
|
|
await supabase
|
|
.from('supplier_invoices')
|
|
.update({
|
|
status: newStatus,
|
|
paid_amount: Math.max(0, newPaidAmount),
|
|
remaining_amount: newRemaining,
|
|
paid_at: null,
|
|
payment_journal_entry_id: null,
|
|
})
|
|
.eq('id', original.source_id)
|
|
.eq('company_id', companyId)
|
|
}
|
|
} else {
|
|
const { data: payment } = await supabase
|
|
.from('invoice_payments')
|
|
.select('amount')
|
|
.eq('journal_entry_id', entryId)
|
|
.single()
|
|
|
|
const { data: customerInvoice } = await supabase
|
|
.from('invoices')
|
|
.select('paid_amount, due_date')
|
|
.eq('id', original.source_id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (customerInvoice) {
|
|
const paymentAmount = payment?.amount ?? customerInvoice.paid_amount
|
|
const newPaidAmount = Math.round((customerInvoice.paid_amount - paymentAmount) * 100) / 100
|
|
const revertStatus = newPaidAmount > 0
|
|
? 'partially_paid'
|
|
: customerInvoice.due_date && new Date(customerInvoice.due_date) < new Date()
|
|
? 'overdue'
|
|
: 'sent'
|
|
|
|
await supabase
|
|
.from('invoices')
|
|
.update({
|
|
status: revertStatus,
|
|
paid_at: null,
|
|
paid_amount: Math.max(0, newPaidAmount),
|
|
})
|
|
.eq('id', original.source_id)
|
|
.eq('company_id', companyId)
|
|
.in('status', ['paid', 'partially_paid'])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch complete reversal entry with lines
|
|
const { data: completeEntry } = await supabase
|
|
.from('journal_entries')
|
|
.select('*, lines:journal_entry_lines(*)')
|
|
.eq('id', reversalEntry.id)
|
|
.single()
|
|
|
|
const result = completeEntry as JournalEntry
|
|
|
|
await eventBus.emit({
|
|
type: 'journal_entry.committed',
|
|
payload: { entry: result, userId, companyId },
|
|
})
|
|
|
|
await eventBus.emit({
|
|
type: 'journal_entry.reversed',
|
|
payload: { originalEntry: original as JournalEntry, reversalEntry: result, userId, companyId },
|
|
})
|
|
|
|
return result
|
|
}
|