Files
accounted/lib/bookkeeping/voucher-series-resolver.ts
T
MattssonandClaude Fable 5 f9ea9c0082 Add/pdf and templates (#705)
* fix(invoices): apply configured voucher series to payments + preview next voucher

The booking engine resolves the series from
default_voucher_series_per_source_type, but the global "Standardserie"
dropdown wrote a separate field the engine ignored, and cash-method invoice
payments (invoice_cash_payment) weren't exposed in settings — so configured
series were silently dropped to "A".

- Expose cash/private payment source types in the per-source-type form
- Write the global default through to the map on save, keeping overrides
- Resolve voucher-sequences/next by source_type (+date) to match the engine
- Show the upcoming voucher (V2) in the payment dialog title
- Share resolveInvoicePaymentSourceType so preview and booking can't drift

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(salary): keep AGI panel in sync with Skatteverket signing state

The AGI panel mixed run-scoped generation state (agi_generated_at,
agi_declarations) with period-scoped submission state (extension_data
agi_submission_{period}), so the two could drift and present
contradictory UI. Reconcile them:

- Auto-detect a Mina Sidor BankID signature: while awaiting_signing,
  poll /agi/kvittenser on mount and on tab refocus so the panel flips
  to "signed" (hiding the signing actions) without a manual
  "Hamta kvittens" click.
- Warn instead of offering to sign when the locked granskningsunderlag
  predates the run's latest AGI generation (draftIsStale) — avoids
  filing superseded figures.
- Self-heal a stale "AGI-XML saknas" error once the run's AGI is
  (re)generated out-of-band (MCP/API/other tab).
- Refetch the salary run on tab focus so agi_generated_at reflects
  out-of-band generation without a hard reload.
- /agi/lasUpp now clears the cached agi_submission_{period} record, so
  unlocking drops the panel back to the pre-submission state instead of
  stranding it on a released "redo att signeras" draft.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: Implement VAT registration handling and invoice item line types

- Added VAT registration check in commitCreateInvoice to set VAT rate to 0% for non-VAT registered companies.
- Updated invoice creation logic to reflect 'exempt' VAT treatment and adjusted related fields accordingly.
- Introduced support for free-text and blank spacer rows in invoice items by adding a new line_type field.
- Enhanced invoice and credit note handling to accommodate new line types.
- Added new localized messages for text rows in English and Swedish.
- Created tests for salary run approval logic, ensuring bank details are validated correctly.
- Implemented effective net payout calculation for salary runs, considering tax overrides.
- Added SQL migrations to support new invoice item line types and accounting method awareness for linking invoices to vouchers.

* feat(articles): artikelregister with revenue account + VAT rate per article

Article register (non-inventory) with per-article VAT rate and optional
BAS class-3 revenue-account override. Includes API routes, UI pages,
MCP tools, pending-operation staging, and the activate-or-create
account flow (ACCOUNTS_NOT_IN_CHART -> ActivateAccountsDialog,
unknown numbers -> AddAccountDialog) reusing the journal entry UX.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): no-doc-required batch + bulk-missing endpoints

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(payments): supplier payment lines + cash-method invoice matching

Shared payment-line proposal for supplier invoices, improved
match-invoice/match-supplier-invoice flows (kontantmetoden-aware),
and voucher-link support without requiring a 151x clearing entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): new journal entry dialog, SIE import tweaks, misc

New journal entry dialog component, journal list/page updates,
invoice editor updates, SIE import adjustments, transaction ingest
and api-key tweaks, pr-agent workflow update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(invoices): implement tax reduction features and localization updates

* feat(tests): add VAT registration gate to pending operations commit tests

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:52:24 +02:00

