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
+7
View File
@@ -310,4 +310,11 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-23] Validate preview-PDF payment settings before fetching customer data using the requested currency and document type: this preserves the same exemption semantics while minimizing personal-data processing for requests that cannot render. [2026-07-23] Validate preview-PDF payment settings before fetching customer data using the requested currency and document type: this preserves the same exemption semantics while minimizing personal-data processing for requests that cannot render.
[2026-07-23] Retain an exact pending delivery snapshot when the provider succeeds but the terminal evidence RPC cannot be confirmed, and keep it outside the preparing-only reservation lock: inventing a sent state would be unsafe, while immutable payload, PDF, operator warnings, and later explicit resend availability preserve evidence and recovery. [2026-07-23] Retain an exact pending delivery snapshot when the provider succeeds but the terminal evidence RPC cannot be confirmed, and keep it outside the preparing-only reservation lock: inventing a sent state would be unsafe, while immutable payload, PDF, operator warnings, and later explicit resend availability preserve evidence and recovery.
[2026-07-23] Keep the invoice-delivery DPIA as a documented screening rather than fabricating a full Article 35 assessment or DPO sign-off: the screened processing does not meet the high-risk threshold, and the implemented controls minimize routine access while preserving statutory evidence. [2026-07-23] Keep the invoice-delivery DPIA as a documented screening rather than fabricating a full Article 35 assessment or DPO sign-off: the screened processing does not meet the high-risk threshold, and the implemented controls minimize routine access while preserving statutory evidence.
[2026-07-23] Invoice PDFs normalize the sv-SE U+2212 minus to ASCII hyphen-minus and retain signed per-rate VAT values: standard PDF fonts can drop U+2212, and absolute VAT groups make deductions and credit notes fail to reconcile with their rows.
[2026-07-23] Invoice-line and article account overrides accept active BAS classes 1-3 while retaining the legacy revenue_account wire and database field: balance accounts cover deposits, advances, and genuine customer outlays without a migration or API break; classes 4-8 remain excluded so the invoice editor does not become an unrestricted journal editor, and reverse-charge/export mappings remain mandatory.
[2026-07-23] Derive kvotvärde (aktiekapital / antal_aktier) in the annual-report note instead of adding the kvotvarde column the builder used to read: a stored third value could desync from the other two and file an internally inconsistent Bolagsverket note; ABL 1 kap 6 § makes it purely derived. [2026-07-23] Derive kvotvärde (aktiekapital / antal_aktier) in the annual-report note instead of adding the kvotvarde column the builder used to read: a stored third value could desync from the other two and file an internally inconsistent Bolagsverket note; ABL 1 kap 6 § makes it purely derived.
[2026-07-23] Class 1-2 posting overrides are rejected on VAT-bearing invoice lines (INVOICE_CREATE_POSTING_ACCOUNT_VAT_CONFLICT) instead of narrowing the schema back to 3xxx: keeps the deposit/advance feature while guaranteeing the tax base for ruta 05 always books to a revenue account (ML 17 kap 24 §).
[2026-07-23] The posting-account regex now lives in one shared constant (lib/invoices/posting-account.ts) imported by server Zod schemas, pending-operation re-validation, and both client forms, so the layers cannot drift.
[2026-07-23] Declined the compliance-swarm suggestion to add DROP COLUMN kvotvarde to the share-capital migration: the column never existed in any migration, so there is nothing to drop.
[2026-07-23] Kept the missing-aktiekapital annual-report path as a warning rather than a hard block on generation: users must be able to preview an in-progress arsredovisning; the warning surfaces in the wizard/validation before filing, and no placeholder text lands in the filed PDF.
[2026-07-23] Declined moving ArticleForm's class 1-3 account filter server-side: the chart of accounts is company-scoped and non-sensitive, the authoritative gate is server-side at booking time, and the combobox intentionally sees the full chart for the activate-account flow.
+1 -1
View File
@@ -55,7 +55,7 @@ export const PATCH = withRouteContext(
if (!result.success) return result.response if (!result.success) return result.response
const body = result.data const body = result.data
// Same activate-and-retry contract as POST /api/articles: a class-3 account // Same activate-and-retry contract as POST /api/articles: a class 1-3 account
// that just isn't activated yet returns ACCOUNTS_NOT_IN_CHART. // that just isn't activated yet returns ACCOUNTS_NOT_IN_CHART.
if (body.revenue_account) { if (body.revenue_account) {
const status = await checkRevenueAccount(supabase, companyId!, body.revenue_account) const status = await checkRevenueAccount(supabase, companyId!, body.revenue_account)
+16
View File
@@ -131,6 +131,22 @@ describe('GET/POST /api/articles', () => {
expect(body.data.revenue_account).toBe('3001') expect(body.data.revenue_account).toBe('3001')
}) })
it('POST accepts a posting account that is active class 2 in the chart', async () => {
enqueue({ data: { account_class: 2, is_active: true } })
enqueue({ data: { id: 'a1', name: 'Deposition', article_number: '4', revenue_account: '2897' } })
const request = createMockRequest('/api/articles', {
method: 'POST',
body: { name: 'Deposition', price_excl_vat: 1000, vat_rate: 0, revenue_account: '2897' },
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ data: { revenue_account: string } }>(response)
expect(status).toBe(200)
expect(body.data.revenue_account).toBe('2897')
})
it('POST creates an article and auto-assigns a number', async () => { it('POST creates an article and auto-assigns a number', async () => {
// 1st DB hit: insert ... returning the row (article_number still null). // 1st DB hit: insert ... returning the row (article_number still null).
enqueue({ data: { id: 'a1', name: 'Konsulttimme', article_number: null, type: 'tjanst', vat_rate: 25 } }) enqueue({ data: { id: 'a1', name: 'Konsulttimme', article_number: null, type: 'tjanst', vat_rate: 25 } })
+2 -2
View File
@@ -55,8 +55,8 @@ export const POST = withRouteContext(
if (!result.success) return result.response if (!result.success) return result.response
const body = result.data const body = result.data
// Guard the optional revenue-account override against the chart of accounts. // Guard the optional posting-account override against the chart of accounts.
// A class-3 account that merely isn't activated yet gets the standard // A class 1-3 account that merely isn't activated yet gets the standard
// ACCOUNTS_NOT_IN_CHART envelope so the client can offer activate-and-retry. // ACCOUNTS_NOT_IN_CHART envelope so the client can offer activate-and-retry.
if (body.revenue_account) { if (body.revenue_account) {
const status = await checkRevenueAccount(supabase, companyId!, body.revenue_account) const status = await checkRevenueAccount(supabase, companyId!, body.revenue_account)
@@ -995,6 +995,84 @@ describe('POST /api/v1/companies/:companyId/invoices', () => {
expect(insertedItems![0].revenue_account).toBe('3041') expect(insertedItems![0].revenue_account).toBe('3041')
}) })
it('persists a class-2 posting account on an invoice item', async () => {
withInvoiceWriteScope()
const createdInvoice = { id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', status: 'draft' }
let insertedItems: Array<Record<string, unknown>> | null = null
mockServiceClient.mockReturnValue({
from: (table: string) => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'insert') {
return (rows: Record<string, unknown> | Array<Record<string, unknown>>) => {
if (table === 'invoice_items') insertedItems = rows as Array<Record<string, unknown>>
return new Proxy({}, handler)
}
}
if (prop === 'then') {
const data = table === 'company_members'
? { company_id: COMPANY_ID, role: 'owner' }
: table === 'customers'
? SWEDISH_BUSINESS_CUSTOMER
: table === 'chart_of_accounts'
? [{ account_number: '2897' }]
: table === 'invoices'
? createdInvoice
: null
return (resolve: (value: unknown) => void) => resolve({ data, error: null })
}
return () => new Proxy({}, handler)
},
}
return new Proxy({}, handler)
},
})
const res = await createInvoice(
makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, {
customer_id: CUSTOMER_ID,
invoice_date: '2026-05-12',
due_date: '2026-06-11',
currency: 'SEK',
items: [
{ description: 'Deposition', quantity: 1, unit: 'st', unit_price: 1000, vat_rate: 0, revenue_account: '2897' },
],
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(201)
expect(insertedItems![0].revenue_account).toBe('2897')
})
it('rejects a class-2 posting account on a VAT-bearing line', async () => {
withInvoiceWriteScope()
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
customers: { data: SWEDISH_BUSINESS_CUSTOMER, error: null },
chart_of_accounts: { data: [{ account_number: '2897' }], error: null },
}),
)
const res = await createInvoice(
makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, {
customer_id: CUSTOMER_ID,
invoice_date: '2026-05-12',
due_date: '2026-06-11',
currency: 'SEK',
items: [
{ description: 'Deposition', quantity: 1, unit: 'st', unit_price: 1000, vat_rate: 25, revenue_account: '2897' },
],
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_CREATE_POSTING_ACCOUNT_VAT_CONFLICT')
})
it('rejects a revenue_account not in the chart of accounts', async () => { it('rejects a revenue_account not in the chart of accounts', async () => {
withInvoiceWriteScope() withInvoiceWriteScope()
mockServiceClient.mockReturnValue( mockServiceClient.mockReturnValue(
@@ -384,7 +384,7 @@ registerEndpoint({
'is_self_billed=true registers a self-billing invoice your CUSTOMER issued on your behalf (a sale for you). It is booked immediately (not a draft, no F-number), so external_invoice_number and received_date are required and it is NOT dry-run-free of side effects on the live call. Do NOT set it for a normal invoice you issue yourself.', 'is_self_billed=true registers a self-billing invoice your CUSTOMER issued on your behalf (a sale for you). It is booked immediately (not a draft, no F-number), so external_invoice_number and received_date are required and it is NOT dry-run-free of side effects on the live call. Do NOT set it for a normal invoice you issue yourself.',
'Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). Tags are stored on the draft and applied to the journal entry lines when the invoice is sent. When the company has the dimension registry enabled, unknown or archived codes are rejected at :send with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions.', 'Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). Tags are stored on the draft and applied to the journal entry lines when the invoice is sent. When the company has the dimension registry enabled, unknown or archived codes are rejected at :send with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions.',
'ROT/RUT: set items[].deduction_type ("rot"|"rut") on labor lines plus labor_hours and work_type (Skatteverket arbetstypskod). The invoice must carry deduction_personnummer AND housing info: deduction_housing_designation (fastighetsbeteckning) for småhus, or deduction_apartment_number + deduction_brf_org_number for bostadsrätt. deduction_amount is computed server-side and cannot be set by the caller; the response exposes deduction_total and remaining_amount = total - deduction_total (Skatteverket pays the rest via 1513). Validation failures return 400 INVOICE_CREATE_ROT_RUT_VALIDATION.', 'ROT/RUT: set items[].deduction_type ("rot"|"rut") on labor lines plus labor_hours and work_type (Skatteverket arbetstypskod). The invoice must carry deduction_personnummer AND housing info: deduction_housing_designation (fastighetsbeteckning) for småhus, or deduction_apartment_number + deduction_brf_org_number for bostadsrätt. deduction_amount is computed server-side and cannot be set by the caller; the response exposes deduction_total and remaining_amount = total - deduction_total (Skatteverket pays the rest via 1513). Validation failures return 400 INVOICE_CREATE_ROT_RUT_VALIDATION.',
'Articles: pass items[].article_id (from the artikelregister, GET /articles) to link a line to a catalog article; price/description are still taken from the request body (the API never auto-fills from the article: send the values you want on the invoice). items[].revenue_account optionally overrides the BAS class-3 account and is validated against the chart of accounts.', 'Articles: pass items[].article_id (from the artikelregister, GET /articles) to link a line to a catalog article; price/description are still taken from the request body (the API never auto-fills from the article: send the values you want on the invoice). items[].revenue_account is the legacy wire name for an optional BAS class 1-3 posting-account override and is validated against the chart of accounts.',
], ],
example: { example: {
request: { request: {
+17 -7
View File
@@ -18,6 +18,7 @@ import { createClient } from '@/lib/supabase/client'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog' import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
import type { BASAccount, CreateArticleInput } from '@/types' 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 // A row from the currencies reference table (lib migration
// 20260630110000_currencies_reference_table.sql). // 20260630110000_currencies_reference_table.sql).
@@ -48,11 +49,11 @@ export default function ArticleForm({
const { company } = useCompany() const { company } = useCompany()
const supabase = createClient() const supabase = createClient()
const t = useTranslations('form_article') 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 // unknown 4-digit numbers optimistically: the API answers with
// ACCOUNTS_NOT_IN_CHART for activatable BAS accounts, and the host page's // ACCOUNTS_NOT_IN_CHART for activatable BAS accounts, and the host page's
// ActivateAccountsDialog flow takes over (same UX as the journal entry form). // 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 // Inline account creation: what the user typed in the combobox when they hit
// "Skapa konto": non-null opens AddAccountDialog prefilled with it. // "Skapa konto": non-null opens AddAccountDialog prefilled with it.
const [createAccountPrefill, setCreateAccountPrefill] = useState<string | null>(null) const [createAccountPrefill, setCreateAccountPrefill] = useState<string | null>(null)
@@ -66,9 +67,11 @@ export default function ArticleForm({
async function fetchRevenueAccounts() { async function fetchRevenueAccounts() {
try { try {
const res = await fetch('/api/bookkeeping/accounts?class=3') const res = await fetch('/api/bookkeeping/accounts')
const body = await res.json() 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 { } catch {
// Non-fatal: the combobox degrades to free 4-digit entry. // 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 // ISO 4217 alpha-3; the authoritative allow-list is the currencies
// table (the DB FK rejects unknown codes). // table (the DB FK rejects unknown codes).
currency: z.string().regex(/^[A-Z]{3}$/), 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(), cost_price: z.number().nonnegative().optional(),
ean: z.string().optional(), ean: z.string().optional(),
housework_type: z.string().optional(), housework_type: z.string().optional(),
@@ -345,12 +352,15 @@ export default function ArticleForm({
render={({ field }) => ( render={({ field }) => (
<AccountCombobox <AccountCombobox
value={field.value || ''} value={field.value || ''}
accounts={revenueAccounts} accounts={postingAccounts}
onChange={field.onChange} onChange={field.onChange}
onCreateAccount={(prefill) => setCreateAccountPrefill(prefill)} 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> <p className="text-xs text-muted-foreground">{t('revenue_account_hint')}</p>
</div> </div>
@@ -476,7 +486,7 @@ export default function ArticleForm({
{/* Inline custom-account creation (renders in a portal, outside the form). {/* Inline custom-account creation (renders in a portal, outside the form).
After create: refresh the chart and select the new number as the 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 <AddAccountDialog
open={createAccountPrefill != null} open={createAccountPrefill != null}
onOpenChange={(next) => { 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 { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute' import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import type { InvoiceCopyInitial } from '@/lib/invoices/copy-invoice' 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' import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType, Article, Invoice, InvoiceItem, BASAccount } from '@/types'
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] 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), vat_rate: z.number().min(0).max(25),
// Article linkage (artikelregister). Optional: free-text lines omit them. // Article linkage (artikelregister). Optional: free-text lines omit them.
article_id: z.string().nullable().optional(), 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". // ROT/RUT-avdrag per line. Optional: null means "no deduction".
deduction_type: z.enum(['rot', 'rut']).nullable().optional(), deduction_type: z.enum(['rot', 'rut']).nullable().optional(),
labor_hours: z.number().nonnegative().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. // Artikelregister: active articles for the line picker + which line is mid quick-create.
const [articles, setArticles] = useState<ArticleOption[]>([]) const [articles, setArticles] = useState<ArticleOption[]>([])
const [savingArticleIndex, setSavingArticleIndex] = useState<number | null>(null) const [savingArticleIndex, setSavingArticleIndex] = useState<number | null>(null)
// Class-3 (revenue) accounts for the optional per-line försäljningskonto // Active balance-sheet and revenue accounts for the optional per-line
// override, plus which rows currently show that picker. // posting override, plus which rows currently show that picker.
const [revenueAccounts, setRevenueAccounts] = useState<BASAccount[]>([]) const [postingAccounts, setPostingAccounts] = useState<BASAccount[]>([])
const [accountOverrideRows, setAccountOverrideRows] = useState<Set<number>>(new Set()) const [accountOverrideRows, setAccountOverrideRows] = useState<Set<number>>(new Set())
// Dimension tagging (kostnadsställe/projekt, dimensions PR7). Affordances // Dimension tagging (kostnadsställe/projekt, dimensions PR7). Affordances
// render only when company_settings.dimensions_enabled: a UI-visibility // 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() { async function fetchRevenueAccounts() {
if (!company?.id) return if (!company?.id) return
try { try {
const res = await fetch('/api/bookkeeping/accounts?class=3') const res = await fetch('/api/bookkeeping/accounts')
const body = await res.json() 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 { } catch {
// Non-fatal: the override picker degrades to free 4-digit entry. // 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. // the value so the engine falls back to the VAT-rate-derived revenue account.
function toggleAccountOverride(index: number) { function toggleAccountOverride(index: number) {
const isOpen = accountOverrideRows.has(index) || !!watchItems[index]?.revenue_account const isOpen = accountOverrideRows.has(index) || !!watchItems[index]?.revenue_account
@@ -1896,7 +1903,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
</div> </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 unset the engine derives the revenue account from the VAT
rate; reverse-charge/export lines ignore the override. */} rate; reverse-charge/export lines ignore the override. */}
{isInvoiceDoc && watchItems[index]?.line_type !== 'text' && {isInvoiceDoc && watchItems[index]?.line_type !== 'text' &&
@@ -1913,11 +1920,16 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
render={({ field }) => ( render={({ field }) => (
<AccountCombobox <AccountCombobox
value={field.value ?? ''} value={field.value ?? ''}
accounts={revenueAccounts} accounts={postingAccounts}
onChange={(v) => field.onChange(v || null)} 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>
</div> </div>
<p className="mt-1 text-xs text-muted-foreground">{t('revenue_account_hint')}</p> <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 { Label } from '@/components/ui/label'
import { roundOre } from '@/lib/money' import { roundOre } from '@/lib/money'
import { formatCurrency } from '@/lib/utils' 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 { interface ShareCapitalFormProps {
settings: CompanySettings settings: {
aktiekapital?: number | null
antal_aktier?: number | null
}
} }
/** /**
@@ -59,7 +59,11 @@ export function CompanySettingsContent() {
<div className="space-y-8"> <div className="space-y-8">
<SettingsFormWrapper onSave={handleSave} className="space-y-8"> <SettingsFormWrapper onSave={handleSave} className="space-y-8">
<CompanyInfoForm settings={settings} /> <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> </SettingsFormWrapper>
<div className="border-t border-border pt-8"> <div className="border-t border-border pt-8">
+11 -8
View File
@@ -6,6 +6,7 @@ import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver' import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account' import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
import { MAX_INVOICE_EMAIL_COPY_RECIPIENTS } from '@/lib/invoices/email-recipients' import { MAX_INVOICE_EMAIL_COPY_RECIPIENTS } from '@/lib/invoices/email-recipients'
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
import type { AuditAction } from '@/types' import type { AuditAction } from '@/types'
// ============================================================ // ============================================================
@@ -47,10 +48,10 @@ const invoiceEmailAddressList = z
`Högst ${MAX_INVOICE_EMAIL_COPY_RECIPIENTS} kopiemottagare är tillåtna`, `Högst ${MAX_INVOICE_EMAIL_COPY_RECIPIENTS} kopiemottagare är tillåtna`,
) )
/** BAS class-3 revenue account: exactly 4 digits starting with 3 (försäljning/intäkt). */ /** Invoice-line posting account: an asset, liability/equity, or revenue account. */
const revenueAccount = z const invoicePostingAccount = z
.string() .string()
.regex(/^3\d{3}$/, 'Revenue account must be a 4-digit BAS class-3 account (3xxx)') .regex(INVOICE_POSTING_ACCOUNT_REGEX, 'Posting account must be a 4-digit BAS class 1-3 account')
/** Swedish VAT rate as an integer percent. */ /** Swedish VAT rate as an integer percent. */
const vatRatePercent = z.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)]) const vatRatePercent = z.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)])
@@ -350,10 +351,12 @@ export const CreateInvoiceItemSchema = z
unit_price: z.number(), unit_price: z.number(),
vat_rate: z.number().min(0).max(100).optional(), vat_rate: z.number().min(0).max(100).optional(),
// Article linkage. `article_id` ties the line to a catalog article (text // Article linkage. `article_id` ties the line to a catalog article (text
// rows omit it). `revenue_account` is the optional BAS class-3 override the // rows omit it). `revenue_account` is the legacy wire name for the optional
// engine books to; the API validates it against chart_of_accounts before use. // BAS class 1-3 posting-account override the engine books to; the API
// validates it against chart_of_accounts before use, and class 1-2
// accounts are only accepted on zero-VAT lines (build-invoice-write.ts).
article_id: uuid.nullable().optional(), article_id: uuid.nullable().optional(),
revenue_account: revenueAccount.nullable().optional(), revenue_account: invoicePostingAccount.nullable().optional(),
// ROT/RUT-avdrag fields. `deduction_amount` is intentionally omitted from // ROT/RUT-avdrag fields. `deduction_amount` is intentionally omitted from
// the client schema: the API computes it from rot-rut-rules.ts so a // the client schema: the API computes it from rot-rut-rules.ts so a
// tampered client can't expand the 1513 receivable beyond the line total. // tampered client can't expand the 1513 receivable beyond the line total.
@@ -606,9 +609,9 @@ export const CreateArticleSchema = z.object({
// seeded currencies reference table: an unknown code is a clean 400 here // seeded currencies reference table: an unknown code is a clean 400 here
// instead of a raw FK violation (23503) surfacing at insert time. // instead of a raw FK violation (23503) surfacing at insert time.
currency: CurrencySchema.optional(), currency: CurrencySchema.optional(),
// Optional BAS class-3 revenue-account override. Null/omitted = derive from // Optional BAS class 1-3 posting-account override. Null/omitted = derive from
// the invoice's VAT treatment (current behaviour). // the invoice's VAT treatment (current behaviour).
revenue_account: revenueAccount.nullable().optional(), revenue_account: invoicePostingAccount.nullable().optional(),
// Margin/display only; never posted. // Margin/display only; never posted.
cost_price: nonNegativeAmount.nullable().optional(), cost_price: nonNegativeAmount.nullable().optional(),
ean: z.string().max(32).nullable().optional(), ean: z.string().max(32).nullable().optional(),
+11 -10
View File
@@ -2,15 +2,15 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { getBASReference } from '@/lib/bookkeeping/bas-reference' import { getBASReference } from '@/lib/bookkeeping/bas-reference'
/** /**
* Classify a per-article revenue-account override against the company's chart: * Classify a per-article invoice posting-account override against the company's chart.
* *
* - 'ok' : active class-3 account in the chart; accept as-is. * - 'ok' : active class 1-3 account in the chart; accept as-is.
* - 'activatable' : a class-3 account that is merely missing/inactive: either * - 'activatable' : a class 1-3 account that is merely missing/inactive: either
* an inactive chart row or a known BAS class-3 number not yet * an inactive chart row or a known BAS class 1-3 number not yet
* in the chart. Routes translate this to ACCOUNTS_NOT_IN_CHART * in the chart. Routes translate this to ACCOUNTS_NOT_IN_CHART
* so the standard activate-and-retry dialog flow applies * so the standard activate-and-retry dialog flow applies
* (same UX as the journal entry form). * (same UX as the journal entry form).
* - 'invalid' : anything else: a non-revenue account or a number unknown to * - 'invalid' : anything else: an account outside classes 1-3 or a number unknown to
* both the chart and the BAS catalogue. Never bookable. * both the chart and the BAS catalogue. Never bookable.
* *
* Throws on an unexpected DB error so the route wrapper maps it to the canonical * Throws on an unexpected DB error so the route wrapper maps it to the canonical
@@ -33,18 +33,18 @@ export async function checkRevenueAccount(
if (error) throw error if (error) throw error
if (data) { if (data) {
if (data.account_class !== 3) return 'invalid' if (data.account_class < 1 || data.account_class > 3) return 'invalid'
return data.is_active ? 'ok' : 'activatable' return data.is_active ? 'ok' : 'activatable'
} }
const ref = getBASReference(account) const ref = getBASReference(account)
return ref?.account_class === 3 ? 'activatable' : 'invalid' return ref && ref.account_class >= 1 && ref.account_class <= 3 ? 'activatable' : 'invalid'
} }
/** /**
* True when `account` exists in the company's chart of accounts as an ACTIVE * True when `account` exists in the company's chart of accounts as an ACTIVE
* class-3 (revenue/intäkt) account. Used to guard the optional per-article * class 1-3 account. Used to guard the optional per-article posting-account
* revenue-account override so a typo or a non-revenue account can never be * override so a typo or an unsuitable account can never be
* pinned to an article (and later booked). Never trust the client. * pinned to an article (and later booked). Never trust the client.
* *
* Throws on an unexpected DB error so the route wrapper maps it to the canonical * Throws on an unexpected DB error so the route wrapper maps it to the canonical
@@ -59,7 +59,8 @@ export async function isValidRevenueAccount(
.from('chart_of_accounts') .from('chart_of_accounts')
.select('account_number') .select('account_number')
.eq('company_id', companyId) .eq('company_id', companyId)
.eq('account_class', 3) .gte('account_class', 1)
.lte('account_class', 3)
.eq('is_active', true) .eq('is_active', true)
.eq('account_number', account) .eq('account_number', account)
.maybeSingle() .maybeSingle()
@@ -356,6 +356,34 @@ describe('createInvoiceJournalEntry: per-article revenue account override', () =
expect(debit).toBe(1250) expect(debit).toBe(1250)
}) })
it('credits a selected liability account without treating the principal as revenue', async () => {
const invoice = makeInvoice({
subtotal: 10000,
vat_amount: 0,
total: 10000,
vat_treatment: 'exempt',
vat_rate: 0,
items: [
makeItem({
description: 'Återbetalningsbar deposition',
unit_price: 10000,
line_total: 10000,
vat_rate: 0,
vat_amount: 0,
revenue_account: '2897',
}),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((line) => line.account_number === '1510')?.debit_amount).toBe(10000)
expect(input.lines.find((line) => line.account_number === '2897')?.credit_amount).toBe(10000)
expect(input.lines.some((line) => line.account_number.startsWith('3'))).toBe(false)
expect(input.lines.some((line) => line.account_number.startsWith('26'))).toBe(false)
})
it('ignores a per-line override on reverse charge: revenue stays on 3308', async () => { it('ignores a per-line override on reverse charge: revenue stays on 3308', async () => {
const invoice = makeInvoice({ const invoice = makeInvoice({
subtotal: 5000, subtotal: 5000,
@@ -535,6 +563,32 @@ describe('createInvoiceCashEntry: per-line VAT', () => {
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0) const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(totalDebit).toBe(totalCredit) expect(totalDebit).toBe(totalCredit)
}) })
it('cash method credits a selected liability account at payment', async () => {
const invoice = makeInvoice({
subtotal: 10000,
vat_amount: 0,
total: 10000,
vat_treatment: 'exempt',
items: [
makeItem({
description: 'Återbetalningsbar deposition',
unit_price: 10000,
line_total: 10000,
vat_rate: 0,
vat_amount: 0,
revenue_account: '2897',
}),
],
})
await createInvoiceCashEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-01')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((line) => line.account_number === '1930')?.debit_amount).toBe(10000)
expect(input.lines.find((line) => line.account_number === '2897')?.credit_amount).toBe(10000)
expect(input.lines.some((line) => line.account_number.startsWith('3'))).toBe(false)
})
}) })
describe('createInvoiceJournalEntry: EUR foreign currency', () => { describe('createInvoiceJournalEntry: EUR foreign currency', () => {
+9 -4
View File
@@ -667,8 +667,13 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
}, },
INVOICE_CREATE_REVENUE_ACCOUNT_INVALID: { INVOICE_CREATE_REVENUE_ACCOUNT_INVALID: {
httpStatus: 400, httpStatus: 400,
message_sv: 'Ett angivet försäljningskonto finns inte eller är inte ett aktivt intäktskonto (klass 3).', message_sv: 'Ett angivet bokföringskonto finns inte eller är inte ett aktivt balans- eller intäktskonto (klass 1-3).',
message_en: 'A supplied revenue account does not exist or is not an active class-3 income account.', message_en: 'A supplied posting account does not exist or is not an active balance-sheet or revenue account (class 1-3).',
},
INVOICE_CREATE_POSTING_ACCOUNT_VAT_CONFLICT: {
httpStatus: 400,
message_sv: 'Ett balanskonto (klass 1-2) kan bara användas på rader utan moms. Använd ett intäktskonto (3xxx) för momspliktiga rader.',
message_en: 'A balance-sheet account (class 1-2) can only be used on zero-VAT lines. Use a revenue account (3xxx) for VAT-bearing lines.',
}, },
INVOICE_CREATE_ROT_RUT_VALIDATION: { INVOICE_CREATE_ROT_RUT_VALIDATION: {
httpStatus: 400, httpStatus: 400,
@@ -1839,8 +1844,8 @@ const ARTICLE: Record<string, StructuredErrorEntry> = {
}, },
ARTICLE_REVENUE_ACCOUNT_INVALID: { ARTICLE_REVENUE_ACCOUNT_INVALID: {
httpStatus: 400, httpStatus: 400,
message_sv: 'Försäljningskontot finns inte eller är inte ett aktivt intäktskonto (klass 3).', message_sv: 'Bokföringskontot finns inte eller är inte ett aktivt balans- eller intäktskonto (klass 1-3).',
message_en: 'The revenue account does not exist or is not an active class-3 income account.', message_en: 'The posting account does not exist or is not an active balance-sheet or revenue account (class 1-3).',
}, },
} }
+5 -3
View File
@@ -117,17 +117,19 @@ describe('parseArticlesFile', () => {
expect(result.rows[0].unit).toBe('st') expect(result.rows[0].unit).toBe('st')
}) })
it('keeps a valid 3xxx revenue account and drops a non-3xxx one', () => { it('keeps class 1-3 posting accounts and drops a class-4 account', () => {
const buffer = buildXlsx([ const buffer = buildXlsx([
['Benämning', 'Försäljningskonto'], ['Benämning', 'Försäljningskonto'],
['A', '3001'], ['A', '3001'],
['B', '1930'], ['B', '1930'],
['C', '4000'],
]) ])
const result = parseArticlesFile(buffer, 'konto.xlsx') const result = parseArticlesFile(buffer, 'konto.xlsx')
expect(result.rows[0].revenue_account).toBe('3001') expect(result.rows[0].revenue_account).toBe('3001')
expect(result.rows[1].revenue_account).toBeNull() expect(result.rows[1].revenue_account).toBe('1930')
expect(result.warnings.some((w) => w.toLowerCase().includes('försäljningskonto'))).toBe(true) expect(result.rows[2].revenue_account).toBeNull()
expect(result.warnings.some((w) => w.toLowerCase().includes('bokföringskonto'))).toBe(true)
}) })
it('treats a blank cost price as null (not 0)', () => { it('treats a blank cost price as null (not 0)', () => {
+3 -3
View File
@@ -157,13 +157,13 @@ export function parseArticlesFile(
const { rate: vatRate, note: vatNote } = normalizeVatRate(cell(row, columns.vat_rate_col)) const { rate: vatRate, note: vatNote } = normalizeVatRate(cell(row, columns.vat_rate_col))
if (vatNote) vatNoteCount++ if (vatNote) vatNoteCount++
// Keep only well-formed BAS class-3 overrides; the execute route validates // Keep only well-formed BAS class 1-3 overrides; the execute route validates
// them further against the chart of accounts. // them further against the chart of accounts.
const revenueRaw = cell(row, columns.revenue_account_col) const revenueRaw = cell(row, columns.revenue_account_col)
let revenueAccount: string | null = null let revenueAccount: string | null = null
if (revenueRaw) { if (revenueRaw) {
const digits = revenueRaw.replace(/\s/g, '') const digits = revenueRaw.replace(/\s/g, '')
if (/^3\d{3}$/.test(digits)) revenueAccount = digits if (/^[123]\d{3}$/.test(digits)) revenueAccount = digits
else droppedAccountCount++ else droppedAccountCount++
} }
@@ -203,7 +203,7 @@ export function parseArticlesFile(
warnings.push(`${vatNoteCount} rad${vatNoteCount === 1 ? '' : 'er'} hade en momssats som avrundades till närmaste giltiga (0/6/12/25 %).`) warnings.push(`${vatNoteCount} rad${vatNoteCount === 1 ? '' : 'er'} hade en momssats som avrundades till närmaste giltiga (0/6/12/25 %).`)
} }
if (droppedAccountCount > 0) { if (droppedAccountCount > 0) {
warnings.push(`${droppedAccountCount} rad${droppedAccountCount === 1 ? '' : 'er'} hade ett ogiltigt försäljningskonto (måste vara 3xxx) som ignorerades.`) warnings.push(`${droppedAccountCount} rad${droppedAccountCount === 1 ? '' : 'er'} hade ett ogiltigt bokföringskonto (måste vara klass 1-3) som ignorerades.`)
} }
if (rows.length === 0) { if (rows.length === 0) {
warnings.push('Inga giltiga artiklar hittades. Kontrollera att namn-/benämningskolumnen är korrekt mappad.') warnings.push('Inga giltiga artiklar hittades. Kontrollera att namn-/benämningskolumnen är korrekt mappad.')
+1 -1
View File
@@ -36,7 +36,7 @@ export interface ParsedArticleRow {
* edit step; cleared once the operator confirms the rate. Not persisted. * edit step; cleared once the operator confirms the rate. Not persisted.
*/ */
vat_rate_adjusted: boolean vat_rate_adjusted: boolean
/** Optional BAS class-3 revenue-account override (validated server-side). */ /** Optional BAS class 1-3 posting-account override (validated server-side). */
revenue_account: string | null revenue_account: string | null
cost_price: number | null cost_price: number | null
ean: string | null ean: string | null
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import type { InvoiceItem } from '@/types'
import { buildPdfVatBreakdown, formatPdfCurrency } from '@/lib/invoices/pdf-template'
function makeItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
return {
id: 'item-1',
invoice_id: 'invoice-1',
sort_order: 0,
line_type: 'product',
description: 'Invoice row',
quantity: 1,
unit: 'st',
unit_price: 100,
line_total: 100,
vat_rate: 25,
vat_amount: 25,
...overrides,
}
}
describe('formatPdfCurrency', () => {
it('uses a PDF-safe minus for Swedish negative amounts', () => {
const formatted = formatPdfCurrency(-12.34, 'SEK', 'sv')
expect(formatted).toBe('-12,34 SEK')
expect(formatted).not.toContain('\u2212')
})
it('preserves English negative amount formatting', () => {
expect(formatPdfCurrency(-12.34, 'SEK', 'en')).toBe('-12.34 SEK')
})
it('does not change positive Swedish amount formatting', () => {
expect(formatPdfCurrency(18_992.2, 'SEK', 'sv')).toBe('18\u00a0992,20 SEK')
})
})
describe('buildPdfVatBreakdown', () => {
it('subtracts a negative adjustment from its VAT group', () => {
const breakdown = buildPdfVatBreakdown([
makeItem({ id: 'sale', line_total: 100, vat_amount: 25 }),
makeItem({ id: 'deduction', line_total: -12.34, vat_amount: -3.09 }),
])
expect(breakdown.get(25)).toEqual({ base: 87.66, vat: 21.91 })
})
it('keeps whole credit-note VAT groups negative', () => {
const breakdown = buildPdfVatBreakdown([
makeItem({ line_total: -100, vat_amount: -25 }),
])
expect(breakdown.get(25)).toEqual({ base: -100, vat: -25 })
})
it('keeps mixed VAT rates signed and excludes text rows', () => {
const breakdown = buildPdfVatBreakdown([
makeItem({ id: 'rate-25', line_total: 200, vat_rate: 25, vat_amount: 50 }),
makeItem({ id: 'rate-12', line_total: 100, vat_rate: 12, vat_amount: 12 }),
makeItem({ id: 'rate-12-deduction', line_total: -20, vat_rate: 12, vat_amount: -2.4 }),
makeItem({ id: 'text', line_type: 'text', line_total: 999, vat_rate: 25, vat_amount: 999 }),
])
expect(breakdown.get(25)).toEqual({ base: 200, vat: 50 })
expect(breakdown.get(12)).toEqual({ base: 80, vat: 9.6 })
})
})
+24 -5
View File
@@ -1,6 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js' import type { SupabaseClient } from '@supabase/supabase-js'
import type { Currency, Customer, InvoiceDocumentType } from '@/types' import type { Currency, Customer, InvoiceDocumentType } from '@/types'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules' import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { isBalanceSheetAccount } from '@/lib/invoices/posting-account'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions' import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
import { import {
@@ -226,16 +227,33 @@ export async function buildInvoiceWriteData(params: {
}, },
} }
} }
// A class 1-2 (balance-sheet) posting override is only valid on
// zero-VAT lines (deposits, advances, outlays). On a VAT-bearing line
// it would divert the tax base away from a 3xxx account and understate
// ruta 05 of the momsdeklaration (ML 17 kap 24§).
if (
item.revenue_account &&
isBalanceSheetAccount(item.revenue_account) &&
itemRate > 0
) {
return {
ok: false,
code: 'INVOICE_CREATE_POSTING_ACCOUNT_VAT_CONFLICT',
details: { account: item.revenue_account, vatRate: itemRate },
}
}
const lineTotal = item.quantity * item.unit_price const lineTotal = item.quantity * item.unit_price
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100 vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
} }
} }
const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount
// Validate any per-line revenue-account override against the company's chart // Validate any per-line posting-account override against the company's chart
// of accounts. Zod already constrains the shape to a 3xxx string; here we // of accounts. The legacy field name is revenue_account, but balance-sheet
// confirm each is a real, active class-3 account so a typo or a non-revenue // accounts are valid for deposits, customer advances, and genuine outlays.
// account can never be booked. Never trust the client. // Zod already constrains the shape to classes 1-3; here we
// confirm each is a real, active account so a typo or unsuitable account
// can never be booked. Never trust the client.
const overrideAccounts = Array.from( const overrideAccounts = Array.from(
new Set( new Set(
items items
@@ -248,7 +266,8 @@ export async function buildInvoiceWriteData(params: {
.from('chart_of_accounts') .from('chart_of_accounts')
.select('account_number') .select('account_number')
.eq('company_id', companyId) .eq('company_id', companyId)
.eq('account_class', 3) .gte('account_class', 1)
.lte('account_class', 3)
.eq('is_active', true) .eq('is_active', true)
.in('account_number', overrideAccounts) .in('account_number', overrideAccounts)
+36 -25
View File
@@ -597,15 +597,30 @@ function createStyles(branding?: InvoiceBranding) {
// Format currency with explicit ISO code so non-Swedish recipients see "1 234,56 SEK" // Format currency with explicit ISO code so non-Swedish recipients see "1 234,56 SEK"
// instead of the Swedish symbol "kr". Decimal style + appended code works for any // instead of the Swedish symbol "kr". Decimal style + appended code works for any
// currency (SEK/EUR/USD) and avoids Intl's locale-specific symbol quirks. // currency (SEK/EUR/USD) and avoids Intl's locale-specific symbol quirks.
function formatCurrency(amount: number, currency: string = 'SEK', language: PdfLang = 'sv'): string { export function formatPdfCurrency(amount: number, currency: string = 'SEK', language: PdfLang = 'sv'): string {
const formatted = new Intl.NumberFormat(language === 'en' ? 'en-US' : 'sv-SE', { const formatted = new Intl.NumberFormat(language === 'en' ? 'en-US' : 'sv-SE', {
style: 'decimal', style: 'decimal',
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
}).format(amount) // sv-SE emits U+2212, which standard PDF fonts can silently drop. The
// ASCII minus is supported by every allowed invoice font.
}).format(amount).replaceAll('\u2212', '-')
return `${formatted} ${currency}` return `${formatted} ${currency}`
} }
export function buildPdfVatBreakdown(items: InvoiceItem[]): Map<number, { base: number; vat: number }> {
const vatByRate = new Map<number, { base: number; vat: number }>()
for (const item of items) {
if (isTextLikeLine(item)) continue
const rate = item.vat_rate ?? 0
const group = vatByRate.get(rate) || { base: 0, vat: 0 }
group.base += item.line_total
group.vat += item.vat_amount || 0
vatByRate.set(rate, group)
}
return vatByRate
}
// Format date as ISO yyyy-MM-dd in both locales: universally unambiguous and // Format date as ISO yyyy-MM-dd in both locales: universally unambiguous and
// matches the project's formatDate() convention (lib/utils.ts). // matches the project's formatDate() convention (lib/utils.ts).
// Input is already a YYYY-MM-DD string from the DB, so slice avoids the // Input is already a YYYY-MM-DD string from the DB, so slice avoids the
@@ -678,16 +693,9 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
const showVatColumn = hasPerLineVat && uniqueRates.size > 1 const showVatColumn = hasPerLineVat && uniqueRates.size > 1
// Calculate per-rate VAT breakdown for totals // Calculate per-rate VAT breakdown for totals
const vatByRate = new Map<number, { base: number; vat: number }>() const vatByRate = hasPerLineVat
if (hasPerLineVat) { ? buildPdfVatBreakdown(billableItems)
for (const item of billableItems) { : new Map<number, { base: number; vat: number }>()
const rate = item.vat_rate ?? 0
const group = vatByRate.get(rate) || { base: 0, vat: 0 }
group.base += Math.abs(item.line_total)
group.vat += Math.abs(item.vat_amount || 0)
vatByRate.set(rate, group)
}
}
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice' const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note' const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma' const isProforma = docType === 'proforma'
@@ -891,13 +899,13 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<Text style={styles.colQty}>{item.quantity}</Text> <Text style={styles.colQty}>{item.quantity}</Text>
<Text style={styles.colUnit}>{item.unit}</Text> <Text style={styles.colUnit}>{item.unit}</Text>
{!isDeliveryNote && ( {!isDeliveryNote && (
<Text style={styles.colPrice}>{formatCurrency(item.unit_price, invoice.currency, lang)}</Text> <Text style={styles.colPrice}>{formatPdfCurrency(item.unit_price, invoice.currency, lang)}</Text>
)} )}
{!isDeliveryNote && showVatColumn && ( {!isDeliveryNote && showVatColumn && (
<Text style={styles.colVat}>{item.vat_rate ?? 0}%</Text> <Text style={styles.colVat}>{item.vat_rate ?? 0}%</Text>
)} )}
{!isDeliveryNote && ( {!isDeliveryNote && (
<Text style={styles.colTotal}>{formatCurrency(item.line_total, invoice.currency, lang)}</Text> <Text style={styles.colTotal}>{formatPdfCurrency(item.line_total, invoice.currency, lang)}</Text>
)} )}
</View> </View>
) )
@@ -910,7 +918,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<View style={styles.totalsSection}> <View style={styles.totalsSection}>
<View style={styles.totalRow}> <View style={styles.totalRow}>
<Text style={styles.totalLabel}>{L.subtotal}</Text> <Text style={styles.totalLabel}>{L.subtotal}</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.subtotal, invoice.currency, lang)}</Text> <Text style={styles.totalValue}>{formatPdfCurrency(invoice.subtotal, invoice.currency, lang)}</Text>
</View> </View>
{vatByRate.size > 1 ? ( {vatByRate.size > 1 ? (
Array.from(vatByRate.entries()) Array.from(vatByRate.entries())
@@ -919,12 +927,12 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<View key={rate}> <View key={rate}>
<View style={styles.totalRow}> <View style={styles.totalRow}>
<Text style={styles.totalLabel}>{L.net(rate)}</Text> <Text style={styles.totalLabel}>{L.net(rate)}</Text>
<Text style={styles.totalValue}>{formatCurrency(group.base, invoice.currency, lang)}</Text> <Text style={styles.totalValue}>{formatPdfCurrency(group.base, invoice.currency, lang)}</Text>
</View> </View>
{group.vat > 0 && ( {group.vat !== 0 && (
<View style={styles.totalRow}> <View style={styles.totalRow}>
<Text style={styles.totalLabel}>{L.vatRow(rate)}</Text> <Text style={styles.totalLabel}>{L.vatRow(rate)}</Text>
<Text style={styles.totalValue}>{formatCurrency(group.vat, invoice.currency, lang)}</Text> <Text style={styles.totalValue}>{formatPdfCurrency(group.vat, invoice.currency, lang)}</Text>
</View> </View>
)} )}
</View> </View>
@@ -938,7 +946,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
!(company.vat_registered === false && invoice.vat_amount === 0) && ( !(company.vat_registered === false && invoice.vat_amount === 0) && (
<View style={styles.totalRow}> <View style={styles.totalRow}>
<Text style={styles.totalLabel}>{L.vatRow(invoice.vat_rate ?? (vatByRate.size === 1 ? (vatByRate.keys().next().value ?? 0) : 0))}</Text> <Text style={styles.totalLabel}>{L.vatRow(invoice.vat_rate ?? (vatByRate.size === 1 ? (vatByRate.keys().next().value ?? 0) : 0))}</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency, lang)}</Text> <Text style={styles.totalValue}>{formatPdfCurrency(invoice.vat_amount, invoice.currency, lang)}</Text>
</View> </View>
) )
)} )}
@@ -952,20 +960,23 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{rounding.applies && ( {rounding.applies && (
<View style={styles.totalRow}> <View style={styles.totalRow}>
<Text style={[styles.totalLabel, { fontSize: 8 }]}>{L.rounding}</Text> <Text style={[styles.totalLabel, { fontSize: 8 }]}>{L.rounding}</Text>
<Text style={[styles.totalValue, { fontSize: 8 }]}>{formatCurrency(rounding.roundingDelta, 'SEK', lang)}</Text> <Text style={[styles.totalValue, { fontSize: 8 }]}>{formatPdfCurrency(rounding.roundingDelta, 'SEK', lang)}</Text>
</View> </View>
)} )}
{showDeduction && ( {showDeduction && (
<View style={styles.totalRow}> <View style={styles.totalRow}>
<Text style={styles.totalLabel}>{L.deductionRow}</Text> <Text style={styles.totalLabel}>{L.deductionRow}</Text>
<Text style={styles.totalValue}> <Text style={styles.totalValue}>
−{formatCurrency(invoice.deduction_total ?? 0, invoice.currency, lang)} {/* deduction_total is stored as a positive magnitude;
-Math.abs() keeps the row a reduction even if the
stored sign convention ever changes. */}
{formatPdfCurrency(-Math.abs(invoice.deduction_total ?? 0), invoice.currency, lang)}
</Text> </Text>
</View> </View>
)} )}
<View style={styles.grandTotal}> <View style={styles.grandTotal}>
<Text style={styles.grandTotalLabel}>{isCreditNote ? L.toCredit : L.toPay}</Text> <Text style={styles.grandTotalLabel}>{isCreditNote ? L.toCredit : L.toPay}</Text>
<Text style={styles.grandTotalValue}>{formatCurrency(grandTotal, invoice.currency, lang)}</Text> <Text style={styles.grandTotalValue}>{formatPdfCurrency(grandTotal, invoice.currency, lang)}</Text>
</View> </View>
</> </>
) )
@@ -975,12 +986,12 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{invoice.vat_amount_sek != null && invoice.vat_amount_sek !== 0 && ( {invoice.vat_amount_sek != null && invoice.vat_amount_sek !== 0 && (
<View style={styles.totalRow}> <View style={styles.totalRow}>
<Text style={[styles.totalLabel, { fontSize: 9 }]}>{L.vatInSek(invoice.exchange_rate ?? '')}</Text> <Text style={[styles.totalLabel, { fontSize: 9 }]}>{L.vatInSek(invoice.exchange_rate ?? '')}</Text>
<Text style={[styles.totalValue, { fontSize: 9 }]}>{formatCurrency(invoice.vat_amount_sek, 'SEK', lang)}</Text> <Text style={[styles.totalValue, { fontSize: 9 }]}>{formatPdfCurrency(invoice.vat_amount_sek, 'SEK', lang)}</Text>
</View> </View>
)} )}
<View style={styles.totalRow}> <View style={styles.totalRow}>
<Text style={[styles.totalLabel, { fontSize: 9 }]}>{L.totalInSek}</Text> <Text style={[styles.totalLabel, { fontSize: 9 }]}>{L.totalInSek}</Text>
<Text style={[styles.totalValue, { fontSize: 9 }]}>{formatCurrency(invoice.total_sek, 'SEK', lang)}</Text> <Text style={[styles.totalValue, { fontSize: 9 }]}>{formatPdfCurrency(invoice.total_sek, 'SEK', lang)}</Text>
</View> </View>
</View> </View>
)} )}
@@ -1036,7 +1047,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
const work = i.work_type ? `, ${i.work_type}` : '' const work = i.work_type ? `, ${i.work_type}` : ''
return ( return (
<Text key={idx} style={styles.deductionLineItem}> <Text key={idx} style={styles.deductionLineItem}>
{`${kind}${work}: ${i.description}, ${formatCurrency(i.deduction_amount ?? 0, invoice.currency, lang)}`} {`${kind}${work}: ${i.description}, ${formatPdfCurrency(i.deduction_amount ?? 0, invoice.currency, lang)}`}
</Text> </Text>
) )
})} })}
+17
View File
@@ -0,0 +1,17 @@
/**
* Canonical shape for a per-line invoice posting-account override: a 4-digit
* BAS class 1-3 account. Single source of truth shared by the server-side Zod
* schemas (lib/api/schemas.ts) and the client-side form schemas
* (ArticleForm, InvoiceEditor) so the two layers cannot drift apart.
*
* Classes 4-8 stay excluded: an invoice line never books to cost, payroll,
* or financial accounts. Class 1-2 (balance-sheet) overrides exist for
* deposits, customer advances, and genuine outlays; they are only bookable
* on zero-VAT lines (enforced server-side in build-invoice-write.ts).
*/
export const INVOICE_POSTING_ACCOUNT_REGEX = /^[123]\d{3}$/
/** True when the account is a balance-sheet account (BAS class 1-2). */
export function isBalanceSheetAccount(account: string): boolean {
return /^[12]/.test(account)
}
+4 -5
View File
@@ -475,7 +475,7 @@ async function commitCreateArticle(
if (validated.revenue_account) { if (validated.revenue_account) {
const ok = await isValidRevenueAccount(supabase, companyId, validated.revenue_account) const ok = await isValidRevenueAccount(supabase, companyId, validated.revenue_account)
if (!ok) return { error: 'Revenue account is not an active class-3 account', status: 400 } if (!ok) return { error: 'Posting account is not an active class 1-3 account', status: 400 }
} }
const { data, error } = await supabase const { data, error } = await supabase
@@ -533,7 +533,7 @@ async function commitUpdateArticle(
if (validated.revenue_account) { if (validated.revenue_account) {
const ok = await isValidRevenueAccount(supabase, companyId, validated.revenue_account) const ok = await isValidRevenueAccount(supabase, companyId, validated.revenue_account)
if (!ok) return { error: 'Revenue account is not an active class-3 account', status: 400 } if (!ok) return { error: 'Posting account is not an active class 1-3 account', status: 400 }
} }
const { article_id, ...rest } = validated const { article_id, ...rest } = validated
@@ -1076,14 +1076,14 @@ async function commitCreateInvoice(
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100 vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
} }
// Validate any per-line revenue-account override (defense in depth: the field // Validate any per-line posting-account override (defense in depth: the legacy field
// is frozen onto invoice_items and flows to generatePerRateLines()). // is frozen onto invoice_items and flows to generatePerRateLines()).
const overrideAccounts = Array.from( const overrideAccounts = Array.from(
new Set(billableItems.map((i) => i.revenue_account).filter((a): a is string => !!a)), new Set(billableItems.map((i) => i.revenue_account).filter((a): a is string => !!a)),
) )
for (const acct of overrideAccounts) { for (const acct of overrideAccounts) {
if (!(await isValidRevenueAccount(supabase, companyId, acct))) { if (!(await isValidRevenueAccount(supabase, companyId, acct))) {
return { error: `Försäljningskonto ${acct} är inte ett aktivt intäktskonto (klass 3)`, status: 400 } return { error: `Bokföringskonto ${acct} är inte ett aktivt balans- eller intäktskonto (klass 1-3)`, status: 400 }
} }
} }
@@ -4977,4 +4977,3 @@ async function commitPendingOperationInner(
data: result.data, data: result.data,
} }
} }
+5 -4
View File
@@ -1,13 +1,14 @@
import { z } from 'zod' import { z } from 'zod'
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
// Commit-boundary re-validation for staged article operations. A staged // Commit-boundary re-validation for staged article operations. A staged
// pending_operations row is re-parsed here before it touches the articles table // pending_operations row is re-parsed here before it touches the articles table
// so a tampered row cannot inject unexpected fields or malformed data // so a tampered row cannot inject unexpected fields or malformed data
// (defense in depth, ASVS V4.5): mirrors lib/pending-operations/schemas/create-supplier.ts. // (defense in depth, ASVS V4.5): mirrors lib/pending-operations/schemas/create-supplier.ts.
const revenueAccount = z const invoicePostingAccount = z
.string() .string()
.regex(/^3\d{3}$/, 'Revenue account must be a 4-digit BAS class-3 account (3xxx)') .regex(INVOICE_POSTING_ACCOUNT_REGEX, 'Posting account must be a 4-digit BAS class 1-3 account')
const vatRatePercent = z.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)]) const vatRatePercent = z.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)])
@@ -26,7 +27,7 @@ export const CreateArticleParamsSchema = z.object({
unit: optString(32), unit: optString(32),
price_excl_vat: z.number().nonnegative(), price_excl_vat: z.number().nonnegative(),
vat_rate: vatRatePercent.default(25), vat_rate: vatRatePercent.default(25),
revenue_account: revenueAccount.nullable().optional(), revenue_account: invoicePostingAccount.nullable().optional(),
cost_price: z.number().nonnegative().nullable().optional(), cost_price: z.number().nonnegative().nullable().optional(),
ean: optString(32), ean: optString(32),
housework_type: optString(64), housework_type: optString(64),
@@ -42,7 +43,7 @@ export const UpdateArticleParamsSchema = z.object({
unit: optString(32), unit: optString(32),
price_excl_vat: z.number().nonnegative().optional(), price_excl_vat: z.number().nonnegative().optional(),
vat_rate: vatRatePercent.optional(), vat_rate: vatRatePercent.optional(),
revenue_account: revenueAccount.nullable().optional(), revenue_account: invoicePostingAccount.nullable().optional(),
cost_price: z.number().nonnegative().nullable().optional(), cost_price: z.number().nonnegative().nullable().optional(),
ean: optString(32), ean: optString(32),
housework_type: optString(64), housework_type: optString(64),
@@ -245,6 +245,23 @@ describe('calculateVatDeclaration', () => {
expect(result.transactionCount).toBe(0) expect(result.transactionCount).toBe(0)
}) })
it('does not report a refundable deposit credited to a liability account as turnover', async () => {
seedLedger(
[
{ account_number: '1510', debit_amount: 10000, credit_amount: 0 },
{ account_number: '2897', debit_amount: 0, credit_amount: 10000 },
],
['invoice_created'],
)
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(0)
expect(result.rutor.ruta42).toBe(0)
expect(result.rutor.ruta49).toBe(0)
expect(result.invoiceCount).toBe(1)
})
it('sums output VAT to ruta10/11/12 and revenue to ruta05', async () => { it('sums output VAT to ruta10/11/12 and revenue to ruta05', async () => {
seedLedger( seedLedger(
[ [
+9 -7
View File
@@ -2710,14 +2710,15 @@
"cancel": "Cancel" "cancel": "Cancel"
}, },
"invoice_editor": { "invoice_editor": {
"row_menu_set_account": "Set sales account", "row_menu_set_account": "Set posting account",
"row_menu_remove_account": "Remove sales account", "row_menu_remove_account": "Remove posting account",
"row_menu_set_dimensions": "Set cost centre/project", "row_menu_set_dimensions": "Set cost centre/project",
"row_menu_remove_dimensions": "Remove cost centre/project", "row_menu_remove_dimensions": "Remove cost centre/project",
"dimensions_default_hint": "Cost centre/project applies to all rows without their own tagging.", "dimensions_default_hint": "Cost centre/project applies to all rows without their own tagging.",
"row_dimensions_inherit_hint": "Empty fields inherit the invoice default ({dims}).", "row_dimensions_inherit_hint": "Empty fields inherit the invoice default ({dims}).",
"revenue_account_label": "Sales account", "revenue_account_label": "Posting account",
"revenue_account_hint": "Leave blank to derive the account from the VAT rate. Ignored for reverse charge and export.", "revenue_account_hint": "Leave blank to derive the sales account from the VAT rate. You may select an active balance-sheet or revenue account in class 1-3. Ignored for reverse charge and export.",
"posting_account_invalid": "Enter a four-digit class 1-3 account.",
"ore_rounding_label": "Öre rounding", "ore_rounding_label": "Öre rounding",
"ore_rounding_help": "Round the invoice total to whole kronor", "ore_rounding_help": "Round the invoice total to whole kronor",
"back": "Back", "back": "Back",
@@ -4645,9 +4646,10 @@
"price_required": "Enter a price", "price_required": "Enter a price",
"vat_rate_label": "VAT", "vat_rate_label": "VAT",
"advanced_section": "Advanced", "advanced_section": "Advanced",
"revenue_account_label": "Revenue account", "revenue_account_label": "Posting account",
"revenue_account_placeholder": "e.g. 3041", "revenue_account_placeholder": "e.g. 2897",
"revenue_account_hint": "Leave empty to derive the account automatically from the VAT rate.", "revenue_account_hint": "Leave empty to derive the sales account automatically from VAT. You may select an active class 1-3 account.",
"posting_account_invalid": "Enter a four-digit class 1-3 account.",
"cost_price_label": "Cost price", "cost_price_label": "Cost price",
"cost_price_hint": "Margin display only, never posted.", "cost_price_hint": "Margin display only, never posted.",
"currency_label": "Currency", "currency_label": "Currency",
+9 -7
View File
@@ -2710,14 +2710,15 @@
"cancel": "Avbryt" "cancel": "Avbryt"
}, },
"invoice_editor": { "invoice_editor": {
"row_menu_set_account": "Ange försäljningskonto", "row_menu_set_account": "Ange bokföringskonto",
"row_menu_remove_account": "Ta bort försäljningskonto", "row_menu_remove_account": "Ta bort bokföringskonto",
"row_menu_set_dimensions": "Ange kostnadsställe/projekt", "row_menu_set_dimensions": "Ange kostnadsställe/projekt",
"row_menu_remove_dimensions": "Ta bort kostnadsställe/projekt", "row_menu_remove_dimensions": "Ta bort kostnadsställe/projekt",
"dimensions_default_hint": "Kostnadsställe/projekt gäller alla rader utan egen märkning.", "dimensions_default_hint": "Kostnadsställe/projekt gäller alla rader utan egen märkning.",
"row_dimensions_inherit_hint": "Tomma fält ärver fakturans standard ({dims}).", "row_dimensions_inherit_hint": "Tomma fält ärver fakturans standard ({dims}).",
"revenue_account_label": "Försäljningskonto", "revenue_account_label": "Bokföringskonto",
"revenue_account_hint": "Lämna tomt för att härleda kontot från momssatsen. Ignoreras för omvänd skattskyldighet och export.", "revenue_account_hint": "Lämna tomt för att härleda försäljningskontot från momssatsen. Du kan välja ett aktivt balans- eller intäktskonto i klass 1-3. Ignoreras för omvänd skattskyldighet och export.",
"posting_account_invalid": "Ange ett fyrsiffrigt konto i klass 1-3.",
"ore_rounding_label": "Öresavrundning", "ore_rounding_label": "Öresavrundning",
"ore_rounding_help": "Avrunda fakturatotal till hel krona", "ore_rounding_help": "Avrunda fakturatotal till hel krona",
"back": "Tillbaka", "back": "Tillbaka",
@@ -4645,9 +4646,10 @@
"price_required": "Ange ett pris", "price_required": "Ange ett pris",
"vat_rate_label": "Moms", "vat_rate_label": "Moms",
"advanced_section": "Avancerat", "advanced_section": "Avancerat",
"revenue_account_label": "Försäljningskonto", "revenue_account_label": "Bokföringskonto",
"revenue_account_placeholder": "t.ex. 3041", "revenue_account_placeholder": "t.ex. 2897",
"revenue_account_hint": "Lämna tomt för att härleda kontot automatiskt utifrån momsen.", "revenue_account_hint": "Lämna tomt för att härleda försäljningskontot automatiskt utifrån momsen. Du kan välja ett aktivt konto i klass 1-3.",
"posting_account_invalid": "Ange ett fyrsiffrigt konto i klass 1-3.",
"cost_price_label": "Inköpspris", "cost_price_label": "Inköpspris",
"cost_price_hint": "Endast för marginalberäkning, bokförs aldrig.", "cost_price_hint": "Endast för marginalberäkning, bokförs aldrig.",
"currency_label": "Valuta", "currency_label": "Valuta",
+3 -4
View File
@@ -724,7 +724,7 @@ export interface Article {
/** Default price currency (ISO 4217 code from the currencies table); /** Default price currency (ISO 4217 code from the currencies table);
* pre-fills the invoice currency when added. */ * pre-fills the invoice currency when added. */
currency: string currency: string
/** Optional BAS class-3 revenue account override. null = derive from VAT treatment. */ /** Optional BAS class 1-3 posting account override. null = derive from VAT treatment. */
revenue_account: string | null revenue_account: string | null
/** Margin/display only: never posted to the ledger. */ /** Margin/display only: never posted to the ledger. */
cost_price: number | null cost_price: number | null
@@ -1087,7 +1087,7 @@ export interface InvoiceItem {
// Article linkage. `article_id` is a soft back-reference to the source // Article linkage. `article_id` is a soft back-reference to the source
// article (for the "Affärshändelser" history view); `revenue_account` is the // article (for the "Affärshändelser" history view); `revenue_account` is the
// BAS class-3 account frozen-copied from the article at line-create time. // BAS class 1-3 posting account frozen-copied from the article at line-create time.
// null `revenue_account` preserves the legacy "derive from VAT treatment" // null `revenue_account` preserves the legacy "derive from VAT treatment"
// booking in generatePerRateLines(). // booking in generatePerRateLines().
article_id?: string | null article_id?: string | null
@@ -1363,7 +1363,7 @@ export interface CreateInvoiceItemInput {
vat_rate?: number vat_rate?: number
/** Source article (optional). Free-text lines omit it. */ /** Source article (optional). Free-text lines omit it. */
article_id?: string | null article_id?: string | null
/** BAS class-3 revenue account override copied from the article. null = derive from VAT treatment. */ /** BAS class 1-3 posting account override copied from the article. null = derive from VAT treatment. */
revenue_account?: string | null revenue_account?: string | null
/** ROT/RUT toggle. null/undefined = no deduction. */ /** ROT/RUT toggle. null/undefined = no deduction. */
deduction_type?: 'rot' | 'rut' | null deduction_type?: 'rot' | 'rut' | null
@@ -3804,4 +3804,3 @@ export interface AGIDeclaration {
created_at: string created_at: string
updated_at: string updated_at: string
} }