feat(invoices): allow BAS class 1-3 posting-account overrides and complete the aktiekapital note (#1121)

- invoice/article posting-account overrides accept active class 1-3 accounts;
  class 1-2 (balance-sheet) accounts are rejected on VAT-bearing lines so the
  ruta 05 tax base always books to a 3xxx account
- shared posting-account regex across server schemas, pending-operation
  re-validation, and client forms
- share-capital settings (aktiekapital/antal_aktier) feed the annual-report
  note; kvotvarde derived per ABL 1 kap 6 $; all-or-nothing pair constraint
- signed per-rate VAT breakdown on credit-note PDFs; U+2212 to ASCII hyphen

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-23 12:16:00 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent b0044bfe98
commit 43f7ccab9e
27 changed files with 440 additions and 109 deletions
+17 -7
View File
@@ -18,6 +18,7 @@ import { createClient } from '@/lib/supabase/client'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
import type { BASAccount, CreateArticleInput } from '@/types'
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
// A row from the currencies reference table (lib migration
// 20260630110000_currencies_reference_table.sql).
@@ -48,11 +49,11 @@ export default function ArticleForm({
const { company } = useCompany()
const supabase = createClient()
const t = useTranslations('form_article')
// Active class-3 (revenue) accounts for the combobox. The combobox accepts
// Active class 1-3 posting accounts for the combobox. The combobox accepts
// unknown 4-digit numbers optimistically: the API answers with
// ACCOUNTS_NOT_IN_CHART for activatable BAS accounts, and the host page's
// ActivateAccountsDialog flow takes over (same UX as the journal entry form).
const [revenueAccounts, setRevenueAccounts] = useState<BASAccount[]>([])
const [postingAccounts, setPostingAccounts] = useState<BASAccount[]>([])
// Inline account creation: what the user typed in the combobox when they hit
// "Skapa konto": non-null opens AddAccountDialog prefilled with it.
const [createAccountPrefill, setCreateAccountPrefill] = useState<string | null>(null)
@@ -66,9 +67,11 @@ export default function ArticleForm({
async function fetchRevenueAccounts() {
try {
const res = await fetch('/api/bookkeeping/accounts?class=3')
const res = await fetch('/api/bookkeeping/accounts')
const body = await res.json()
setRevenueAccounts((body?.data as BASAccount[]) || [])
const accounts = ((body?.data as BASAccount[]) || [])
.filter((account) => account.account_class >= 1 && account.account_class <= 3)
setPostingAccounts(accounts)
} catch {
// Non-fatal: the combobox degrades to free 4-digit entry.
}
@@ -141,7 +144,11 @@ export default function ArticleForm({
// ISO 4217 alpha-3; the authoritative allow-list is the currencies
// table (the DB FK rejects unknown codes).
currency: z.string().regex(/^[A-Z]{3}$/),
revenue_account: z.string().optional(),
revenue_account: z
.string()
.regex(INVOICE_POSTING_ACCOUNT_REGEX, t('posting_account_invalid'))
.or(z.literal(''))
.optional(),
cost_price: z.number().nonnegative().optional(),
ean: z.string().optional(),
housework_type: z.string().optional(),
@@ -345,12 +352,15 @@ export default function ArticleForm({
render={({ field }) => (
<AccountCombobox
value={field.value || ''}
accounts={revenueAccounts}
accounts={postingAccounts}
onChange={field.onChange}
onCreateAccount={(prefill) => setCreateAccountPrefill(prefill)}
/>
)}
/>
{errors.revenue_account && (
<p className="text-sm text-destructive">{errors.revenue_account.message}</p>
)}
<p className="text-xs text-muted-foreground">{t('revenue_account_hint')}</p>
</div>
@@ -476,7 +486,7 @@ export default function ArticleForm({
{/* Inline custom-account creation (renders in a portal, outside the form).
After create: refresh the chart and select the new number as the
article's revenue account: mirrors the journal entry form. */}
article's posting account: mirrors the journal entry form. */}
<AddAccountDialog
open={createAccountPrefill != null}
onOpenChange={(next) => {
+21 -9
View File
@@ -62,6 +62,7 @@ import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import type { InvoiceCopyInitial } from '@/lib/invoices/copy-invoice'
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType, Article, Invoice, InvoiceItem, BASAccount } from '@/types'
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
@@ -166,7 +167,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
vat_rate: z.number().min(0).max(25),
// Article linkage (artikelregister). Optional: free-text lines omit them.
article_id: z.string().nullable().optional(),
revenue_account: z.string().nullable().optional(),
revenue_account: z
.string()
.regex(INVOICE_POSTING_ACCOUNT_REGEX, t('posting_account_invalid'))
.nullable()
.optional(),
// ROT/RUT-avdrag per line. Optional: null means "no deduction".
deduction_type: z.enum(['rot', 'rut']).nullable().optional(),
labor_hours: z.number().nonnegative().nullable().optional(),
@@ -294,9 +299,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// Artikelregister: active articles for the line picker + which line is mid quick-create.
const [articles, setArticles] = useState<ArticleOption[]>([])
const [savingArticleIndex, setSavingArticleIndex] = useState<number | null>(null)
// Class-3 (revenue) accounts for the optional per-line försäljningskonto
// override, plus which rows currently show that picker.
const [revenueAccounts, setRevenueAccounts] = useState<BASAccount[]>([])
// Active balance-sheet and revenue accounts for the optional per-line
// posting override, plus which rows currently show that picker.
const [postingAccounts, setPostingAccounts] = useState<BASAccount[]>([])
const [accountOverrideRows, setAccountOverrideRows] = useState<Set<number>>(new Set())
// Dimension tagging (kostnadsställe/projekt, dimensions PR7). Affordances
// render only when company_settings.dimensions_enabled: a UI-visibility
@@ -497,9 +502,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
async function fetchRevenueAccounts() {
if (!company?.id) return
try {
const res = await fetch('/api/bookkeeping/accounts?class=3')
const res = await fetch('/api/bookkeeping/accounts')
const body = await res.json()
setRevenueAccounts((body?.data as BASAccount[]) || [])
const accounts = ((body?.data as BASAccount[]) || [])
.filter((account) => account.account_class >= 1 && account.account_class <= 3)
setPostingAccounts(accounts)
} catch {
// Non-fatal: the override picker degrades to free 4-digit entry.
}
@@ -851,7 +858,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
}
}
// Open/close the optional per-line försäljningskonto override. Closing clears
// Open/close the optional per-line posting-account override. Closing clears
// the value so the engine falls back to the VAT-rate-derived revenue account.
function toggleAccountOverride(index: number) {
const isOpen = accountOverrideRows.has(index) || !!watchItems[index]?.revenue_account
@@ -1896,7 +1903,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
</div>
)}
{/* Optional försäljningskonto override (engångsartikel). When
{/* Optional posting-account override (engångsartikel). When
unset the engine derives the revenue account from the VAT
rate; reverse-charge/export lines ignore the override. */}
{isInvoiceDoc && watchItems[index]?.line_type !== 'text' &&
@@ -1913,11 +1920,16 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
render={({ field }) => (
<AccountCombobox
value={field.value ?? ''}
accounts={revenueAccounts}
accounts={postingAccounts}
onChange={(v) => field.onChange(v || null)}
/>
)}
/>
{errors.items?.[index]?.revenue_account && (
<p className="text-sm text-destructive">
{errors.items[index].revenue_account?.message}
</p>
)}
</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">{t('revenue_account_hint')}</p>
+6 -2
View File
@@ -6,10 +6,14 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { roundOre } from '@/lib/money'
import { formatCurrency } from '@/lib/utils'
import type { CompanySettings } from '@/types'
// Deliberately narrow (data minimisation): the form only ever needs the two
// share-capital fields, not the whole CompanySettings object.
interface ShareCapitalFormProps {
settings: CompanySettings
settings: {
aktiekapital?: number | null
antal_aktier?: number | null
}
}
/**
@@ -59,7 +59,11 @@ export function CompanySettingsContent() {
<div className="space-y-8">
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
<CompanyInfoForm settings={settings} />
{settings.entity_type === 'aktiebolag' && <ShareCapitalForm settings={settings} />}
{settings.entity_type === 'aktiebolag' && (
<ShareCapitalForm
settings={{ aktiekapital: settings.aktiekapital, antal_aktier: settings.antal_aktier }}
/>
)}
</SettingsFormWrapper>
<div className="border-t border-border pt-8">