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
+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: {