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:
co-authored by
Claude Fable 5
parent
b0044bfe98
commit
43f7ccab9e
@@ -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) => {
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
Reference in New Issue
Block a user