124 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Voucher series resolver — pure helpers for mapping journal_entries.source_type
* to a default voucher_series per company_settings, formatting voucher labels
* for display, and parsing them back.
*
* Source-type → series mapping lives in
* company_settings.default_voucher_series_per_source_type (JSONB).
*
* Defaults to 'A' when:
* - the settings row is null/undefined
* - the JSONB is missing the source_type key
* - the configured value is not a single uppercase letter A–Z
*
* These functions are pure; no I/O. Engine call-sites read the settings row
* once and pass it in.
*/
import type { JournalEntrySourceType } from '@/types'
export type VoucherSeriesMap = Partial<Record<JournalEntrySourceType, string>> &
Record<string, string>
const SERIES_LETTER_RE = /^[A-Z]$/
/**
* Resolve the default voucher_series letter for a given source_type from a
* company_settings row. Returns 'A' as a safe fallback when no mapping is
* configured for that source_type.
*
* @param settings - Either a full CompanySettings row or just the per-source
* map. `null`/`undefined` is allowed (returns 'A').
* @param sourceType - The journal_entries.source_type value.
*/
export function resolveDefaultSeriesForSource(
settings:
| { default_voucher_series_per_source_type?: VoucherSeriesMap | null }
| VoucherSeriesMap
| null
| undefined,
sourceType: JournalEntrySourceType,
): string {
if (!settings) return 'A'
// Accept both the full settings row and a bare map. Both shapes are
// narrowed via duck-typing on the column key — when present, treat it as
// the settings row; otherwise treat the argument itself as the map.
const raw = settings as {
default_voucher_series_per_source_type?: VoucherSeriesMap | null
} & VoucherSeriesMap
const mapCandidate =
raw.default_voucher_series_per_source_type !== undefined
? raw.default_voucher_series_per_source_type
: (settings as VoucherSeriesMap)
if (!mapCandidate || typeof mapCandidate !== 'object') return 'A'
const value = (mapCandidate as VoucherSeriesMap)[sourceType]
if (typeof value === 'string' && SERIES_LETTER_RE.test(value)) {
return value
}
return 'A'
}
/**
* Propagate a change to the global default voucher series across the
* per-source-type map. Source types that were still following the previous
* default move to the new default; explicit overrides (values that differ from
* the previous default) are preserved untouched.
*
* The booking engine resolves series from the per-source-type map, not from the
* global default, so the bookkeeping settings form calls this when the user
* changes the "Standardserie" dropdown — otherwise that control would be a
* no-op for bookkeeping. Pure; returns the next map (input is not mutated).
*/
export function applyDefaultSeriesToMap(
currentMap: VoucherSeriesMap | null | undefined,
prevDefault: string,
nextDefault: string,
): VoucherSeriesMap {
const out: VoucherSeriesMap = {}
for (const [key, value] of Object.entries(currentMap || {})) {
out[key] = value === prevDefault ? nextDefault : value
}
return out
}
/**
* Format a voucher (series + number) for UI display. Returns "—" when the
* voucher number is null (e.g. a draft entry that has not been committed yet).
*
* Always lifts the series to uppercase. Falls back to 'A' when the series is
* null/empty for forward-compat with legacy rows. Accepts partial inputs so
* callsites can pass through API responses without re-shaping them.
*/
export function formatVoucher(entry: {
voucher_series?: string | null
voucher_number?: number | null
}): string {
if (entry.voucher_number == null || entry.voucher_number === 0) {
return '—'
}
const series =
entry.voucher_series && typeof entry.voucher_series === 'string'
? entry.voucher_series.toUpperCase()
: 'A'
return `${series}${entry.voucher_number}`
}
/**
* Parse a formatted voucher label back into its parts. Returns null when the
* input does not match the expected shape (single uppercase letter followed
* by a positive integer). Use for filter inputs / search.
*/
export function parseVoucher(
formatted: string,
): { series: string; number: number } | null {
if (typeof formatted !== 'string') return null
const trimmed = formatted.trim().toUpperCase()
const match = trimmed.match(/^([A-Z])(\d+)$/)
if (!match) return null
const number = parseInt(match[2], 10)
if (!Number.isFinite(number) || number <= 0) return null
return { series: match[1], number }
}