feat(bookkeeping): verifikationsserie per bankkonto for bank-transaction bookings (#2160)
* feat(bookkeeping): verifikationsserie per bankkonto for bank-transaction bookings A company running several bank accounts (main bank on A, company card on M, both imported via CSV) could not route each account's bookings into its own series: every bank_transaction booking took the single company-wide default from default_voucher_series_per_source_type. - cash_accounts.voucher_series (nullable, single letter): per-account override, editable under Inställningar → Bokföring → Verifikationsserier per bankkonto (new PATCH /api/cash-accounts/[id]). - resolveCashAccountVoucherSeries(): step 2 of the resolution order (explicit pick → account override → per-type map → A). Wired into the book route and createTransactionJournalEntry, which covers categorize, the agent, pending operations and the v1 API. - Booking dialog gets the series picker, seeded from the server via /voucher-sequences/next?source_type&cash_account_id so dialog and route can never disagree. An unresolved embedded picker omits voucher_series so a stray 'A' never overrides the account's series. Scope: bank_transaction bookings only. Invoice settlements matched from the bank keep their payment series; bulk-book resolves inside its RPC (see DECISIONS.md). Migration applied to staging as 20260902121420. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JWSLbQc3jgpfqnxWe6nteh * fix(bookkeeping): audit and document the per-bankkonto series, tighten preview and PATCH Consolidated pass over the PR #2160 findings (skeptics, CodeRabbit, Swedish compliance review): - Behandlingshistorik (BFNAR 2013:2 p. 9.16): changing cash_accounts.voucher_series is a behandlingsregel that outranks the audited per-type map. New trigger audit_cash_accounts_voucher_series (UPDATE only, WHEN the series changes, so bank-sync churn never logs), cash_accounts added to AUDITED_TABLES and the audit_log filter, "Bankkonto ... Verifikationsserie: (tomt) -> M" events in the report, pg-real test. Applied to staging as 20260902124513. - Systemdokumentation (p. 9.2-9.15): revision/systemdokumentation.json gains a verifikationsserier_regler block with the resolution order and the two exceptions (invoice settlements, samlingsverifikat); the per-account mapping itself is in data/cash_accounts.json. - Settings picker uses the same closed list as the manual verifikat form (presets plus letters already in use) instead of all 26 letters; strings moved to messages/sv.json and messages/en.json. - /voucher-sequences/next applies the account override only for source_type=bank_transaction (CodeRabbit), so a manual-entry preview cannot show a series the entry will not get. - Book route resolves the series from the account the row ends up on after a stranded-row repoint, not the stale one. - PATCH /api/cash-accounts/[id] answers 404 for a non-UUID id instead of a Postgres cast 500; the series lookup logs a warning when it fails open. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JWSLbQc3jgpfqnxWe6nteh --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
678acfe7ef
commit
f1230282a9
@@ -19,6 +19,7 @@ function makeCashAccount(overrides: Partial<CashAccount> = {}): CashAccount {
|
||||
enabled: true,
|
||||
is_primary: true,
|
||||
source: 'manual',
|
||||
voucher_series: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
|
||||
@@ -291,6 +291,9 @@ export const VoucherSequenceNextQuerySchema = z.object({
|
||||
period_id: uuid.optional(),
|
||||
series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z').optional(),
|
||||
source_type: JournalEntrySourceTypeSchema.optional(),
|
||||
// Bank account the entry is booked from: its voucher_series override (when
|
||||
// set) takes precedence over the per-source-type default.
|
||||
cash_account_id: uuid.optional(),
|
||||
date: isoDate.optional(),
|
||||
})
|
||||
|
||||
@@ -1590,6 +1593,9 @@ export const BookTransactionSchema = z
|
||||
entry_date: isoDate,
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
lines: z.array(CreateJournalEntryLineSchema).min(1, 'At least one line is required'),
|
||||
// Explicit series from the booking dialog's picker. Omitted: the route
|
||||
// resolves it from the transaction's cash account, then the per-type map.
|
||||
voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z').optional(),
|
||||
// Booking-time duplicate guard: see CategorizeTransactionSchema.
|
||||
force: z.boolean().optional(),
|
||||
expected_duplicate_transaction_id: uuid.optional(),
|
||||
@@ -1721,6 +1727,18 @@ export const MoveTransactionCashAccountSchema = z.object({
|
||||
.regex(/^19\d{2}$/, 'Expected a BAS 19xx bank account number'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Set or clear the verifikationsserie override on one of the company's cash
|
||||
* accounts. null clears the override: entries booked from the account then
|
||||
* follow the per-source-type default again.
|
||||
*/
|
||||
export const UpdateCashAccountVoucherSeriesSchema = z.object({
|
||||
voucher_series: z
|
||||
.string()
|
||||
.regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z')
|
||||
.nullable(),
|
||||
})
|
||||
|
||||
export const BookInboxItemDirectlySchema = z.object({
|
||||
fiscal_period_id: uuid,
|
||||
entry_date: isoDate,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import {
|
||||
cashAccountSeriesOverride,
|
||||
resolveCashAccountVoucherSeries,
|
||||
} from '../cash-account-voucher-series'
|
||||
|
||||
const { supabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
||||
|
||||
describe('cashAccountSeriesOverride', () => {
|
||||
it('returns the letter when the account carries a valid override', () => {
|
||||
expect(cashAccountSeriesOverride({ voucher_series: 'M' })).toBe('M')
|
||||
})
|
||||
|
||||
it('returns undefined for null, missing, lowercase or multi-letter values', () => {
|
||||
expect(cashAccountSeriesOverride(null)).toBeUndefined()
|
||||
expect(cashAccountSeriesOverride(undefined)).toBeUndefined()
|
||||
expect(cashAccountSeriesOverride({ voucher_series: null })).toBeUndefined()
|
||||
expect(cashAccountSeriesOverride({})).toBeUndefined()
|
||||
expect(cashAccountSeriesOverride({ voucher_series: 'm' })).toBeUndefined()
|
||||
expect(cashAccountSeriesOverride({ voucher_series: 'AB' })).toBeUndefined()
|
||||
expect(cashAccountSeriesOverride({ voucher_series: '' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCashAccountVoucherSeries', () => {
|
||||
beforeEach(() => {
|
||||
reset()
|
||||
})
|
||||
|
||||
it('skips the lookup entirely when the transaction has no cash account', async () => {
|
||||
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', null)).toBeUndefined()
|
||||
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', undefined)).toBeUndefined()
|
||||
expect(findCalls('cash_accounts', 'select')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns the account override, scoped to the company', async () => {
|
||||
enqueue({ data: { voucher_series: 'M' }, error: null })
|
||||
|
||||
const series = await resolveCashAccountVoucherSeries(supabase as never, 'company-1', 'ca-1')
|
||||
|
||||
expect(series).toBe('M')
|
||||
const eqCalls = findCalls('cash_accounts', 'eq')
|
||||
expect(eqCalls).toContainEqual(['company_id', 'company-1'])
|
||||
expect(eqCalls).toContainEqual(['id', 'ca-1'])
|
||||
})
|
||||
|
||||
it('returns undefined when the account has no override', async () => {
|
||||
enqueue({ data: { voucher_series: null }, error: null })
|
||||
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', 'ca-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when the account is unknown', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', 'ca-missing')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails open (undefined) on a query error so the booking still goes through', async () => {
|
||||
enqueue({ data: null, error: { message: 'boom' } })
|
||||
expect(await resolveCashAccountVoucherSeries(supabase as never, 'company-1', 'ca-1')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -468,6 +468,30 @@ describe('createTransactionJournalEntry', () => {
|
||||
expect(input.source_id).toBe('tx-abc-123')
|
||||
})
|
||||
|
||||
it("books into the cash account's verifikationsserie when the account carries one", async () => {
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
reset()
|
||||
enqueue({ data: { voucher_series: 'M' }, error: null })
|
||||
const tx = makeTransaction({ amount: -100, cash_account_id: 'ca-card' })
|
||||
|
||||
await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, makeMappingResult())
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(input.voucher_series).toBe('M')
|
||||
})
|
||||
|
||||
it('omits voucher_series (engine resolves the per-type default) when the account has no override', async () => {
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
reset()
|
||||
enqueue({ data: { voucher_series: null }, error: null })
|
||||
const tx = makeTransaction({ amount: -100, cash_account_id: 'ca-main' })
|
||||
|
||||
await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, makeMappingResult())
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect('voucher_series' in input).toBe(false)
|
||||
})
|
||||
|
||||
it('uses transaction.date as entry_date', async () => {
|
||||
const tx = makeTransaction({ date: '2024-09-15', amount: -100 })
|
||||
const mapping = makeMappingResult()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Verifikationsserie per bankkonto.
|
||||
*
|
||||
* Resolution order for an entry booked from a bank transaction:
|
||||
* 1. explicit voucher_series in the request (the booking dialog's picker)
|
||||
* 2. cash_accounts.voucher_series of the transaction's account (this module)
|
||||
* 3. company_settings.default_voucher_series_per_source_type (engine)
|
||||
* 4. 'A' (engine)
|
||||
*
|
||||
* This helper covers step 2 only. It returns undefined whenever there is no
|
||||
* override so callers can pass the result straight into CreateJournalEntryInput
|
||||
* and let the engine handle steps 3 and 4. Lookup failures also resolve to
|
||||
* undefined: a broken override must never block a booking, the entry then
|
||||
* lands in the per-type default exactly as before this feature.
|
||||
*
|
||||
* Scope: bank-transaction bookings (book route, categorize, agent, pending
|
||||
* operations). Invoice settlements matched from the bank keep the invoice
|
||||
* payment series (invoice_paid, supplier_invoice_paid): those series describe
|
||||
* the payment kind, not the account the money moved through.
|
||||
*/
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('cash-account-voucher-series')
|
||||
const SERIES_LETTER_RE = /^[A-Z]$/
|
||||
|
||||
/** Pure: the override letter of a cash account row, or undefined. */
|
||||
export function cashAccountSeriesOverride(
|
||||
account: { voucher_series?: string | null } | null | undefined,
|
||||
): string | undefined {
|
||||
const value = account?.voucher_series
|
||||
return typeof value === 'string' && SERIES_LETTER_RE.test(value) ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the series override of one of the company's cash accounts.
|
||||
* undefined when the account is unknown, has no override, or the query fails.
|
||||
*/
|
||||
export async function resolveCashAccountVoucherSeries(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
cashAccountId: string | null | undefined,
|
||||
): Promise<string | undefined> {
|
||||
if (!cashAccountId) return undefined
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('voucher_series')
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', cashAccountId)
|
||||
.maybeSingle()
|
||||
if (error) {
|
||||
// Fail open, but never silently: the entry lands in the per-type
|
||||
// default and the log says why.
|
||||
log.warn('cash_accounts voucher_series lookup failed; using per-type default', {
|
||||
companyId,
|
||||
cashAccountId,
|
||||
error: error.message,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
return cashAccountSeriesOverride(data as { voucher_series?: string | null } | null)
|
||||
} catch (err) {
|
||||
log.warn('cash_accounts voucher_series lookup threw; using per-type default', {
|
||||
companyId,
|
||||
cashAccountId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createJournalEntry, findFiscalPeriod } from './engine'
|
||||
import { resolveCashAccountVoucherSeries } from './cash-account-voucher-series'
|
||||
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
|
||||
import { coerceDimensionsBag } from './dimension-resolver'
|
||||
import { extractNetAmount, extractVatAmount } from './vat-entries'
|
||||
@@ -338,6 +339,14 @@ export async function createTransactionJournalEntry(
|
||||
? [baseDescription, ...extraParts].filter(Boolean).join(' · ').slice(0, 500)
|
||||
: baseDescription
|
||||
|
||||
// The transaction's bank account may carry its own verifikationsserie;
|
||||
// undefined lets the engine fall back to the per-source-type default.
|
||||
const voucherSeries = await resolveCashAccountVoucherSeries(
|
||||
supabase,
|
||||
companyId,
|
||||
transaction.cash_account_id,
|
||||
)
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: entryDate,
|
||||
@@ -345,6 +354,7 @@ export async function createTransactionJournalEntry(
|
||||
source_type: 'bank_transaction',
|
||||
source_id: transaction.id,
|
||||
lines,
|
||||
...(voucherSeries ? { voucher_series: voucherSeries } : {}),
|
||||
}
|
||||
|
||||
return createJournalEntry(supabase, companyId, userId, input)
|
||||
|
||||
@@ -1523,6 +1523,28 @@ export async function setLedgerAccount(
|
||||
if (error) throw new Error(`cash_accounts setLedgerAccount failed: ${error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or clear the verifikationsserie override for a cash account. null means
|
||||
* "follow the per-source-type default"; the engine reads this via
|
||||
* resolveCashAccountVoucherSeries() when it books from the account.
|
||||
*/
|
||||
export async function setVoucherSeries(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
cashAccountId: string,
|
||||
voucherSeries: string | null,
|
||||
): Promise<CashAccount | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('cash_accounts')
|
||||
.update({ voucher_series: voucherSeries })
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', cashAccountId)
|
||||
.select('*')
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`cash_accounts setVoucherSeries failed: ${error.message}`)
|
||||
return (data as CashAccount | null) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a cash account as the primary for its company. Delegates to the
|
||||
* `set_cash_account_primary` RPC so the clear-old-primary and set-new-primary
|
||||
|
||||
@@ -707,6 +707,30 @@ describe('auditRowToEvent: behandlingsregler', () => {
|
||||
expect(upd.details).toEqual(['Debetkonto: 6540 → 6212'])
|
||||
})
|
||||
|
||||
it('cash_accounts: a verifikationsserie change names the account and diffs the series', () => {
|
||||
const upd = auditRowToEvent(
|
||||
auditRow({
|
||||
table_name: 'cash_accounts',
|
||||
action: 'UPDATE',
|
||||
old_state: { name: 'Företagskort', ledger_account: '1931', voucher_series: null, balance: 100, updated_at: 'x' },
|
||||
new_state: { name: 'Företagskort', ledger_account: '1931', voucher_series: 'M', balance: 250, updated_at: 'y' },
|
||||
}),
|
||||
)!
|
||||
expect(upd).toMatchObject({ category: 'installningar', code: 'cash_account.updated', object: 'Företagskort 1931' })
|
||||
expect(upd.details).toEqual(['Verifikationsserie: (tomt) → M'])
|
||||
|
||||
// Bank-sync churn (balance, name) is not a behandlingsregel: no event.
|
||||
const churn = auditRowToEvent(
|
||||
auditRow({
|
||||
table_name: 'cash_accounts',
|
||||
action: 'UPDATE',
|
||||
old_state: { name: 'Företagskort', ledger_account: '1931', voucher_series: 'M', balance: 100 },
|
||||
new_state: { name: 'Företagskort', ledger_account: '1931', voucher_series: 'M', balance: 250 },
|
||||
}),
|
||||
)
|
||||
expect(churn).toBeNull()
|
||||
})
|
||||
|
||||
it('categorization_templates: the learning columns never reach the report', () => {
|
||||
// The DB trigger filters these already (20260901103000 + 20260901200000);
|
||||
// the read model must not resurrect them if a row slips through, or every
|
||||
|
||||
@@ -209,6 +209,10 @@ export const AUDITED_TABLES = [
|
||||
'booking_template_library',
|
||||
'sie_imports',
|
||||
'bank_file_imports',
|
||||
// Verifikationsserie per bankkonto (audited since migration 20260902124513):
|
||||
// the per-account override outranks the per-source-type map above, so it is
|
||||
// a behandlingsregel in the same sense.
|
||||
'cash_accounts',
|
||||
] as const
|
||||
|
||||
/**
|
||||
@@ -232,7 +236,7 @@ export const GLOBAL_ACTIONS = [
|
||||
* names statically; a unit test pins it to AUDITED_TABLES / GLOBAL_ACTIONS.
|
||||
*/
|
||||
export const AUDIT_ROW_FILTER =
|
||||
'table_name.in.(journal_entries,chart_of_accounts,company_settings,fiscal_periods,api_keys,dimensions,dimension_values,account_dimension_rules,accrual_schedules,document_attachments,mapping_rules,categorization_templates,booking_template_library,sie_imports,bank_file_imports),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)'
|
||||
'table_name.in.(journal_entries,chart_of_accounts,company_settings,fiscal_periods,api_keys,dimensions,dimension_values,account_dimension_rules,accrual_schedules,document_attachments,mapping_rules,categorization_templates,booking_template_library,sie_imports,bank_file_imports,cash_accounts),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)'
|
||||
|
||||
const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||
manual: 'Manuell',
|
||||
@@ -411,6 +415,15 @@ const MAPPING_RULE_FIELDS: Record<string, string> = {
|
||||
is_active: 'Aktiv',
|
||||
}
|
||||
|
||||
/**
|
||||
* cash_accounts columns that are behandlingsregler. Only voucher_series: the
|
||||
* trigger (20260902124513) fires on that column alone, and the read model
|
||||
* must not resurrect balance/name churn from bank sync if a row slips through.
|
||||
*/
|
||||
const CASH_ACCOUNT_FIELDS: Record<string, string> = {
|
||||
voucher_series: 'Verifikationsserie',
|
||||
}
|
||||
|
||||
const CATEGORIZATION_TEMPLATE_FIELDS: Record<string, string> = {
|
||||
counterparty_name: 'Motpart',
|
||||
// counterparty_aliases deliberately absent: aliases grow in the same
|
||||
@@ -1128,6 +1141,14 @@ export function auditRowToEvent(
|
||||
fields: BOOKING_TEMPLATE_FIELDS,
|
||||
objectKeys: ['name'],
|
||||
})
|
||||
case 'cash_accounts':
|
||||
return genericAuditEvent(row, {
|
||||
category: 'installningar',
|
||||
codePrefix: 'cash_account',
|
||||
noun: 'Bankkonto',
|
||||
fields: CASH_ACCOUNT_FIELDS,
|
||||
objectKeys: ['name', 'ledger_account'],
|
||||
})
|
||||
case 'salary_payroll_config':
|
||||
return payrollConfigAuditEvent(row)
|
||||
// The import tables emit their own events from the rows themselves; the
|
||||
@@ -1584,7 +1605,7 @@ async function fetchAuditRows(
|
||||
// Literal on purpose (not AUDIT_ROW_FILTER): the schema guard only
|
||||
// resolves string literals here. A test pins the two to each other.
|
||||
.or(
|
||||
'table_name.in.(journal_entries,chart_of_accounts,company_settings,fiscal_periods,api_keys,dimensions,dimension_values,account_dimension_rules,accrual_schedules,document_attachments,mapping_rules,categorization_templates,booking_template_library,sie_imports,bank_file_imports),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)',
|
||||
'table_name.in.(journal_entries,chart_of_accounts,company_settings,fiscal_periods,api_keys,dimensions,dimension_values,account_dimension_rules,accrual_schedules,document_attachments,mapping_rules,categorization_templates,booking_template_library,sie_imports,bank_file_imports,cash_accounts),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)',
|
||||
)
|
||||
.order('created_at', { ascending: true })
|
||||
.order('id', { ascending: true })
|
||||
|
||||
@@ -1546,6 +1546,23 @@ async function buildSystemDoc(
|
||||
fiscal_period_id: vs.fiscal_period_id ?? null,
|
||||
})
|
||||
),
|
||||
// BFNAR 2013:2 p. 9.2-9.15: how verifikationer are assigned to a series
|
||||
// is a behandlingsregel the systemdokumentation has to spell out, incl.
|
||||
// the exceptions. The concrete mappings live in the data tables named
|
||||
// here (company_settings.default_voucher_series_per_source_type and
|
||||
// cash_accounts.voucher_series); changes to both are in behandlingshistorik.
|
||||
verifikationsserier_regler: {
|
||||
ordning: [
|
||||
'Serie vald av användaren i bokföringsdialogen',
|
||||
'Bankkontots egen verifikationsserie (data/cash_accounts.json, fältet voucher_series), gäller verifikat som skapas från banktransaktioner',
|
||||
'Standardserie per verifikattyp (data/company_settings.json, fältet default_voucher_series_per_source_type)',
|
||||
'Serie A',
|
||||
],
|
||||
undantag: [
|
||||
'Betalningar av kund- och leverantörsfakturor som matchas mot en banktransaktion använder fakturatypens serie, inte bankkontots',
|
||||
'Samlingsverifikat (bokföring av flera banktransaktioner i ett verifikat) använder standardserien för banktransaktioner',
|
||||
],
|
||||
},
|
||||
behorighetskontroll: {
|
||||
description: 'Rollbaserad atkomstkontroll med owner/admin/member/viewer',
|
||||
mfa_stod: true,
|
||||
|
||||
Reference in New Issue
Block a user