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
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] 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] 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] 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
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.
if (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')
})
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 () => {
// 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 } })
+2 -2
View File
@@ -55,8 +55,8 @@ export const POST = withRouteContext(
if (!result.success) return result.response
const body = result.data
// Guard the optional revenue-account override against the chart of accounts.
// A class-3 account that merely isn't activated yet gets the standard
// Guard the optional posting-account override against the chart of accounts.
// 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.
if (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')
})
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 () => {
withInvoiceWriteScope()
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.',
'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.',
'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: {
request: {
+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">
+11 -8
View File
@@ -6,6 +6,7 @@ import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
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'
// ============================================================
@@ -47,10 +48,10 @@ const invoiceEmailAddressList = z
`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). */
const revenueAccount = z
/** Invoice-line posting account: an asset, liability/equity, or revenue account. */
const invoicePostingAccount = z
.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. */
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(),
vat_rate: z.number().min(0).max(100).optional(),
// 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
// engine books to; the API validates it against chart_of_accounts before use.
// rows omit it). `revenue_account` is the legacy wire name for the optional
// 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(),
revenue_account: revenueAccount.nullable().optional(),
revenue_account: invoicePostingAccount.nullable().optional(),
// ROT/RUT-avdrag fields. `deduction_amount` is intentionally omitted from
// 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.
@@ -606,9 +609,9 @@ export const CreateArticleSchema = z.object({
// seeded currencies reference table: an unknown code is a clean 400 here
// instead of a raw FK violation (23503) surfacing at insert time.
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).
revenue_account: revenueAccount.nullable().optional(),
revenue_account: invoicePostingAccount.nullable().optional(),
// Margin/display only; never posted.
cost_price: nonNegativeAmount.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'
/**
* 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.
* - 'activatable' : a class-3 account that is merely missing/inactive: either
* an inactive chart row or a known BAS class-3 number not yet
* - 'ok' : active class 1-3 account in the chart; accept as-is.
* - 'activatable' : a class 1-3 account that is merely missing/inactive: either
* 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
* so the standard activate-and-retry dialog flow applies
* (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.
*
* 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 (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'
}
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
* class-3 (revenue/intäkt) account. Used to guard the optional per-article
* revenue-account override so a typo or a non-revenue account can never be
* class 1-3 account. Used to guard the optional per-article posting-account
* override so a typo or an unsuitable account can never be
* 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
@@ -59,7 +59,8 @@ export async function isValidRevenueAccount(
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.eq('account_class', 3)
.gte('account_class', 1)
.lte('account_class', 3)
.eq('is_active', true)
.eq('account_number', account)
.maybeSingle()
@@ -356,6 +356,34 @@ describe('createInvoiceJournalEntry: per-article revenue account override', () =
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 () => {
const invoice = makeInvoice({
subtotal: 5000,
@@ -535,6 +563,32 @@ describe('createInvoiceCashEntry: per-line VAT', () => {
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
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', () => {
+9 -4
View File
@@ -667,8 +667,13 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
},
INVOICE_CREATE_REVENUE_ACCOUNT_INVALID: {
httpStatus: 400,
message_sv: 'Ett angivet försäljningskonto finns inte eller är inte ett aktivt intäktskonto (klass 3).',
message_en: 'A supplied revenue account does not exist or is not an active class-3 income account.',
message_sv: 'Ett angivet bokföringskonto finns inte eller är inte ett aktivt balans- eller intäktskonto (klass 1-3).',
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: {
httpStatus: 400,
@@ -1839,8 +1844,8 @@ const ARTICLE: Record<string, StructuredErrorEntry> = {
},
ARTICLE_REVENUE_ACCOUNT_INVALID: {
httpStatus: 400,
message_sv: 'Försäljningskontot finns inte eller är inte ett aktivt intäktskonto (klass 3).',
message_en: 'The revenue account does not exist or is not an active class-3 income account.',
message_sv: 'Bokföringskontot finns inte eller är inte ett aktivt balans- eller intäktskonto (klass 1-3).',
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')
})
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([
['Benämning', 'Försäljningskonto'],
['A', '3001'],
['B', '1930'],
['C', '4000'],
])
const result = parseArticlesFile(buffer, 'konto.xlsx')
expect(result.rows[0].revenue_account).toBe('3001')
expect(result.rows[1].revenue_account).toBeNull()
expect(result.warnings.some((w) => w.toLowerCase().includes('försäljningskonto'))).toBe(true)
expect(result.rows[1].revenue_account).toBe('1930')
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)', () => {
+3 -3
View File
@@ -157,13 +157,13 @@ export function parseArticlesFile(
const { rate: vatRate, note: vatNote } = normalizeVatRate(cell(row, columns.vat_rate_col))
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.
const revenueRaw = cell(row, columns.revenue_account_col)
let revenueAccount: string | null = null
if (revenueRaw) {
const digits = revenueRaw.replace(/\s/g, '')
if (/^3\d{3}$/.test(digits)) revenueAccount = digits
if (/^[123]\d{3}$/.test(digits)) revenueAccount = digits
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 %).`)
}
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) {
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.
*/
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
cost_price: number | 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 { Currency, Customer, InvoiceDocumentType } from '@/types'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { isBalanceSheetAccount } from '@/lib/invoices/posting-account'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
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
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
}
}
const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount
// Validate any per-line revenue-account override against the company's chart
// of accounts. Zod already constrains the shape to a 3xxx string; here we
// confirm each is a real, active class-3 account so a typo or a non-revenue
// account can never be booked. Never trust the client.
// Validate any per-line posting-account override against the company's chart
// of accounts. The legacy field name is revenue_account, but balance-sheet
// accounts are valid for deposits, customer advances, and genuine outlays.
// 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(
new Set(
items
@@ -248,7 +266,8 @@ export async function buildInvoiceWriteData(params: {
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.eq('account_class', 3)
.gte('account_class', 1)
.lte('account_class', 3)
.eq('is_active', true)
.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"
// 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.
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', {
style: 'decimal',
minimumFractionDigits: 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}`
}
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
// matches the project's formatDate() convention (lib/utils.ts).
// 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
// Calculate per-rate VAT breakdown for totals
const vatByRate = new Map<number, { base: number; vat: number }>()
if (hasPerLineVat) {
for (const item of billableItems) {
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 vatByRate = hasPerLineVat
? buildPdfVatBreakdown(billableItems)
: new Map<number, { base: number; vat: number }>()
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
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.colUnit}>{item.unit}</Text>
{!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 && (
<Text style={styles.colVat}>{item.vat_rate ?? 0}%</Text>
)}
{!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>
)
@@ -910,7 +918,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<View style={styles.totalsSection}>
<View style={styles.totalRow}>
<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>
{vatByRate.size > 1 ? (
Array.from(vatByRate.entries())
@@ -919,12 +927,12 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<View key={rate}>
<View style={styles.totalRow}>
<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>
{group.vat > 0 && (
{group.vat !== 0 && (
<View style={styles.totalRow}>
<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>
@@ -938,7 +946,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
!(company.vat_registered === false && invoice.vat_amount === 0) && (
<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.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency, lang)}</Text>
<Text style={styles.totalValue}>{formatPdfCurrency(invoice.vat_amount, invoice.currency, lang)}</Text>
</View>
)
)}
@@ -952,20 +960,23 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{rounding.applies && (
<View style={styles.totalRow}>
<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>
)}
{showDeduction && (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>{L.deductionRow}</Text>
<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>
</View>
)}
<View style={styles.grandTotal}>
<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>
</>
)
@@ -975,12 +986,12 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{invoice.vat_amount_sek != null && invoice.vat_amount_sek !== 0 && (
<View style={styles.totalRow}>
<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 style={styles.totalRow}>
<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>
)}
@@ -1036,7 +1047,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
const work = i.work_type ? `, ${i.work_type}` : ''
return (
<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>
)
})}
+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) {
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
@@ -533,7 +533,7 @@ async function commitUpdateArticle(
if (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
@@ -1076,14 +1076,14 @@ async function commitCreateInvoice(
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()).
const overrideAccounts = Array.from(
new Set(billableItems.map((i) => i.revenue_account).filter((a): a is string => !!a)),
)
for (const acct of overrideAccounts) {
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,
}
}
+5 -4
View File
@@ -1,13 +1,14 @@
import { z } from 'zod'
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
// Commit-boundary re-validation for staged article operations. A staged
// pending_operations row is re-parsed here before it touches the articles table
// 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.
const revenueAccount = z
const invoicePostingAccount = z
.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)])
@@ -26,7 +27,7 @@ export const CreateArticleParamsSchema = z.object({
unit: optString(32),
price_excl_vat: z.number().nonnegative(),
vat_rate: vatRatePercent.default(25),
revenue_account: revenueAccount.nullable().optional(),
revenue_account: invoicePostingAccount.nullable().optional(),
cost_price: z.number().nonnegative().nullable().optional(),
ean: optString(32),
housework_type: optString(64),
@@ -42,7 +43,7 @@ export const UpdateArticleParamsSchema = z.object({
unit: optString(32),
price_excl_vat: z.number().nonnegative().optional(),
vat_rate: vatRatePercent.optional(),
revenue_account: revenueAccount.nullable().optional(),
revenue_account: invoicePostingAccount.nullable().optional(),
cost_price: z.number().nonnegative().nullable().optional(),
ean: optString(32),
housework_type: optString(64),
@@ -245,6 +245,23 @@ describe('calculateVatDeclaration', () => {
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 () => {
seedLedger(
[
+9 -7
View File
@@ -2710,14 +2710,15 @@
"cancel": "Cancel"
},
"invoice_editor": {
"row_menu_set_account": "Set sales account",
"row_menu_remove_account": "Remove sales account",
"row_menu_set_account": "Set posting account",
"row_menu_remove_account": "Remove posting account",
"row_menu_set_dimensions": "Set 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.",
"row_dimensions_inherit_hint": "Empty fields inherit the invoice default ({dims}).",
"revenue_account_label": "Sales account",
"revenue_account_hint": "Leave blank to derive the account from the VAT rate. Ignored for reverse charge and export.",
"revenue_account_label": "Posting account",
"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_help": "Round the invoice total to whole kronor",
"back": "Back",
@@ -4645,9 +4646,10 @@
"price_required": "Enter a price",
"vat_rate_label": "VAT",
"advanced_section": "Advanced",
"revenue_account_label": "Revenue account",
"revenue_account_placeholder": "e.g. 3041",
"revenue_account_hint": "Leave empty to derive the account automatically from the VAT rate.",
"revenue_account_label": "Posting account",
"revenue_account_placeholder": "e.g. 2897",
"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_hint": "Margin display only, never posted.",
"currency_label": "Currency",
+9 -7
View File
@@ -2710,14 +2710,15 @@
"cancel": "Avbryt"
},
"invoice_editor": {
"row_menu_set_account": "Ange försäljningskonto",
"row_menu_remove_account": "Ta bort försäljningskonto",
"row_menu_set_account": "Ange bokföringskonto",
"row_menu_remove_account": "Ta bort bokföringskonto",
"row_menu_set_dimensions": "Ange 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.",
"row_dimensions_inherit_hint": "Tomma fält ärver fakturans standard ({dims}).",
"revenue_account_label": "Försäljningskonto",
"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_label": "Bokföringskonto",
"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_help": "Avrunda fakturatotal till hel krona",
"back": "Tillbaka",
@@ -4645,9 +4646,10 @@
"price_required": "Ange ett pris",
"vat_rate_label": "Moms",
"advanced_section": "Avancerat",
"revenue_account_label": "Försäljningskonto",
"revenue_account_placeholder": "t.ex. 3041",
"revenue_account_hint": "Lämna tomt för att härleda kontot automatiskt utifrån momsen.",
"revenue_account_label": "Bokföringskonto",
"revenue_account_placeholder": "t.ex. 2897",
"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_hint": "Endast för marginalberäkning, bokförs aldrig.",
"currency_label": "Valuta",
+3 -4
View File
@@ -724,7 +724,7 @@ export interface Article {
/** Default price currency (ISO 4217 code from the currencies table);
* pre-fills the invoice currency when added. */
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
/** Margin/display only: never posted to the ledger. */
cost_price: number | null
@@ -1087,7 +1087,7 @@ export interface InvoiceItem {
// Article linkage. `article_id` is a soft back-reference to the source
// 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"
// booking in generatePerRateLines().
article_id?: string | null
@@ -1363,7 +1363,7 @@ export interface CreateInvoiceItemInput {
vat_rate?: number
/** Source article (optional). Free-text lines omit it. */
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
/** ROT/RUT toggle. null/undefined = no deduction. */
deduction_type?: 'rot' | 'rut' | null
@@ -3804,4 +3804,3 @@ export interface AGIDeclaration {
created_at: string
updated_at: string
}