Fix/invoice delivery and payment accounts (#1116)

* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
This commit is contained in:
Mattsson
2026-07-23 09:54:02 +02:00
committed by GitHub
parent 321e684523
commit 466e55a015
83 changed files with 6619 additions and 671 deletions
+56
View File
@@ -1230,6 +1230,62 @@ describe('UpdateSettingsSchema', () => {
expect(result.success).toBe(true)
})
it('rejects more than 19 fixed invoice copy recipients in total', () => {
const result = UpdateSettingsSchema.safeParse({
invoice_email_cc_addresses: Array.from(
{ length: 10 },
(_, index) => `copy-${index}@example.test`,
),
invoice_email_bcc_addresses: Array.from(
{ length: 10 },
(_, index) => `archive-${index}@example.test`,
),
})
expect(result.success).toBe(false)
})
it('accepts empty strings when clearing nested invoice payment account fields', () => {
const result = UpdateSettingsSchema.safeParse({
invoice_payment_accounts: {
SEK: {
clearing_number: '',
account_number: '',
bankgiro: '',
plusgiro: '',
iban: '',
bic: '',
},
},
})
expect(result.success).toBe(true)
})
it('accepts null when clearing the legacy SEK bank account mirror', () => {
const result = UpdateSettingsSchema.safeParse({
bank_name: null,
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
bic: null,
})
expect(result.success).toBe(true)
})
it('accepts and normalizes a non-Swedish IBAN in the legacy SEK mirror', () => {
const result = UpdateSettingsSchema.safeParse({
iban: 'gb29 nwbk 6016 1331 9268 19',
})
expect(result.success).toBe(true)
if (result.success) expect(result.data.iban).toBe('GB29NWBK60161331926819')
})
it('accepts a positive next_arrival_number (supplier-invoice start floor)', () => {
const result = UpdateSettingsSchema.safeParse({ next_arrival_number: 248 })
expect(result.success).toBe(true)
+84 -4
View File
@@ -5,6 +5,7 @@ import { isSaneDateString } from '@/lib/utils'
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 type { AuditAction } from '@/types'
// ============================================================
@@ -33,6 +34,19 @@ const accountNumber = z.string().regex(/^\d{4}$/, 'Account number must be exactl
/** Non-negative monetary amount (>= 0) */
const nonNegativeAmount = z.number().nonnegative()
const invoiceEmailAddress = z
.string()
.trim()
.email('Ange en giltig e-postadress')
.max(254, 'E-postadressen får vara max 254 tecken')
const invoiceEmailAddressList = z
.array(invoiceEmailAddress)
.max(
MAX_INVOICE_EMAIL_COPY_RECIPIENTS,
`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
.string()
@@ -728,6 +742,20 @@ export const MarkInvoiceSentSchema = z.object({
})).min(2).optional(),
})
export const SendInvoiceSchema = MarkInvoiceSentSchema.extend({
additional_cc: invoiceEmailAddressList.optional(),
additional_bcc: invoiceEmailAddressList.optional(),
}).refine(
(data) => (
(data.additional_cc?.length ?? 0) + (data.additional_bcc?.length ?? 0)
<= MAX_INVOICE_EMAIL_COPY_RECIPIENTS
),
{
message: `Högst ${MAX_INVOICE_EMAIL_COPY_RECIPIENTS} extra kopiemottagare är tillåtna totalt`,
path: ['additional_cc'],
},
)
// ============================================================
// Customer schemas
// ============================================================
@@ -1499,6 +1527,43 @@ export const InvoiceEmailTextsSchema = z.object({
en: InvoiceEmailTextsLangSchema.optional(),
})
const InvoiceIbanSchema = z.string()
.transform((value) => value.replace(/\s/g, '').toUpperCase())
.pipe(z.string().regex(/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/, 'Ogiltigt IBAN'))
.nullable()
.optional()
.or(z.literal(''))
const InvoicePaymentAccountSchema = z.object({
bank_name: z.string().trim().max(100).nullable().optional(),
clearing_number: z.string().regex(/^\d{4,5}$/, 'Clearingnummer måste vara 4-5 siffror').nullable().optional().or(z.literal('')),
account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6-12 siffror').nullable().optional().or(z.literal('')),
bankgiro: z.string().regex(/^(\d{3,4}-\d{4}|\d{7,8})$/, 'Ogiltigt bankgironummer').nullable().optional().or(z.literal('')),
plusgiro: z.string().regex(/^\d{1,7}-\d$/, 'Ogiltigt plusgironummer').nullable().optional().or(z.literal('')),
swish: z.string().transform(normaliseSwish).pipe(z.string().refine(isValidSwish, 'Ogiltigt Swish-nummer')).nullable().optional(),
iban: InvoiceIbanSchema,
bic: z.string()
.transform((value) => value.replace(/\s/g, '').toUpperCase())
.pipe(z.string().regex(/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/, 'Ogiltig BIC/SWIFT'))
.nullable()
.optional()
.or(z.literal('')),
})
const InvoicePaymentAccountsSchema = z
.partialRecord(CurrencySchema, InvoicePaymentAccountSchema)
.superRefine((accounts, ctx) => {
for (const [currency, account] of Object.entries(accounts)) {
if (currency !== 'SEK' && account && !account.iban) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [currency, 'iban'],
message: `IBAN krävs för betalningskonto i ${currency}`,
})
}
}
})
export const UpdateSettingsSchema = z.object({
entity_type: EntityTypeSchema.optional(),
company_name: z.string().optional(),
@@ -1536,9 +1601,9 @@ export const UpdateSettingsSchema = z.object({
preliminary_tax_monthly: z.number().nullable().optional(),
employer_registered: z.boolean().nullable().optional(),
employer_seasonal: z.boolean().optional(),
bank_name: z.string().max(100, 'Banknamn får vara max 100 tecken').optional(),
clearing_number: z.string().regex(/^\d{4,5}$/, 'Clearingnummer måste vara 4-5 siffror').optional().or(z.literal('')),
account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6-12 siffror').optional().or(z.literal('')),
bank_name: z.string().max(100, 'Banknamn får vara max 100 tecken').nullable().optional(),
clearing_number: z.string().regex(/^\d{4,5}$/, 'Clearingnummer måste vara 4-5 siffror').nullable().optional().or(z.literal('')),
account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6-12 siffror').nullable().optional().or(z.literal('')),
bankgiro: z.string().regex(/^(\d{3,4}-\d{4}|\d{7,8})$/, 'Ogiltigt bankgironummer (7-8 siffror)').nullable().optional().or(z.literal('')),
plusgiro: z.string().regex(/^\d{1,7}-\d{1}$/, 'Ogiltigt plusgironummer').nullable().optional().or(z.literal('')),
swish: z.string()
@@ -1551,8 +1616,11 @@ export const UpdateSettingsSchema = z.object({
)
.nullable()
.optional(),
iban: z.string().regex(/^SE\d{22}$/, 'Ogiltigt IBAN (SE följt av 22 siffror)').nullable().optional().or(z.literal('')),
// Legacy SEK mirror of invoice_payment_accounts.SEK. Use the same general
// IBAN validation because a SEK-denominated account need not be Swedish.
iban: InvoiceIbanSchema,
bic: z.string().regex(/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/, 'Ogiltig BIC/SWIFT (8 eller 11 tecken)').nullable().optional().or(z.literal('')),
invoice_payment_accounts: InvoicePaymentAccountsSchema.optional(),
accounting_method: AccountingMethodSchema.optional(),
// #967: register/send invoices without booking; booking is a separate step.
defer_invoice_booking: z.boolean().optional(),
@@ -1606,6 +1674,8 @@ export const UpdateSettingsSchema = z.object({
// all overrides. Without this entry the generic PUT would silently strip
// the field (the schema is the de-facto column whitelist).
invoice_email_texts: InvoiceEmailTextsSchema.nullable().optional(),
invoice_email_cc_addresses: invoiceEmailAddressList.nullable().optional(),
invoice_email_bcc_addresses: invoiceEmailAddressList.nullable().optional(),
// Invoice branding: colors enforced as #RRGGBB at the DB level too
// (see migration 20260526120200_invoice_branding.sql). The dedicated
// /api/settings/invoicing/branding route is the primary path; these
@@ -1661,6 +1731,16 @@ export const UpdateSettingsSchema = z.object({
// blocks changing this while open vacation-ledger rows exist.
salary_vacation_year_basis: z.enum(['calendar', 'statutory_apr_mar']).optional(),
}).refine(
(data) => (
(data.invoice_email_cc_addresses?.length ?? 0)
+ (data.invoice_email_bcc_addresses?.length ?? 0)
<= MAX_INVOICE_EMAIL_COPY_RECIPIENTS
),
{
message: `Högst ${MAX_INVOICE_EMAIL_COPY_RECIPIENTS} fasta kopiemottagare är tillåtna totalt`,
path: ['invoice_email_cc_addresses'],
},
).refine(
(data) => {
// BFL 3 kap.: Enskild firma must have fiscal year starting January
if (data.entity_type === 'enskild_firma' && data.fiscal_year_start_month !== undefined) {
+45
View File
@@ -355,6 +355,51 @@ describe('withApiV1: idempotency', () => {
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('hashes a cloned body and leaves the original readable by the handler', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
scopes: ['invoices:write'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' }))
mockCheckIdempotency.mockResolvedValue(null)
let observedBody: unknown
const handler = withApiV1(
'invoices.create',
async (request, ctx) => {
observedBody = await request.json()
return ok({ ok: true }, { requestId: ctx.requestId })
},
{ requireScope: 'invoices:write' },
)
const requestBody = { customer_id: 'cust-1', additional_cc: ['copy@example.test'] }
const response = await handler(
makeRequest('https://x.test/api/v1/companies/company-1/invoices', {
method: 'POST',
headers: {
Authorization: 'Bearer gnubok_sk_x',
'Idempotency-Key': 'key-body-readable',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
}),
companyParams('company-1'),
)
expect(response.status).toBe(200)
expect(observedBody).toEqual(requestBody)
expect(mockCheckIdempotency).toHaveBeenCalledWith(
expect.anything(),
'user-1',
'company-1',
'key-body-readable',
expect.any(String),
)
})
})
describe('withApiV1: dry-run', () => {
@@ -26,8 +26,9 @@ vi.mock('@/lib/reports/kassaflodesanalys', () => ({
vi.mock('@/lib/bokslut/assets/asset-service', () => ({
listAssets: vi.fn().mockResolvedValue([]),
}))
const mockFetchAllRows = vi.hoisted(() => vi.fn())
vi.mock('@/lib/supabase/fetch-all', () => ({
fetchAllRows: vi.fn().mockResolvedValue([]),
fetchAllRows: mockFetchAllRows,
}))
import { buildArsredovisningData } from '../build-data'
@@ -274,6 +275,7 @@ function plantStandardReports() {
beforeEach(() => {
vi.clearAllMocks()
mockFetchAllRows.mockResolvedValue([])
plantStandardReports()
})
@@ -352,6 +354,43 @@ describe('buildArsredovisningData: K3', () => {
})
describe('buildArsredovisningData: K2 byte-equivalence', () => {
it('keeps tax and appropriations in the statutory pre-closing balance', async () => {
const supabase = makeSupabase({ accountingFramework: 'k2' })
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
await buildArsredovisningData(supabase, 'co1', 'fp1')
expect(mockedTrialBalance).toHaveBeenCalledWith(
expect.anything(),
'co1',
'fp1',
{ excludeFinalClosingEntry: true },
)
expect(mockedTrialBalance).not.toHaveBeenCalledWith(
expect.anything(),
'co1',
'fp1',
{ excludeYearEndClosing: true },
)
})
it('reuses the current-period mapping in the multi-year overview', async () => {
mockFetchAllRows.mockResolvedValueOnce([
{
id: 'fp1',
name: '2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
},
])
const supabase = makeSupabase({ accountingFramework: 'k2' })
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
await buildArsredovisningData(supabase, 'co1', 'fp1')
const currentPeriodCalls = mockedTrialBalance.mock.calls.filter((call) => call[2] === 'fp1')
expect(currentPeriodCalls).toHaveLength(2)
})
it('records accounting_framework=k2', async () => {
const supabase = makeSupabase({ accountingFramework: 'k2' })
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
@@ -5,7 +5,12 @@ import {
type AnnualReportEligibilityResult,
type AnnualReportProfile,
} from '../compliance-types'
import { validateAnnualReportCompleteness } from '../completeness'
import {
validateAnnualReportCompleteness,
validateStatementIntegrity,
} from '../completeness'
import { mapTrialBalancesToK2 } from '../../ixbrl/k2-mapper'
import { buildBrRows, buildRrRows } from '../statement-rows'
const eligibility: AnnualReportEligibilityResult = {
k2_eligible: true,
@@ -47,9 +52,16 @@ function report(): ArsredovisningData {
total_equity_liabilities: 100,
total_assets_previous: null,
total_equity_liabilities_previous: null,
assets: [{ label: 'Bank', amount: 100 }],
assets: [{ label: 'Bank', current: 100, previous: null }],
equity_liabilities: [
{ label: 'Eget kapital', current: 100, previous: null },
{ label: 'Årets resultat', current: 20, previous: null },
],
},
resultatrakning: [{ label: 'Nettoomsättning', amount: 100 }],
resultatrakning: [
{ label: 'Nettoomsättning', current: 20, previous: null },
{ label: 'Årets resultat', current: 20, previous: null, is_total: true },
],
noter: [{ number: 1, title: 'Principer', body: 'K2' }],
signatures: [{ role: 'Styrelseledamot', name: 'Anna Andersson', signed_at: '2026-03-01' }],
warnings: [],
@@ -157,6 +169,115 @@ describe('validateAnnualReportCompleteness', () => {
expect(result.issues.some((issue) => issue.code === 'AR-DIVIDEND-EXCEEDS-EQUITY')).toBe(true)
})
it('blocks a version whose income-statement result differs from equity', () => {
const value = input('draft')
value.report.resultatrakning = [
{ label: 'Årets resultat', current: 790_296, previous: null, is_total: true },
]
value.report.forvaltningsberattelse.resultatdisposition_amounts.current_year_result = 469_542
const result = validateAnnualReportCompleteness(value)
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'AR-RESULT-MISMATCH', severity: 'error' }),
]),
)
})
it('compares annual-report results at whole-ore precision', () => {
const value = report()
value.resultatrakning = [
{ label: 'Årets resultat', current: 0.1 + 0.2, previous: null, is_total: true },
]
value.balansrakning.equity_liabilities = [
{ label: 'Årets resultat', current: 0.3, previous: null },
]
value.forvaltningsberattelse.resultatdisposition_amounts.current_year_result = 0.3
expect(validateStatementIntegrity(value)).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'AR-RESULT-MISMATCH' }),
]),
)
})
it('blocks a version when the income statement has no final result row', () => {
const value = input('draft')
value.report.resultatrakning = [
{ label: 'Nettoomsättning', current: 100, previous: null },
]
const result = validateAnnualReportCompleteness(value)
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'AR-RESULT-MISSING', severity: 'error' }),
]),
)
})
it('identifies the statutory result by semantic key instead of its K2 label', () => {
const value = report()
value.resultatrakning = [{
label: 'Årets resultat/förlust',
semantic_key: 'income_statement_result',
current: 20,
previous: null,
is_total: true,
}]
value.balansrakning.equity_liabilities = [{
label: 'Periodens resultat',
semantic_key: 'balance_sheet_current_year_result',
current: 20,
previous: null,
}]
expect(validateStatementIntegrity(value)).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'AR-RESULT-MISSING' }),
]),
)
})
it('detects a line reclassification between PDF and iXBRL with unchanged totals', () => {
const value = input('draft')
const full = [
{ account_number: '1930', account_name: 'Bank', closing_debit: 100, closing_credit: 0 },
{ account_number: '2081', account_name: 'Share capital', closing_debit: 0, closing_credit: 80 },
{ account_number: '2099', account_name: 'Current result', closing_debit: 0, closing_credit: 20 },
{ account_number: '3010', account_name: 'Revenue', closing_debit: 20, closing_credit: 20 },
]
const preClosing = [
{ account_number: '1930', account_name: 'Bank', closing_debit: 100, closing_credit: 0 },
{ account_number: '2081', account_name: 'Share capital', closing_debit: 0, closing_credit: 80 },
{ account_number: '3010', account_name: 'Revenue', closing_debit: 0, closing_credit: 20 },
]
const mapping = mapTrialBalancesToK2({ full, preClosing }, null)
const balanceRows = buildBrRows(mapping)
value.report.resultatrakning = buildRrRows(mapping)
value.report.balansrakning.assets = balanceRows.assets
value.report.balansrakning.equity_liabilities = balanceRows.equityLiabilities
value.report.balansrakning.total_assets = mapping.totals.tillgangar.current
value.report.balansrakning.total_equity_liabilities =
mapping.totals.egetKapitalSkulder.current
const ixbrl = {
rr: {
...mapping.rr,
Nettoomsattning: { current: 0, previous: null },
OvrigaRorelseintakter: { current: 20, previous: null },
},
br: mapping.br,
totals: mapping.totals,
}
expect(validateStatementIntegrity(value.report, ixbrl as never)).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'AR-IXBRL-STATEMENT-MISMATCH' }),
]),
)
})
it('requires a documented prudence assessment for a positive dividend', () => {
const value = input('draft')
value.report.forvaltningsberattelse.resultatdisposition_amounts.proposed_dividend = 50
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { mapTrialBalancesToK2, type TrialBalanceRowLike } from '../../ixbrl/k2-mapper'
import { calculateSoliditet } from '../build-data'
function row(
account: string,
debit: number,
credit: number,
): TrialBalanceRowLike {
return {
account_number: account,
account_name: `Account ${account}`,
closing_debit: debit,
closing_credit: credit,
}
}
describe('calculateSoliditet', () => {
it('returns null when total assets are non-positive', () => {
const mapping = mapTrialBalancesToK2({ full: [], preClosing: [] }, null)
expect(calculateSoliditet(mapping)).toBeNull()
})
it('uses adjusted equity and the sign-reclassified balance-sheet total', () => {
const full = [
row('1630', 0, 22_985),
row('1930', 17_428.36, 0),
row('1940', 749_306.35, 0),
row('2081', 0, 25_000),
row('2099', 0, 469_542.21),
row('2125', 0, 197_574),
row('2512', 0, 123_180),
row('2518', 101_970, 0),
row('2641', 1_387.5, 0),
row('2891', 0, 23_223),
row('2893', 0, 8_588),
]
const mapping = mapTrialBalancesToK2({ full, preClosing: full }, null)
expect(mapping.br['OvrigaFordringarKortfristiga'].current).toBe(1_387)
expect(mapping.br['KassaBankExklRedovisningsmedel'].current).toBe(766_735)
expect(mapping.br['Skatteskulder'].current).toBe(44_195)
expect(mapping.br['OvrigaKortfristigaSkulder'].current).toBe(31_811)
expect(mapping.br['Periodiseringsfonder'].current).toBe(197_574)
expect(mapping.totals.tillgangar.current).toBe(768_122)
expect(mapping.totals.egetKapitalSkulder.current).toBe(768_122)
expect(calculateSoliditet(mapping)).toBe(84.8)
})
})
@@ -65,6 +65,23 @@ function previousPair(): TrialBalancePair {
return { full, preClosing }
}
function reportedAccountsPair(): TrialBalancePair {
const full = [
tbRow('1250', 'Computers', { debit: 50 }),
tbRow('1259', 'Accumulated depreciation', { credit: 10 }),
tbRow('1930', 'Bank', { debit: 75 }),
tbRow('2081', 'Share capital', { credit: 50 }),
tbRow('2099', 'Current-year result', { credit: 90 }),
tbRow('2650', 'VAT settlement account', { debit: 25 }),
]
const preClosing = [
...full.filter((row) => row.account_number !== '2099'),
tbRow('3010', 'Revenue', { credit: 100 }),
tbRow('7833', 'Depreciation of computers', { debit: 10 }),
]
return { full, preClosing }
}
describe('buildRrRows / buildBrRows — no kontonummer regression', () => {
it('no RR or BR label contains a BAS account number', () => {
const mapping = mapTrialBalancesToK2(currentPair(), previousPair())
@@ -79,6 +96,17 @@ describe('buildRrRows / buildBrRows — no kontonummer regression', () => {
})
describe('buildRrRows', () => {
it('renders account 7833 depreciation in the statutory expense post', () => {
const rows = buildRrRows(mapTrialBalancesToK2(reportedAccountsPair(), null))
expect(
rows.find(
(row) =>
row.label === 'Av- och nedskrivningar av materiella och immateriella anläggningstillgångar',
)?.current,
).toBe(-10)
})
it('follows the ÅRL uppställningsform order with posts and subtotals', () => {
const mapping = mapTrialBalancesToK2(currentPair(), null)
const labels = buildRrRows(mapping).map((r) => r.label)
@@ -130,6 +158,7 @@ describe('buildRrRows', () => {
const rr = buildRrRows(mapping)
const aretsResultat = rr[rr.length - 1]
expect(aretsResultat.label).toBe('Årets resultat')
expect(aretsResultat.semantic_key).toBe('income_statement_result')
expect(aretsResultat.is_total).toBe(true)
expect(aretsResultat.current).toBe(mapping.totals.aretsResultat.current)
expect(aretsResultat.current).toBe(300_000)
@@ -146,10 +175,25 @@ describe('buildRrRows', () => {
})
describe('buildBrRows', () => {
it('renders a debit on account 2650 under receivables instead of liabilities', () => {
const { assets, equityLiabilities } = buildBrRows(
mapTrialBalancesToK2(reportedAccountsPair(), null),
)
expect(assets.find((row) => row.label === 'Övriga fordringar')?.current).toBe(25)
expect(equityLiabilities.find((row) => row.label === 'Övriga skulder')?.current).toBe(0)
expect(assets.at(-1)?.current).toBe(140)
expect(equityLiabilities.at(-1)?.current).toBe(140)
})
it('renders Kassa och bank as a post and ends both sides on tied totals', () => {
const mapping = mapTrialBalancesToK2(currentPair(), null)
const { assets, equityLiabilities } = buildBrRows(mapping)
expect(
equityLiabilities.find((row) => row.semantic_key === 'balance_sheet_current_year_result'),
).toMatchObject({ label: 'Årets resultat', current: 300_000 })
expect(assets.find((r) => r.label === 'Kassa och bank' && !r.is_heading)?.current).toBe(
600_000,
)
@@ -1,8 +1,26 @@
import { describe, expect, it, vi } from 'vitest'
import type { CanonicalAnnualReport } from '../compliance-types'
import { annualReportContentHash, createAnnualReportVersion } from '../version-service'
import {
annualReportContentHash,
createAnnualReportVersion,
} from '../version-service'
import { mapTrialBalancesToK2 } from '../../ixbrl/k2-mapper'
import { buildBrRows, buildRrRows } from '../statement-rows'
function model(signedAt: string | null): CanonicalAnnualReport {
const full = [
{ account_number: '1930', account_name: 'Bank', closing_debit: 100, closing_credit: 0 },
{ account_number: '2081', account_name: 'Share capital', closing_debit: 0, closing_credit: 80 },
{ account_number: '2099', account_name: 'Current result', closing_debit: 0, closing_credit: 20 },
{ account_number: '3010', account_name: 'Revenue', closing_debit: 20, closing_credit: 20 },
]
const preClosing = [
{ account_number: '1930', account_name: 'Bank', closing_debit: 100, closing_credit: 0 },
{ account_number: '2081', account_name: 'Share capital', closing_debit: 0, closing_credit: 80 },
{ account_number: '3010', account_name: 'Revenue', closing_debit: 0, closing_credit: 20 },
]
const mapping = mapTrialBalancesToK2({ full, preClosing }, null)
const balanceRows = buildBrRows(mapping)
return {
schema_version: '1.0',
generated_at: '2026-07-21T10:00:00Z',
@@ -12,6 +30,16 @@ function model(signedAt: string | null): CanonicalAnnualReport {
report: {
accounting_framework: 'k2',
signatures: [{ role: 'Styrelseledamot', name: 'Anna Andersson', signed_at: signedAt }],
resultatrakning: buildRrRows(mapping),
balansrakning: {
assets: balanceRows.assets,
equity_liabilities: balanceRows.equityLiabilities,
total_assets: 100,
total_equity_liabilities: 100,
},
forvaltningsberattelse: {
resultatdisposition_amounts: { current_year_result: 20 },
},
},
profile: { reporting_currency: 'SEK' },
disclosures: {},
@@ -22,6 +50,9 @@ function model(signedAt: string | null): CanonicalAnnualReport {
validation: { ok: true },
ixbrl: {
entryPointId: 'k2-ab-risbs-2024-09-12',
rr: mapping.rr,
br: mapping.br,
totals: mapping.totals,
underskrifter: {
dateringsdatum: signedAt,
signers: [
@@ -76,4 +107,46 @@ describe('annualReportContentHash', () => {
}),
)
})
it('refuses to snapshot inconsistent financial statements', async () => {
const rpc = vi.fn()
const inconsistent = model(null)
inconsistent.report.resultatrakning.find(
(row) => row.label === 'Årets resultat',
)!.current = 21
inconsistent.validation = { ...inconsistent.validation, ok: true, issues: [] }
await expect(
createAnnualReportVersion({ rpc } as never, 'user-1', inconsistent, false),
).rejects.toThrow('inconsistent financial statements')
expect(rpc).not.toHaveBeenCalled()
})
it('refuses a PDF and iXBRL line reclassification with unchanged totals', async () => {
const rpc = vi.fn()
const inconsistent = model(null)
inconsistent.ixbrl!.rr = {
...inconsistent.ixbrl!.rr,
Nettoomsattning: { current: 0, previous: null },
OvrigaRorelseintakter: { current: 20, previous: null },
}
await expect(
createAnnualReportVersion({ rpc } as never, 'user-1', inconsistent, false),
).rejects.toThrow('inconsistent financial statements')
expect(rpc).not.toHaveBeenCalled()
})
it('refuses a mutated visible balance-sheet result row', async () => {
const rpc = vi.fn()
const inconsistent = model(null)
inconsistent.report.balansrakning.equity_liabilities.find(
(row) => row.label === 'Årets resultat',
)!.current = 19
await expect(
createAnnualReportVersion({ rpc } as never, 'user-1', inconsistent, false),
).rejects.toThrow('inconsistent financial statements')
expect(rpc).not.toHaveBeenCalled()
})
})
+32 -44
View File
@@ -1,5 +1,4 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys'
import { listAssets } from '@/lib/bokslut/assets/asset-service'
@@ -85,7 +84,7 @@ export async function buildArsredovisningData(
.range(from, to),
),
generateTrialBalance(supabase, companyId, fiscalPeriodId),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeYearEndClosing: true }),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeFinalClosingEntry: true }),
// Load persisted narrative overrides: replaces the URL-query-param
// carry from earlier phases. Caller-supplied overrides (passed in via
// the second arg) still win, so the API can layer per-request edits on
@@ -134,8 +133,11 @@ export async function buildArsredovisningData(
try {
const [prevFull, prevPreClosing] = await Promise.all([
generateTrialBalance(supabase, companyId, prevPeriodRow.id),
// Comparative RR figures need the same statutory view as the current
// year: keep booked depreciation, appropriations, and tax, excluding
// only the linked final result-closing entry.
generateTrialBalance(supabase, companyId, prevPeriodRow.id, {
excludeYearEndClosing: true,
excludeFinalClosingEntry: true,
}),
])
previousTb = { full: prevFull.rows, preClosing: prevPreClosing.rows }
@@ -169,7 +171,7 @@ export async function buildArsredovisningData(
companyId,
fiscalPeriodId,
(periodList ?? []) as Array<{ id: string; name: string; period_start: string; period_end: string }>,
accountingFramework,
mapping,
)
const egen_kapital_changes = buildEquityChanges(mapping)
@@ -402,12 +404,21 @@ interface PeriodRow {
period_end: string
}
export function calculateSoliditet(mapping: K2MappingResult): number | null {
const totalAssets = mapping.totals.tillgangar.current
if (totalAssets <= 0) return null
const adjustedEquity =
mapping.totals.egetKapital.current +
mapping.totals.obeskattadeReserver.current * (1 - LATENT_TAX_DEFAULT_RATE)
return Math.round((adjustedEquity / totalAssets) * 1000) / 10
}
async function buildFlerarsoversikt(
supabase: SupabaseClient,
companyId: string,
currentPeriodId: string,
allPeriods: PeriodRow[],
accountingFramework: AccountingFramework,
currentMapping: K2MappingResult,
): Promise<FlerarsoversiktRow[]> {
// Take the current period + 3 prior (oldest first).
const sorted = [...allPeriods].sort((a, b) => a.period_start.localeCompare(b.period_start))
@@ -418,46 +429,23 @@ async function buildFlerarsoversikt(
const rows: FlerarsoversiktRow[] = []
for (const p of slice) {
try {
const [is, tb] = await Promise.all([
generateIncomeStatement(supabase, companyId, p.id),
generateTrialBalance(supabase, companyId, p.id),
])
// Nettoomsättning = sum of revenue sections (revenue is normally credit).
const netRevenue = is.total_revenue
const resultAfterFinancial = is.total_revenue - is.total_expenses + is.total_financial
const totalAssets = tb.rows
.filter((r) => r.account_class === 1)
.reduce((s, r) => s + (r.closing_debit - r.closing_credit), 0)
const eqLiab = tb.rows
.filter((r) => r.account_class === 2)
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
// Soliditet differs by framework:
// K2 (ÅRL / BFNAR 2016:10): 20xx only. 21xx (periodiseringsfonder,
// överavskrivningar) are obeskattade reserver: partially deferred
// tax, not equity. Including 21xx would inflate soliditet for any AB
// that posts dispositions.
//
// K3 (BFNAR 2012:1) splits 21xx into 79,4 % equity + 20,6 % latent
// skatteskuld. Account 2240 holds the latent tax liability and is
// already classified as a liability via class 2 / account_group 22,
// so the soliditet add-on is just the equity portion of 21xx. (We
// do NOT double-count 2240 here: the trial balance row for 2240
// already lives in eqLiab as a liability.)
const baseEquity = tb.rows
.filter((r) => r.account_number.startsWith('20'))
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
let equity = baseEquity
if (accountingFramework === 'k3') {
const obeskattadeReserver = tb.rows
.filter((r) => r.account_number.startsWith('21'))
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
equity += obeskattadeReserver * (1 - LATENT_TAX_DEFAULT_RATE)
let mapping = currentMapping
if (p.id !== currentPeriodId) {
const [tbFull, tbPreClosing] = await Promise.all([
generateTrialBalance(supabase, companyId, p.id),
generateTrialBalance(supabase, companyId, p.id, { excludeFinalClosingEntry: true }),
])
mapping = mapTrialBalancesToK2(
{ full: tbFull.rows, preClosing: tbPreClosing.rows },
null,
)
}
const soliditet =
totalAssets > 0 ? Math.round((equity / totalAssets) * 1000) / 10 : null
// Avoid the unused-variable warning while leaving eqLiab computed for
// future "Skulder" column expansion.
void eqLiab
const netRevenue = mapping.rr['Nettoomsattning']?.current ?? 0
const resultAfterFinancial = mapping.totals.resultatEfterFinansiellaPoster.current
// K2 flerårsöversikt defines soliditet as adjusted equity divided by
// total assets. Adjusted equity includes the equity portion of untaxed
// reserves even though those reserves remain a separate BR section.
const soliditet = calculateSoliditet(mapping)
rows.push({
year: p.name,
net_revenue: Math.round(netRevenue),
+118 -15
View File
@@ -1,4 +1,6 @@
import type { ArsredovisningData } from './types'
import type { IxbrlArsredovisningInput } from '@/lib/bokslut/ixbrl/types'
import { buildBrRows, buildRrRows } from './statement-rows'
import type {
AnnualReportComplianceIssue,
AnnualReportDisclosureState,
@@ -39,6 +41,121 @@ function push(
issues.push({ code, severity, section, message, remediation })
}
function statementRowsEqual(
left: ArsredovisningData['resultatrakning'],
right: ArsredovisningData['resultatrakning'],
): boolean {
return left.length === right.length && left.every((row, index) => {
const other = right[index]
return (
row.label === other.label &&
row.semantic_key === other.semantic_key &&
row.current === other.current &&
row.previous === other.previous &&
Boolean(row.is_total) === Boolean(other.is_total) &&
Boolean(row.is_heading) === Boolean(other.is_heading) &&
(row.indent ?? 0) === (other.indent ?? 0)
)
})
}
export function validateStatementIntegrity(
report: ArsredovisningData,
ixbrl: IxbrlArsredovisningInput | null = null,
): AnnualReportComplianceIssue[] {
const issues: AnnualReportComplianceIssue[] = []
if (
Math.round(report.balansrakning.total_assets * 100) !==
Math.round(report.balansrakning.total_equity_liabilities * 100)
) {
push(
issues,
'AR-BALANCE-MISMATCH',
'error',
'statements',
'Balansräkningen balanserar inte i årsredovisningen.',
)
}
const incomeStatementResult = (
report.resultatrakning.find(
(row) => row.semantic_key === 'income_statement_result' && row.current !== null,
)
?? report.resultatrakning.find(
(row) => row.label === 'Årets resultat' && row.current !== null,
)
)?.current ?? undefined
const visibleBalanceSheetResult = (
report.balansrakning.equity_liabilities.find(
(row) => row.semantic_key === 'balance_sheet_current_year_result' && row.current !== null,
)
?? report.balansrakning.equity_liabilities.find(
(row) => row.label === 'Årets resultat' && row.current !== null,
)
)?.current ?? undefined
const dispositionResult =
report.forvaltningsberattelse.resultatdisposition_amounts.current_year_result
if (incomeStatementResult === undefined || visibleBalanceSheetResult === undefined) {
push(
issues,
'AR-RESULT-MISSING',
'error',
'statements',
'Resultat- eller balansräkningen saknar raden Årets resultat.',
)
} else if (
Math.round(incomeStatementResult * 100) !== Math.round(visibleBalanceSheetResult * 100) ||
Math.round(incomeStatementResult * 100) !== Math.round(dispositionResult * 100)
) {
push(
issues,
'AR-RESULT-MISMATCH',
'error',
'statements',
'Årets resultat i resultaträkningen stämmer inte med årets resultat i balansräkningen.',
'Kontrollera att årsredovisningen innehåller bokslutsdispositioner och skatt före resultatstängningen.',
)
}
if (
report.resultatrakning.length === 0 ||
report.balansrakning.assets.length === 0 ||
report.balansrakning.equity_liabilities.length === 0
) {
push(
issues,
'AR-STATEMENTS-EMPTY',
'error',
'statements',
'Resultat- eller balansräkningen saknar rader.',
)
}
if (ixbrl) {
const ixbrlMapping = { rr: ixbrl.rr, br: ixbrl.br, totals: ixbrl.totals }
const ixbrlIncomeRows = buildRrRows(ixbrlMapping)
const ixbrlBalanceRows = buildBrRows(ixbrlMapping)
if (
!statementRowsEqual(report.resultatrakning, ixbrlIncomeRows) ||
!statementRowsEqual(report.balansrakning.assets, ixbrlBalanceRows.assets) ||
!statementRowsEqual(
report.balansrakning.equity_liabilities,
ixbrlBalanceRows.equityLiabilities,
)
) {
push(
issues,
'AR-IXBRL-STATEMENT-MISMATCH',
'error',
'statements',
'Beloppen i PDF-underlaget och iXBRL-underlaget stämmer inte överens.',
'Skapa om årsredovisningen från ett oförändrat bokslut.',
)
}
}
return issues
}
export interface ValidateAnnualReportInput {
report: ArsredovisningData
profile: AnnualReportProfile
@@ -154,18 +271,7 @@ export function validateAnnualReportCompleteness(
)
}
if (
Math.round(report.balansrakning.total_assets * 100) !==
Math.round(report.balansrakning.total_equity_liabilities * 100)
) {
push(
issues,
'AR-BALANCE-MISMATCH',
'error',
'statements',
'Balansräkningen balanserar inte i årsredovisningen.',
)
}
issues.push(...validateStatementIntegrity(report))
if (
report.previous_period &&
(report.balansrakning.total_assets_previous === null ||
@@ -179,9 +285,6 @@ export function validateAnnualReportCompleteness(
'Jämförelsetal saknas trots att ett föregående räkenskapsår finns.',
)
}
if (report.resultatrakning.length === 0 || report.balansrakning.assets.length === 0) {
push(issues, 'AR-STATEMENTS-EMPTY', 'error', 'statements', 'Resultat- eller balansräkningen saknar rader.')
}
if (report.noter.length === 0) {
push(issues, 'AR-NOTES-EMPTY', 'error', 'notes', 'Årsredovisningen saknar noter.')
}
+17 -2
View File
@@ -3,7 +3,10 @@ import { buildArsredovisningData } from './build-data'
import { listSignatureRequests } from './signature-service'
import { getAnnualReportProfile } from './profile-service'
import { evaluateAnnualReportEligibility } from './eligibility'
import { validateAnnualReportCompleteness } from './completeness'
import {
validateAnnualReportCompleteness,
validateStatementIntegrity,
} from './completeness'
import {
ANNUAL_REPORT_SCHEMA_VERSION,
type AnnualReportDisclosureState,
@@ -113,7 +116,7 @@ export async function buildCanonicalAnnualReport(
metrics,
})
const disclosures = disclosureState(report)
const validation = validateAnnualReportCompleteness({
let validation = validateAnnualReportCompleteness({
report,
profile,
disclosures,
@@ -133,6 +136,18 @@ export async function buildCanonicalAnnualReport(
})
}
const crossDocumentIssues = validateStatementIntegrity(report, ixbrl).filter(
(issue) => issue.code === 'AR-IXBRL-STATEMENT-MISMATCH',
)
if (crossDocumentIssues.length > 0) {
validation = {
...validation,
ok: false,
error_count: validation.error_count + crossDocumentIssues.length,
issues: [...validation.issues, ...crossDocumentIssues],
}
}
return {
schema_version: ANNUAL_REPORT_SCHEMA_VERSION,
generated_at: options.generatedAt ?? new Date().toISOString(),
+14 -5
View File
@@ -21,6 +21,7 @@ import type { K2MappingResult } from '@/lib/bokslut/ixbrl/k2-mapper'
import type { StatementRow } from './types'
const ZERO: ConceptAmount = { current: 0, previous: null }
type StatementMapping = Pick<K2MappingResult, 'rr' | 'br' | 'totals'>
function hasValue(amount: ConceptAmount): boolean {
return amount.current !== 0 || (amount.previous ?? 0) !== 0
@@ -28,6 +29,7 @@ function hasValue(amount: ConceptAmount): boolean {
interface RowOptions {
indent?: number
semantic_key?: StatementRow['semantic_key']
/** Presentational minus — show cost posts as negative. */
displayMinus?: boolean
/** Emit the row even when zero in both years (statutory always-visible posts). */
@@ -64,6 +66,7 @@ class RowBuilder {
label,
current: sign * amount.current,
previous: this.hasPrevious ? sign * (amount.previous ?? 0) : null,
...(opts.semantic_key ? { semantic_key: opts.semantic_key } : {}),
...(isTotal ? { is_total: true } : {}),
...(opts.indent ? { indent: opts.indent } : {}),
}
@@ -73,7 +76,7 @@ class RowBuilder {
/** The mapper leaves `previous` null on every concept when the company has
* no previous fiscal year; any concept with a number means a jämförelseår
* exists. */
function mappingHasPrevious(mapping: K2MappingResult): boolean {
function mappingHasPrevious(mapping: StatementMapping): boolean {
return mapping.totals.tillgangar.previous !== null
}
@@ -81,7 +84,7 @@ function mappingHasPrevious(mapping: K2MappingResult): boolean {
* Resultaträkning — kostnadsslagsindelad per ÅRL bilaga 2 / K2 risbs, in
* uppställningsform order.
*/
export function buildRrRows(mapping: K2MappingResult): StatementRow[] {
export function buildRrRows(mapping: StatementMapping): StatementRow[] {
const { rr, totals } = mapping
const b = new RowBuilder(mappingHasPrevious(mapping))
@@ -171,7 +174,9 @@ export function buildRrRows(mapping: K2MappingResult): StatementRow[] {
b.heading('Skatter')
b.post('Skatt på årets resultat', rr['SkattAretsResultat'], { indent: 1, displayMinus: true })
b.post('Övriga skatter', rr['OvrigaSkatter'], { indent: 1, displayMinus: true })
b.total('Årets resultat', totals.aretsResultat)
b.total('Årets resultat', totals.aretsResultat, {
semantic_key: 'income_statement_result',
})
return b.rows
}
@@ -182,7 +187,7 @@ export function buildRrRows(mapping: K2MappingResult): StatementRow[] {
* kortfristiga fordringar, kassa och bank, eget kapital and kortfristiga
* skulder always render.
*/
export function buildBrRows(mapping: K2MappingResult): {
export function buildBrRows(mapping: StatementMapping): {
assets: StatementRow[]
equityLiabilities: StatementRow[]
} {
@@ -356,7 +361,11 @@ export function buildBrRows(mapping: K2MappingResult): {
e.heading('Fritt eget kapital', 1)
e.post('Överkursfond', br['Overkursfond'], { indent: 2 })
e.post('Balanserat resultat', br['BalanseratResultat'], { indent: 2, alwaysShow: true })
e.post('Årets resultat', br['AretsResultatEgetKapital'], { indent: 2, alwaysShow: true })
e.post('Årets resultat', br['AretsResultatEgetKapital'], {
indent: 2,
alwaysShow: true,
semantic_key: 'balance_sheet_current_year_result',
})
e.total('Summa fritt eget kapital', totals.frittEgetKapital, { indent: 1 })
e.total('Summa eget kapital', totals.egetKapital)
if (hasValue(totals.obeskattadeReserver)) {
+4
View File
@@ -38,6 +38,9 @@ export interface NoteEntry {
*/
export interface StatementRow {
label: string
/** Stable integrity key for rows whose legal meaning must not depend on the
* localized presentation label. */
semantic_key?: 'income_statement_result' | 'balance_sheet_current_year_result'
/** Whole-SEK amount for the current year; null on heading rows. */
current: number | null
/** Previous-year amount (jämförelseår, ÅRL 3:5 §); null on heading rows
@@ -95,6 +98,7 @@ export interface ArsredovisningData {
resultatdisposition_amounts: {
retained_earnings: number
share_premium_reserve: number
/** Server-derived from the statutory statement mapping, never narrative input. */
current_year_result: number
total: number
proposed_dividend: number
@@ -6,6 +6,11 @@ import type {
CanonicalAnnualReport,
} from './compliance-types'
import { getEntryPoint } from '@/lib/bokslut/ixbrl/taxonomy/entry-points'
import { validateStatementIntegrity } from './completeness'
export function hasStatementIntegrityErrors(model: CanonicalAnnualReport): boolean {
return validateStatementIntegrity(model.report, model.ixbrl).length > 0
}
function stableValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableValue)
@@ -143,6 +148,9 @@ export async function createAnnualReportVersion(
model: CanonicalAnnualReport,
finalize: boolean,
): Promise<AnnualReportVersionSummary> {
if (hasStatementIntegrityErrors(model)) {
throw new Error('Annual report has inconsistent financial statements and cannot be versioned')
}
if (finalize && !model.validation.ok) {
throw new Error('Annual report has blocking validation errors and cannot be finalized')
}
+1 -1
View File
@@ -9,7 +9,7 @@
* class 3-8 account is zeroed (equal debit/credit churn) and 2099
* carries the year's result.
* - `preClosing`: the same year WITHOUT the closing entry
* (excludeYearEndClosing): RR accounts still open, 2099 only carries
* (excludeFinalClosingEntry): RR accounts still open, 2099 only carries
* the prior-year churn from the resultatdisposition entry.
*/
@@ -82,6 +82,42 @@ describe('generateK2IxbrlDocument', () => {
expect(xhtml).toMatch(/contextRef="balans1" name="se-gen-base:Tillgangar"[^>]*>253 000/)
})
it('emits account 7833 depreciation and debit 2650 as the correct iXBRL facts', () => {
const balance = (account: string, name: string, debit: number, credit: number) => ({
account_number: account,
account_name: name,
closing_debit: debit,
closing_credit: credit,
})
const full = [
balance('1250', 'Computers', 50, 0),
balance('1259', 'Accumulated depreciation', 0, 10),
balance('1930', 'Bank', 75, 0),
balance('2081', 'Share capital', 0, 50),
balance('2099', 'Current-year result', 0, 90),
balance('2650', 'VAT settlement account', 25, 0),
]
const preClosing = [
...full.filter((row) => row.account_number !== '2099'),
balance('3010', 'Revenue', 0, 100),
balance('7833', 'Depreciation of computers', 10, 0),
]
const mapping = mapTrialBalancesToK2({ full, preClosing }, null)
const input = makeInput()
input.rr = mapping.rr
input.br = mapping.br
input.totals = mapping.totals
const { xhtml: reportedAccountsXhtml } = generateK2IxbrlDocument(input)
expect(reportedAccountsXhtml).toMatch(
/name="se-gen-base:AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar"[^>]*>10<\/ix:nonFraction>/,
)
expect(reportedAccountsXhtml).toMatch(
/name="se-gen-base:OvrigaFordringarKortfristiga"[^>]*>25<\/ix:nonFraction>/,
)
})
it('tags the underskrifter tuple with per-signer dates (TA §2.9.1)', () => {
expect(xhtml).toContain('se-gaap-ext:UnderskriftArsredovisningForetradareTuple')
const tilltalsnamn = xhtml.match(/name="se-gen-base:UnderskriftHandlingTilltalsnamn"/g) ?? []
+203 -7
View File
@@ -92,6 +92,144 @@ describe('mapTrialBalancesToK2', () => {
expect(result.totals.aretsResultat.current).toBe(result.br['AretsResultatEgetKapital'].current)
})
it('reclassifies tax and VAT balances by economic sign', () => {
const rows = [
row('1930', 'Bank', 100, 0),
row('1630', 'Tax account', 0, 20),
row('2518', 'Paid preliminary tax', 30, 0),
row('2641', 'Input VAT', 5, 0),
row('2081', 'Share capital', 0, 115),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.br['OvrigaFordringarKortfristiga'].current).toBe(35)
expect(res.br['Skatteskulder'].current).toBe(20)
expect(res.br['OvrigaKortfristigaSkulder'].current).toBe(0)
expect(res.totals.tillgangar.current).toBe(135)
expect(res.totals.egetKapitalSkulder.current).toBe(135)
expect(res.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining('1630-1659'),
expect.stringContaining('2500-2599'),
expect.stringContaining('2610-2659'),
]),
)
})
it('maps account 7833 depreciation from the statutory pre-closing balance', () => {
const full = [
row('1250', 'Computers', 50, 0),
row('1259', 'Accumulated depreciation', 0, 10),
row('1930', 'Bank', 100, 0),
row('2081', 'Share capital', 0, 50),
row('2099', 'Current-year result', 0, 90),
]
const preClosing = [
...full.filter((balance) => balance.account_number !== '2099'),
row('3010', 'Revenue', 0, 100),
row('7833', 'Depreciation of computers', 10, 0),
]
const res = mapTrialBalancesToK2({ full, preClosing }, null)
expect(
res.rr['AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar']
.current,
).toBe(10)
expect(res.totals.aretsResultat.current).toBe(90)
expect(res.br['AretsResultatEgetKapital'].current).toBe(90)
expect(res.totals.tillgangar.current).toBe(res.totals.egetKapitalSkulder.current)
})
it('presents a debit on account 2650 as a receivable for each comparison year', () => {
const current = [
row('1930', 'Bank', 75, 0),
row('2081', 'Share capital', 0, 100),
row('2650', 'VAT settlement account', 25, 0),
]
const previous = [
row('1930', 'Bank', 100, 0),
row('2081', 'Share capital', 0, 75),
row('2650', 'VAT settlement account', 0, 25),
]
const res = mapTrialBalancesToK2(
{ full: current, preClosing: current },
{ full: previous, preClosing: previous },
)
expect(res.br['OvrigaFordringarKortfristiga']).toEqual({ current: 25, previous: 0 })
expect(res.br['OvrigaKortfristigaSkulder']).toEqual({ current: 0, previous: 25 })
expect(res.totals.tillgangar).toEqual({ current: 100, previous: 100 })
expect(res.totals.egetKapitalSkulder).toEqual({ current: 100, previous: 100 })
})
it('nets paid preliminary tax against the current tax liability', () => {
const rows = [
row('1930', 'Bank', 100, 0),
row('2512', 'Current tax', 0, 123.18),
row('2518', 'Paid preliminary tax', 23.18, 0),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.br['Skatteskulder'].current).toBe(100)
expect(res.br['OvrigaFordringarKortfristiga'].current).toBe(0)
})
it('nets domestic VAT without offsetting excise duty', () => {
const rows = [
row('1930', 'Bank', 15, 0),
row('2611', 'Output VAT', 0, 50),
row('2641', 'Input VAT', 75, 0),
row('2660', 'Excise duty', 0, 40),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.br['OvrigaFordringarKortfristiga'].current).toBe(25)
expect(res.br['OvrigaKortfristigaSkulder'].current).toBe(40)
expect(res.totals.tillgangar.current).toBe(40)
expect(res.totals.egetKapitalSkulder.current).toBe(40)
})
it('reclassifies a previous-year tax-account credit independently', () => {
const current = [
row('1930', 'Bank', 100, 0),
row('2081', 'Share capital', 0, 100),
]
const previous = [
row('1930', 'Bank', 20, 0),
row('1630', 'Tax account', 0, 20),
]
const res = mapTrialBalancesToK2(
{ full: current, preClosing: current },
{ full: previous, preClosing: previous },
)
expect(res.br['OvrigaFordringarKortfristiga']).toEqual({ current: 0, previous: 0 })
expect(res.br['Skatteskulder']).toEqual({ current: 0, previous: 20 })
expect(res.warnings).toContainEqual(expect.stringContaining('1630-1659'))
})
it('does not offset a tax-account liability against a separate tax receivable', () => {
const rows = [
row('1630', 'Tax account', 0, 100),
row('1650', 'VAT receivable', 100, 0),
row('2081', 'Share capital', 0, 100),
row('1930', 'Bank', 100, 0),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.br['OvrigaFordringarKortfristiga'].current).toBe(100)
expect(res.br['Skatteskulder'].current).toBe(100)
expect(res.totals.tillgangar.current).toBe(200)
expect(res.totals.egetKapitalSkulder.current).toBe(200)
})
// Regression for the year-end-closing split: a realistic post-bokslut TB
// pair must yield NON-ZERO RR concepts (from the pre-closing TB) AND a BR
// that ties (from the full TB). Mapping a single TB can never do both: the
@@ -165,11 +303,11 @@ describe('mapTrialBalancesToK2', () => {
})
describe('mapTrialBalancesToK2: öre-rounding residual smoothing', () => {
it('absorbs a ±1 kr BR residual into the largest equity/liability post', () => {
it('absorbs a ±1 kr BR residual into a post with an exact öre balance', () => {
// Assets round UP twice (.50 each), liabilities round once up once down:
// rounded Tillgångar 202 vs rounded EK+skulder 201 although the TB ties
// exactly at 201,00. The +1 residual lands in the largest post on the
// equity/liabilities side (Leverantörsskulder).
// exactly at 201,00. The +1 residual lands on a fractional post, never
// on an unrelated exact whole-krona balance.
const rows = [
row('1510', 'Kundfordringar', 100.5, 0),
row('1930', 'Bank', 100.5, 0),
@@ -177,9 +315,10 @@ describe('mapTrialBalancesToK2: öre-rounding residual smoothing', () => {
row('2510', 'Skatteskulder', 0, 100.25),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.totals.tillgangar.current).toBe(202)
expect(res.totals.egetKapitalSkulder.current).toBe(202)
expect(res.br['Leverantorsskulder'].current).toBe(102)
expect(res.totals.tillgangar.current).toBe(201)
expect(res.totals.egetKapitalSkulder.current).toBe(201)
expect(res.br['Kundfordringar'].current).toBe(100)
expect(res.br['Leverantorsskulder'].current).toBe(101)
expect(res.br['Skatteskulder'].current).toBe(100)
expect(res.warnings).toEqual([])
})
@@ -207,7 +346,64 @@ describe('mapTrialBalancesToK2: öre-rounding residual smoothing', () => {
expect(res.warnings).toEqual([])
})
it('leaves residuals beyond ±1 kr alone and reports them', () => {
it('distributes a multi-krona BR residual over fractional posts', () => {
const rows = [
row('1510', 'Trade receivable', 0.5, 0),
row('1630', 'Tax account', 0.5, 0),
row('1710', 'Prepaid expense', 0.5, 0),
row('1810', 'Short-term investment', 0.5, 0),
row('1930', 'Bank', 0.5, 0),
row('2081', 'Share capital', 0, 2.5),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.totals.tillgangar.current).toBe(3)
expect(res.totals.egetKapitalSkulder.current).toBe(3)
expect(res.warnings.some((warning) => warning.includes('3005'))).toBe(false)
})
it('normalizes exact öre sums before whole-krona rounding', () => {
const rows = [
row('1510', 'Trade receivable', 0.03, 0),
row('1710', 'Prepaid expense', 0.29, 0),
row('1930', 'Bank', 0.18, 0),
row('2081', 'Share capital', 0, 0.5),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.totals.tillgangar.current).toBe(1)
expect(res.totals.egetKapitalSkulder.current).toBe(1)
expect(res.warnings.some((warning) => warning.includes('3005'))).toBe(false)
})
it('distributes a multi-krona RR residual over fractional posts', () => {
const incomeRows = [
row('3010', 'Revenue', 0, 0.5),
row('3810', 'Capitalized work', 0, 0.5),
row('3910', 'Other income', 0, 0.5),
row('8010', 'Group result', 0, 0.5),
row('8310', 'Interest income', 0, 0.5),
]
const preClosing = [row('1930', 'Bank', 2.5, 0), ...incomeRows]
const full = [
row('1930', 'Bank', 2.5, 0),
row('2099', 'Current result', 0, 2.5),
...incomeRows.map((incomeRow) => ({
...incomeRow,
closing_debit: incomeRow.closing_credit,
})),
]
const res = mapTrialBalancesToK2({ full, preClosing }, null)
expect(res.totals.aretsResultat.current).toBe(3)
expect(res.br['AretsResultatEgetKapital'].current).toBe(3)
expect(res.warnings.some((warning) => warning.includes('2099'))).toBe(false)
})
it('leaves real bookkeeping differences alone and reports them', () => {
const rows = [
row('1930', 'Bank', 1_000, 0),
row('2440', 'Leverantörsskulder', 0, 990),
+4 -4
View File
@@ -58,8 +58,8 @@ export async function buildIxbrlInput(
// Two TB variants per year (see TrialBalancePair): the FULL trial balance
// (year-end closing included → 2099 booked, class 3-8 zeroed) drives the
// BR; the PRE-CLOSING trial balance (excludeYearEndClosing: the same split
// lib/reports' generateIncomeStatement uses) drives the RR. A single TB can
// BR; the PRE-CLOSING trial balance (excludeFinalClosingEntry) drives the
// RR while retaining tax and appropriations. A single TB can
// never serve both: with bokslut booked every RR concept would map to 0,
// without it the BR would not tie.
const [pdfData, periodRow, currentTbFull, currentTbPreClosing, signatureRequests] =
@@ -72,7 +72,7 @@ export async function buildIxbrlInput(
.eq('company_id', companyId)
.single(),
generateTrialBalance(supabase, companyId, fiscalPeriodId),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeYearEndClosing: true }),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeFinalClosingEntry: true }),
options.signatureRequests ?? listSignatureRequests(supabase, companyId, fiscalPeriodId),
])
@@ -102,7 +102,7 @@ export async function buildIxbrlInput(
try {
const [prevFull, prevPreClosing] = await Promise.all([
generateTrialBalance(supabase, companyId, prev.id),
generateTrialBalance(supabase, companyId, prev.id, { excludeYearEndClosing: true }),
generateTrialBalance(supabase, companyId, prev.id, { excludeFinalClosingEntry: true }),
])
previousTb = { full: prevFull.rows, preClosing: prevPreClosing.rows }
} catch {
+212 -67
View File
@@ -15,6 +15,7 @@
*/
import type { ConceptAmount, ConceptAmounts } from './types'
import { equalOre, roundOre, sumOre } from '@/lib/money'
export interface TrialBalanceRowLike {
account_number: string
@@ -29,7 +30,7 @@ export interface TrialBalanceRowLike {
* never serve both statements:
* - `full` (including the closing entry) carries the booked 2099 and the
* correct equity: it drives the BR concepts.
* - `preClosing` (generateTrialBalance with excludeYearEndClosing: true)
* - `preClosing` (generateTrialBalance with excludeFinalClosingEntry: true)
* still has the RR accounts open: it drives the RR concepts.
* Mirrors how lib/reports' generateIncomeStatement/generateBalanceSheet split
* the same source.
@@ -51,6 +52,15 @@ interface PostMapping {
ranges: Range[]
}
interface SignReclassification {
sourceConcept: string
targetConcept: string
balance: 'debit' | 'credit'
ranges: Range[]
mode: 'net' | 'deviating_rows'
warning: string
}
const r = (start: string, end: string): Range => ({ start, end })
/** RR: kostnadsslagsindelad (risbs), in uppställningsform order. */
@@ -383,6 +393,42 @@ const RECLASSIFIED_ACCOUNTS: Record<string, string> = {
'2089': 'Fond för utvecklingsutgifter (2089) redovisas under Reservfond: granska klassificeringen (K2 tillåter inte aktivering av egenupparbetade utgifter).',
}
/**
* Tax settlement and VAT accounts can carry the opposite economic balance
* from their BAS class. K2 presentation follows the balance's substance:
* a tax-account credit is a liability, while a net debit on tax or VAT
* liability accounts is a current receivable.
*/
const SIGN_RECLASSIFICATIONS: SignReclassification[] = [
{
sourceConcept: 'OvrigaFordringarKortfristiga',
targetConcept: 'Skatteskulder',
balance: 'debit',
ranges: [r('1630', '1659')],
mode: 'deviating_rows',
warning:
'Skatte- och momsfordringskonton 1630-1659 har ett nettokreditsaldo och har därför redovisats som skatteskuld.',
},
{
sourceConcept: 'Skatteskulder',
targetConcept: 'OvrigaFordringarKortfristiga',
balance: 'credit',
ranges: [r('2500', '2599')],
mode: 'net',
warning:
'Skatteskuldkonton 2500-2599 har ett nettodebetsaldo och har därför redovisats som övrig fordran.',
},
{
sourceConcept: 'OvrigaKortfristigaSkulder',
targetConcept: 'OvrigaFordringarKortfristiga',
balance: 'credit',
ranges: [r('2610', '2659')],
mode: 'net',
warning:
'Momsavräkningskonton 2610-2659 har ett nettodebetsaldo och har därför redovisats som övrig fordran.',
},
]
export interface K2MappingResult {
rr: ConceptAmounts
br: ConceptAmounts
@@ -421,7 +467,7 @@ export interface K2MappingResult {
}
function netBalance(row: TrialBalanceRowLike, orientation: 'debit' | 'credit'): number {
const net = row.closing_debit - row.closing_credit
const net = roundOre(row.closing_debit - row.closing_credit)
return orientation === 'debit' ? net : -net
}
@@ -429,34 +475,80 @@ function inRanges(account: string, ranges: Range[]): boolean {
return ranges.some((range) => account >= range.start && account <= range.end)
}
function sumForMapping(rows: TrialBalanceRowLike[], mapping: PostMapping): number {
let total = 0
for (const row of rows) {
if (inRanges(row.account_number, mapping.ranges)) {
total += netBalance(row, mapping.balance)
}
}
return Math.round(total)
function exactSumForMapping(rows: TrialBalanceRowLike[], mapping: PostMapping): number {
return sumOre(
rows
.filter((row) => inRanges(row.account_number, mapping.ranges))
.map((row) => netBalance(row, mapping.balance)),
)
}
function amount(
function roundWhole(amount: number): number {
const normalized = roundOre(amount)
const rounded = Math.round(Math.abs(normalized))
return rounded === 0 ? 0 : Math.sign(normalized) * rounded
}
function exactAmount(
mapping: PostMapping,
current: TrialBalanceRowLike[],
previous: TrialBalanceRowLike[] | null,
): ConceptAmount {
return {
current: sumForMapping(current, mapping),
previous: previous ? sumForMapping(previous, mapping) : null,
current: exactSumForMapping(current, mapping),
previous: previous ? exactSumForMapping(previous, mapping) : null,
}
}
function deviatingRowsTotal(
rows: TrialBalanceRowLike[],
rule: SignReclassification,
): number {
return sumOre(
rows
.filter((row) => inRanges(row.account_number, rule.ranges))
.map((row) => netBalance(row, rule.balance))
.filter((balance) => balance < 0),
)
}
function applySignReclassifications(
br: ConceptAmounts,
current: TrialBalanceRowLike[],
previous: TrialBalanceRowLike[] | null,
warnings: string[],
): void {
for (const rule of SIGN_RECLASSIFICATIONS) {
let reclassified = false
for (const field of ['current', 'previous'] as const) {
const rows = field === 'current' ? current : previous
if (!rows) continue
const deviatingBalance =
rule.mode === 'deviating_rows'
? deviatingRowsTotal(rows, rule)
: exactSumForMapping(rows, {
concept: rule.sourceConcept,
balance: rule.balance,
ranges: rule.ranges,
})
if (deviatingBalance >= 0) continue
const amountToMove = -deviatingBalance
adjustConcept(br, rule.sourceConcept, field, amountToMove)
adjustConcept(br, rule.targetConcept, field, amountToMove)
reclassified = true
}
if (reclassified) warnings.push(rule.warning)
}
}
function add(a: ConceptAmount, b: ConceptAmount, sign = 1): ConceptAmount {
return {
current: a.current + sign * b.current,
current: roundOre(a.current + sign * b.current),
previous:
a.previous === null && b.previous === null
? null
: (a.previous ?? 0) + sign * (b.previous ?? 0),
: roundOre((a.previous ?? 0) + sign * (b.previous ?? 0)),
}
}
@@ -486,13 +578,32 @@ export function mapTrialBalancesToK2(
): K2MappingResult {
const warnings: string[] = []
const rr: ConceptAmounts = {}
const rrExact: ConceptAmounts = {}
const br: ConceptAmounts = {}
const brExact: ConceptAmounts = {}
for (const mapping of K2_RR_MAPPINGS) {
rr[mapping.concept] = amount(mapping, current.preClosing, previous?.preClosing ?? null)
rrExact[mapping.concept] = exactAmount(
mapping,
current.preClosing,
previous?.preClosing ?? null,
)
const exact = rrExact[mapping.concept]
rr[mapping.concept] = {
current: roundWhole(exact.current),
previous: exact.previous === null ? null : roundWhole(exact.previous),
}
}
for (const mapping of K2_BR_MAPPINGS) {
br[mapping.concept] = amount(mapping, current.full, previous?.full ?? null)
brExact[mapping.concept] = exactAmount(mapping, current.full, previous?.full ?? null)
}
applySignReclassifications(brExact, current.full, previous?.full ?? null, warnings)
for (const mapping of K2_BR_MAPPINGS) {
const exact = brExact[mapping.concept]
br[mapping.concept] = {
current: roundWhole(exact.current),
previous: exact.previous === null ? null : roundWhole(exact.previous),
}
}
// Reclassification + unmapped sweep over balance-carrying accounts. Both TB
@@ -536,21 +647,20 @@ export function mapTrialBalancesToK2(
// tagged totals exactly (kontrollera 3005), so a ±1 kr residual is
// distributed back into a line item instead of tolerated. Deterministic
// rule, per year:
// - BR: the residual (Tillgångar − Eget kapital och skulder) is added to
// the largest post (by absolute value) on the equity/liabilities side,
// excluding AretsResultatEgetKapital, whose value must stay equal to
// the booked 2099 / RR result (ties broken toward the LATER post in
// the uppställningsform, so liabilities win over aktiekapital).
// - BR: each side is reconciled to its rounded exact total. The residual
// is assigned only to a post with an exact öre amount on that side.
// Exact whole-krona posts, such as a booked reserve, are never changed.
// - RR: the residual (RR-resultat − konto 2099) is absorbed by the
// largest RR post: cost posts are increased by the residual, income
// posts decreased (ties broken toward the EARLIER post).
// Residuals beyond ±1 kr are real bookkeeping errors and are left for the
// exact balance checks below.
// Multi-krona residuals are distributed over multiple fractional posts.
// A residual without enough fractional posts is left for the exact balance
// checks below instead of changing a booked whole-krona amount.
let smoothedAny = false
for (const field of ['current', 'previous'] as const) {
if (field === 'previous' && previous === null) continue
const rrSmoothed = smoothRrResidual(rr, br, totals, field)
const brSmoothed = smoothBrResidual(br, totals, field)
const rrSmoothed = smoothRrResidual(rr, rrExact, br, brExact, totals, field)
const brSmoothed = smoothBrResidual(br, brExact, totals, field)
smoothedAny = smoothedAny || rrSmoothed || brSmoothed
}
if (smoothedAny) totals = computeTotals(rr, br)
@@ -574,28 +684,6 @@ export function mapTrialBalancesToK2(
return { rr, br, totals, warnings, unmappedAccounts }
}
function pickLargestConcept(
amounts: ConceptAmounts,
mappings: PostMapping[],
field: 'current' | 'previous',
exclude: ReadonlySet<string>,
tieBreak: 'first' | 'last',
): string | null {
let best: string | null = null
let bestAbs = -1
for (const mapping of mappings) {
if (exclude.has(mapping.concept)) continue
const value = amounts[mapping.concept]?.[field]
if (value === null || value === undefined || value === 0) continue
const abs = Math.abs(value)
if (abs > bestAbs || (abs === bestAbs && tieBreak === 'last')) {
best = mapping.concept
bestAbs = abs
}
}
return best
}
function adjustConcept(
amounts: ConceptAmounts,
concept: string,
@@ -603,13 +691,15 @@ function adjustConcept(
delta: number,
): void {
const existing = amounts[concept] ?? { current: 0, previous: null }
amounts[concept] = { ...existing, [field]: (existing[field] ?? 0) + delta }
amounts[concept] = { ...existing, [field]: roundOre((existing[field] ?? 0) + delta) }
}
/** Absorb a ±1 kr rounding residual between the RR result and BR 2099. */
function smoothRrResidual(
rr: ConceptAmounts,
rrExact: ConceptAmounts,
br: ConceptAmounts,
brExact: ConceptAmounts,
totals: K2MappingResult['totals'],
field: 'current' | 'previous',
): boolean {
@@ -617,39 +707,94 @@ function smoothRrResidual(
const result = totals.aretsResultat[field]
if (target === null || target === undefined || result === null) return false
const diff = result - target
if (diff === 0 || Math.abs(diff) > 1) return false
const concept = pickLargestConcept(rr, K2_RR_MAPPINGS, field, new Set(), 'first')
if (!concept) return false
const balance = K2_RR_MAPPINGS.find((mapping) => mapping.concept === concept)?.balance
// Debit (cost) posts enter the result with weight −1, credit (income)
// posts with +1: adjust so the recomputed result lands on the 2099 value.
adjustConcept(rr, concept, field, balance === 'debit' ? diff : -diff)
if (diff === 0) return false
const exactResult = computeTotals(rrExact, brExact).aretsResultat[field]
const exactTarget = brExact['AretsResultatEgetKapital']?.[field]
if (exactResult === null || exactTarget === null || exactTarget === undefined) return false
if (!equalOre(exactResult, exactTarget)) return false
const direction = Math.sign(diff)
const candidates = K2_RR_MAPPINGS.flatMap((mapping, index) => {
const rounded = rr[mapping.concept]?.[field]
const exact = rrExact[mapping.concept]?.[field]
if (rounded === null || rounded === undefined || exact === null || exact === undefined) return []
if (Math.abs(exact - rounded) < 0.000001) return []
const delta = mapping.balance === 'debit' ? direction : -direction
return [{ concept: mapping.concept, delta, error: Math.abs(rounded + delta - exact), index }]
}).sort((left, right) => left.error - right.error || left.index - right.index)
if (candidates.length < Math.abs(diff)) return false
for (const candidate of candidates.slice(0, Math.abs(diff))) {
adjustConcept(rr, candidate.concept, field, candidate.delta)
}
return true
}
/** Equity/liability-side posts (everything from Aktiekapital onwards). */
const FIRST_EQ_LIAB_MAPPING_INDEX = K2_BR_MAPPINGS.findIndex(
(mapping) => mapping.concept === 'Aktiekapital',
)
const ASSET_MAPPINGS = K2_BR_MAPPINGS.slice(0, FIRST_EQ_LIAB_MAPPING_INDEX)
const EQ_LIAB_MAPPINGS = K2_BR_MAPPINGS.slice(
K2_BR_MAPPINGS.findIndex((mapping) => mapping.concept === 'Aktiekapital'),
FIRST_EQ_LIAB_MAPPING_INDEX,
)
/** Absorb a ±1 kr rounding residual between the two BR sides. */
/**
* Reconcile each BR side to its own rounded exact total without changing exact
* posts. Residuals must not be netted across sides: doing so could make the
* balance check pass while leaving one reported side different from its exact
* accounting total.
*/
function smoothBrResidual(
br: ConceptAmounts,
brExact: ConceptAmounts,
totals: K2MappingResult['totals'],
field: 'current' | 'previous',
): boolean {
const assets = totals.tillgangar[field]
const eqLiab = totals.egetKapitalSkulder[field]
if (assets === null || eqLiab === null) return false
const diff = assets - eqLiab
if (diff === 0 || Math.abs(diff) > 1) return false
const concept =
pickLargestConcept(br, EQ_LIAB_MAPPINGS, field, new Set(['AretsResultatEgetKapital']), 'last') ??
'BalanseratResultat'
// All equity/liability posts are credit-oriented: adding the residual
// raises the eget kapital och skulder side to match Tillgångar.
adjustConcept(br, concept, field, diff)
return true
const exactTotals = computeTotals({}, brExact)
const exactAssets = exactTotals.tillgangar[field]
const exactEqLiab = exactTotals.egetKapitalSkulder[field]
if (exactAssets === null || exactEqLiab === null) return false
if (!equalOre(exactAssets, exactEqLiab)) return false
const sides = [
{ mappings: ASSET_MAPPINGS, rounded: assets, target: roundWhole(exactAssets) },
{ mappings: EQ_LIAB_MAPPINGS, rounded: eqLiab, target: roundWhole(exactEqLiab) },
]
const residuals = sides.map((side) => side.target - side.rounded)
if (residuals.every((residual) => residual === 0)) return false
const plans = sides.map((side, index) => {
const residual = residuals[index]
if (residual === 0) return []
const direction = Math.sign(residual)
const candidates = side.mappings.flatMap((mapping, mappingIndex) => {
if (mapping.concept === 'AretsResultatEgetKapital') return []
const rounded = br[mapping.concept]?.[field]
const exact = brExact[mapping.concept]?.[field]
if (rounded === null || rounded === undefined || exact === null || exact === undefined) return []
if (Math.abs(exact - rounded) < 0.000001) return []
return [{
concept: mapping.concept,
error: Math.abs(rounded + direction - exact),
index: mappingIndex,
}]
}).sort((left, right) => left.error - right.error || left.index - right.index)
if (candidates.length < Math.abs(residual)) return null
return candidates.slice(0, Math.abs(residual)).map((candidate) => ({
concept: candidate.concept,
delta: direction,
}))
})
if (plans.some((plan) => plan === null)) return false
for (const plan of plans) {
for (const adjustment of plan ?? []) {
adjustConcept(br, adjustment.concept, field, adjustment.delta)
}
}
return plans.some((plan) => (plan?.length ?? 0) > 0)
}
function computeTotals(rr: ConceptAmounts, br: ConceptAmounts): K2MappingResult['totals'] {
@@ -431,6 +431,43 @@ describe('invoice email templates', () => {
expect(text).toMatch(/1[\s ]234,56 EUR/)
})
it('uses the matching EUR payment account in both email variants', () => {
const eurInvoice = makeInvoice({ invoice_number: '1042', currency: 'EUR', total: 1234.56 })
const multiCurrencyCompany = makeCompanySettings({
bank_name: 'Legacy SEK Bank',
clearing_number: '5037',
account_number: '1231231',
iban: 'SE0011111111111111111111',
bic: 'NDEASESS',
invoice_payment_accounts: {
EUR: {
bank_name: 'Mock ASPSP',
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: 'SE4550000000058398257466',
bic: 'ESSESESS',
},
},
})
const data = { invoice: eurInvoice, customer: svCustomer, company: multiCurrencyCompany }
const html = generateInvoiceEmailHtml(data)
const text = generateInvoiceEmailText(data)
for (const rendered of [html, text]) {
expect(rendered).toContain('Mock ASPSP')
expect(rendered).toContain('SE4550000000058398257466')
expect(rendered).toContain('ESSESESS')
expect(rendered).not.toContain('Legacy SEK Bank')
expect(rendered).not.toContain('5037-1231231')
expect(rendered).not.toContain('SE0011111111111111111111')
expect(rendered).not.toContain('NDEASESS')
}
})
it('subtracts the ROT/RUT deduction so the email states what the customer owes', () => {
const rotInvoice = makeInvoice({ invoice_number: '1042', total: 1234.56, deduction_total: 500 })
const html = generateInvoiceEmailHtml({ invoice: rotInvoice, customer: svCustomer, company })
+5 -2
View File
@@ -1,6 +1,7 @@
import type { Invoice, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import { formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils'
import { getAmountToPay } from '@/lib/invoices/rounding'
import { companyWithInvoicePaymentAccount } from '@/lib/invoices/payment-accounts'
import { applyPlaceholders, escapeHtml, sanitizeSubjectLine, userTextToHtml } from './user-text'
type EmailLang = 'sv' | 'en'
@@ -197,7 +198,8 @@ function safeBrandingColor(value: string | null | undefined, fallback: string):
* Generate HTML email for sending an invoice
*/
export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const { invoice, customer } = data
const company = companyWithInvoicePaymentAccount(data.company, invoice.currency)
const lang = resolveLang(customer)
const L = LABELS[lang]
@@ -353,7 +355,8 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
* Generate plain text email for sending an invoice
*/
export function generateInvoiceEmailText(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const { invoice, customer } = data
const company = companyWithInvoicePaymentAccount(data.company, invoice.currency)
const lang = resolveLang(customer)
const L = LABELS[lang]
+1
View File
@@ -9,6 +9,7 @@
export interface SendEmailOptions {
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
subject: string
html: string
text?: string
+16 -2
View File
@@ -885,11 +885,25 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_en: 'Customer has no email address.',
remediation: { description: 'Add an email address on the customer record before sending.' },
},
INVOICE_SEND_TOO_MANY_RECIPIENTS: {
httpStatus: 400,
message_sv: 'Ett fakturautskick får ha högst 20 mottagare totalt.',
message_en: 'An invoice email may have at most 20 recipients in total.',
remediation: { description: 'Remove CC or BCC recipients before sending the invoice.' },
},
INVOICE_SEND_COMPANY_SETTINGS_MISSING: {
httpStatus: 404,
message_sv: 'Företagsinställningar saknas.',
message_en: 'Company settings are missing.',
},
INVOICE_SEND_PAYMENT_ACCOUNT_MISSING: {
httpStatus: 400,
message_sv: 'Fakturan saknar ett betalningskonto för vald valuta. Lägg till kontot under Fakturering innan du skapar PDF-filen eller skickar fakturan.',
message_en: 'The invoice has no payment account for its currency. Add the account under Invoicing before generating the PDF or sending the invoice.',
remediation: {
description: 'Lägg till ett betalningskonto med IBAN för fakturans valuta under Fakturering.',
},
},
INVOICE_SEND_NUMBER_ASSIGN_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte tilldela fakturanummer.',
@@ -2848,8 +2862,8 @@ const BOLAGSVERKET: Record<string, StructuredErrorEntry> = {
},
ARSREDOVISNING_INCOMPLETE: {
httpStatus: 409,
message_sv: 'Årsredovisningen har blockerande kontrollfel och kan inte låsas ännu.',
message_en: 'The annual report has blocking validation errors and cannot be finalized yet.',
message_sv: 'Årsredovisningen har blockerande kontrollfel och kan inte versionssparas ännu.',
message_en: 'The annual report has blocking validation errors and cannot be versioned yet.',
retryable: false,
},
ARSREDOVISNING_VERSION_NOT_SIGNABLE: {
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import {
exceedsInvoiceEmailRecipientLimit,
findAdditionalInvoiceRecipientCollisions,
invoiceEmailRecipientCount,
parseInvoiceRecipientText,
resolveInvoiceEmailRecipients,
} from '@/lib/invoices/email-recipients'
describe('resolveInvoiceEmailRecipients', () => {
it('uses the legacy copy only while the company list is unconfigured', () => {
expect(resolveInvoiceEmailRecipients({
to: 'customer@example.test',
configuredCc: null,
legacyCc: 'billing@example.test',
}).cc).toEqual(['billing@example.test'])
expect(resolveInvoiceEmailRecipients({
to: 'customer@example.test',
configuredCc: [],
legacyCc: 'billing@example.test',
}).cc).toEqual([])
})
it('merges fixed and per-send recipients with deterministic precedence', () => {
expect(resolveInvoiceEmailRecipients({
to: 'customer@example.test',
configuredCc: ['finance@example.test', 'CUSTOMER@example.test'],
configuredBcc: ['archive@example.test', 'finance@example.test'],
additionalCc: ['handler@example.test', 'Finance@example.test'],
additionalBcc: ['director@example.test', 'archive@example.test'],
})).toEqual({
to: ['customer@example.test'],
cc: ['finance@example.test', 'handler@example.test'],
bcc: ['archive@example.test', 'director@example.test'],
})
})
it('counts the final de-duplicated To, CC, and BCC recipients', () => {
const atLimit = resolveInvoiceEmailRecipients({
to: 'customer@example.test',
configuredCc: Array.from({ length: 19 }, (_, index) => `copy-${index}@example.test`),
})
expect(invoiceEmailRecipientCount(atLimit)).toBe(20)
expect(exceedsInvoiceEmailRecipientLimit(atLimit)).toBe(false)
const overLimit = resolveInvoiceEmailRecipients({
to: 'customer@example.test',
configuredCc: atLimit.cc,
additionalBcc: ['archive@example.test'],
})
expect(invoiceEmailRecipientCount(overLimit)).toBe(21)
expect(exceedsInvoiceEmailRecipientLimit(overLimit)).toBe(true)
})
it('trims and de-duplicates address text', () => {
expect(parseInvoiceRecipientText(
' finance@example.test,\nDIRECTOR@example.test; finance@example.test ',
)).toEqual(['finance@example.test', 'DIRECTOR@example.test'])
})
it('reports per-send collisions instead of silently changing recipient precedence', () => {
expect(findAdditionalInvoiceRecipientCollisions({
to: 'customer@example.test',
configuredCc: ['finance@example.test'],
configuredBcc: ['archive@example.test'],
additionalCc: ['CUSTOMER@example.test', 'case-owner@example.test'],
additionalBcc: ['finance@example.test', 'case-owner@example.test'],
})).toEqual([
{
address: 'CUSTOMER@example.test',
field: 'additional_cc',
conflicts_with: 'to',
},
{
address: 'finance@example.test',
field: 'additional_bcc',
conflicts_with: 'configured_cc',
},
{
address: 'case-owner@example.test',
field: 'additional_bcc',
conflicts_with: 'additional_cc',
},
])
})
})
@@ -1,8 +1,33 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import type { PoolClient } from 'pg'
import { getPool, withUserContext } from '@/tests/pg/setup'
import { insertAuthUser, insertCompanyMember, seedCompany } from '@/tests/pg/fixtures'
async function withServiceRoleContext<T>(
userId: string,
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
JSON.stringify({ sub: userId, role: 'service_role' }),
])
await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId])
await client.query(`SELECT set_config('request.jwt.claim.role', 'service_role', true)`)
await client.query(`SET LOCAL ROLE service_role`)
const result = await fn(client)
await client.query('ROLLBACK')
return result
} catch (error) {
await client.query('ROLLBACK').catch(() => {})
throw error
} finally {
client.release()
}
}
async function insertInvoice(userId: string, companyId: string): Promise<string> {
const customerId = randomUUID()
const invoiceId = randomUUID()
@@ -68,11 +93,11 @@ async function insertPendingEmailDelivery(params: {
await getPool().query(
`INSERT INTO public.invoice_deliveries
(id, user_id, company_id, invoice_id, channel, status,
to_addresses, cc_addresses, reply_to, from_name, subject,
to_addresses, cc_addresses, bcc_addresses, reply_to, from_name, subject,
body_text, body_html, document_attachment_id, attachment_filename,
attachment_content_type, attachment_sha256, retention_expires_at)
VALUES ($1, $2, $3, $4, 'email', 'pending',
ARRAY['customer@example.com'], ARRAY['copy@example.com'],
ARRAY['customer@example.com'], ARRAY['copy@example.com'], ARRAY['archive@example.com'],
'sender@example.com', 'Example AB', 'Faktura F-1001',
'Exact plain text', '<p>Exact HTML</p>', $5,
'invoice.pdf', 'application/pdf', $6, $7)`,
@@ -155,6 +180,16 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
[deliveryId],
),
).rejects.toThrow(/invoice delivery payload is immutable/i)
await expect(
getPool().query(
`UPDATE public.invoice_deliveries
SET status = 'sent', sent_at = now(),
bcc_addresses = ARRAY['changed@example.com']
WHERE id = $1`,
[deliveryId],
),
).rejects.toThrow(/invoice delivery payload is immutable/i)
})
it('rejects invoice and document references from another company', async () => {
@@ -226,6 +261,119 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
expect(visibleIds).toEqual([deliveryA])
})
it('keeps exact payload sender-only and exposes masked summaries to members', async () => {
const { userId, companyId } = await seedCompany()
const memberId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const deliveryId = await insertPendingEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await getPool().query(
`UPDATE public.invoice_deliveries SET status = 'sent', sent_at = now() WHERE id = $1`,
[deliveryId],
)
const directRows = await withUserContext(memberId, async (client) => {
return client.query(
`SELECT id, bcc_addresses, body_text
FROM public.invoice_deliveries
WHERE id = $1`,
[deliveryId],
)
})
expect(directRows.rowCount).toBe(0)
const summary = await withUserContext(memberId, async (client) => {
return client.query<Record<string, unknown>>(
`SELECT *
FROM public.list_invoice_delivery_summaries($1, $2)`,
[companyId, invoiceId],
)
})
expect(summary.rows).toEqual([
expect.objectContaining({
id: deliveryId,
to_addresses: ['***@example.com'],
cc_addresses: ['***@example.com'],
}),
])
expect(summary.rows[0]).not.toHaveProperty('bcc_addresses')
expect(summary.rows[0]).not.toHaveProperty('body_text')
const documentLookup = await withUserContext(memberId, (client) => client.query<{ id: string }>(
`SELECT public.latest_sent_invoice_delivery_document($1, $2)::text AS id`,
[companyId, invoiceId],
))
expect(documentLookup.rows[0].id).toBe(documentId)
const other = await seedCompany()
const otherInvoiceId = await insertInvoice(other.userId, other.companyId)
const otherDocumentId = await insertDocument(other.userId, other.companyId)
const otherDeliveryId = await insertPendingEmailDelivery({
userId: other.userId,
companyId: other.companyId,
invoiceId: otherInvoiceId,
documentId: otherDocumentId,
})
await getPool().query(
`UPDATE public.invoice_deliveries SET status = 'sent', sent_at = now() WHERE id = $1`,
[otherDeliveryId],
)
const mismatchedInvoiceLookup = await withUserContext(memberId, (client) =>
client.query<{ id: string | null }>(
`SELECT public.latest_sent_invoice_delivery_document($1, $2)::text AS id`,
[companyId, otherInvoiceId],
),
)
expect(mismatchedInvoiceLookup.rows[0].id).toBeNull()
await expect(
withUserContext(memberId, (client) => client.query(
`SELECT public.latest_sent_invoice_delivery_document($1, $2)`,
[other.companyId, otherInvoiceId],
)),
).rejects.toThrow(/not authorized to find delivered invoice document/i)
await expect(
withUserContext(memberId, (client) => client.query(
`SELECT id FROM public.export_invoice_delivery_evidence($1)`,
[companyId],
)),
).rejects.toThrow(/owner or admin role required/i)
const ownerExport = await withUserContext(userId, (client) => client.query<{
id: string
bcc_addresses: string[]
}>(
`SELECT id, bcc_addresses
FROM public.export_invoice_delivery_evidence($1)
WHERE id = $2`,
[companyId, deliveryId],
))
expect(ownerExport.rows[0]).toEqual({
id: deliveryId,
bcc_addresses: ['archive@example.com'],
})
const senderPayload = await withUserContext(userId, async (client) => {
return client.query<{ bcc_addresses: string[]; body_text: string }>(
`SELECT bcc_addresses, body_text
FROM public.invoice_deliveries
WHERE id = $1`,
[deliveryId],
)
})
expect(senderPayload.rows[0]).toEqual({
bcc_addresses: ['archive@example.com'],
body_text: 'Exact plain text',
})
})
it('denies inserts to a viewer', async () => {
const { userId, companyId } = await seedCompany()
const viewerId = await insertAuthUser()
@@ -244,6 +392,173 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
).rejects.toThrow(/row-level security|policy/i)
})
it('denies direct delivery writes and RPC execution to authenticated members', async () => {
const { userId, companyId } = await seedCompany()
const memberId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const deliveryId = await insertPendingEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await expect(
withUserContext(memberId, (client) => client.query(
`INSERT INTO public.invoice_deliveries
(user_id, company_id, invoice_id, channel, status, sent_at)
VALUES ($1, $2, $3, 'manual', 'marked_sent', now())`,
[memberId, companyId, invoiceId],
)),
).rejects.toThrow(/row-level security|policy/i)
const directUpdate = await withUserContext(userId, (client) => client.query(
`UPDATE public.invoice_deliveries
SET status = 'sent', sent_at = now()
WHERE id = $1`,
[deliveryId],
))
expect(directUpdate.rowCount).toBe(0)
await expect(
withUserContext(memberId, (client) => client.query(
`SELECT public.reserve_invoice_delivery($1, $2, $3)`,
[companyId, invoiceId, userId],
)),
).rejects.toThrow(/permission denied/i)
})
it('uses server-only RPCs for reservation, payload capture, and finalization', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
await withServiceRoleContext(userId, async (client) => {
const reserved = await client.query<{ id: string }>(
`SELECT public.reserve_invoice_delivery($1, $2, $3)::text AS id`,
[companyId, invoiceId, userId],
)
const deliveryId = reserved.rows[0].id
const reused = await client.query<{ id: string }>(
`SELECT public.reserve_invoice_delivery($1, $2, $3)::text AS id`,
[companyId, invoiceId, userId],
)
expect(reused.rows[0].id).toBe(deliveryId)
const captured = await client.query<{ id: string }>(
`SELECT public.capture_invoice_delivery_payload(
$1, $2, $3, $4,
ARRAY['customer@example.com'], ARRAY['copy@example.com'], ARRAY['archive@example.com'],
'sender@example.com', 'Example AB', 'Faktura F-1001',
'Exact plain text', '<p>Exact HTML</p>', $5,
'invoice.pdf', 'application/pdf', $6
)::text AS id`,
[deliveryId, companyId, invoiceId, userId, documentId, 'a'.repeat(64)],
)
expect(captured.rows[0].id).toBe(deliveryId)
const finalized = await client.query<{ id: string }>(
`SELECT public.finalize_invoice_delivery(
$1, $2, $3, 'sent', 'resend', 'provider-message-1', NULL
)::text AS id`,
[deliveryId, companyId, userId],
)
expect(finalized.rows[0].id).toBe(deliveryId)
const row = await client.query(
`SELECT status, bcc_addresses, body_text
FROM public.invoice_deliveries
WHERE id = $1`,
[deliveryId],
)
expect(row.rows[0]).toMatchObject({
status: 'sent',
bcc_addresses: ['archive@example.com'],
body_text: 'Exact plain text',
})
})
})
it('rejects service-role delivery writes for a non-member or mismatched tenant', async () => {
const first = await seedCompany()
const second = await seedCompany()
const outsiderId = await insertAuthUser()
const invoiceId = await insertInvoice(first.userId, first.companyId)
await expect(
withServiceRoleContext(outsiderId, (client) => client.query(
`SELECT public.reserve_invoice_delivery($1, $2, $3)`,
[first.companyId, invoiceId, outsiderId],
)),
).rejects.toThrow(/writable company member/i)
await expect(
withServiceRoleContext(second.userId, (client) => client.query(
`SELECT public.reserve_invoice_delivery($1, $2, $3)`,
[second.companyId, invoiceId, second.userId],
)),
).rejects.toThrow(/invoice not found/i)
})
it('reclaims only stale payload-free reservations for another sender', async () => {
const { userId, companyId } = await seedCompany()
const otherUserId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: otherUserId, role: 'admin' })
const invoiceId = await insertInvoice(userId, companyId)
const staleId = randomUUID()
await getPool().query(
`INSERT INTO public.invoice_deliveries
(id, user_id, company_id, invoice_id, channel, status, created_at)
VALUES ($1, $2, $3, $4, 'email', 'preparing', now() - interval '16 minutes')`,
[staleId, userId, companyId, invoiceId],
)
await withServiceRoleContext(otherUserId, async (client) => {
const result = await client.query<{ id: string }>(
`SELECT public.reserve_invoice_delivery($1, $2, $3)::text AS id`,
[companyId, invoiceId, otherUserId],
)
expect(result.rows[0].id).not.toBe(staleId)
const stale = await client.query(
`SELECT id FROM public.invoice_deliveries WHERE id = $1`,
[staleId],
)
expect(stale.rowCount).toBe(0)
})
})
it('restricts fixed invoice email recipient settings to owners and admins', async () => {
const { userId, companyId } = await seedCompany()
const memberId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
await getPool().query(
`INSERT INTO public.company_settings (user_id, company_id)
VALUES ($1, $2)`,
[userId, companyId],
)
const memberUpdate = await withUserContext(memberId, (client) => client.query(
`UPDATE public.company_settings
SET invoice_email_bcc_addresses = ARRAY['archive@example.com']
WHERE company_id = $1`,
[companyId],
))
expect(memberUpdate.rowCount).toBe(0)
const ownerUpdate = await withUserContext(userId, (client) => client.query(
`UPDATE public.company_settings
SET invoice_email_bcc_addresses = ARRAY['archive@example.com']
WHERE company_id = $1`,
[companyId],
))
expect(ownerUpdate.rowCount).toBe(1)
})
it('reserves one preparing attempt and promotes it to the exact pending payload', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
@@ -288,7 +603,29 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
[deliveryId],
)
expect(result.rows[0].status).toBe('pending')
expect(result.rows[0].retention_expires_at).toBeTruthy()
expect(new Date(result.rows[0].retention_expires_at).toISOString().slice(0, 10)).toBe(
'2034-01-01',
)
const nextReservationId = await withServiceRoleContext(userId, async (client) => {
const reservation = await client.query<{ id: string }>(
`SELECT public.reserve_invoice_delivery($1, $2, $3)::text AS id`,
[companyId, invoiceId, userId],
)
const states = await client.query<{ id: string; status: string }>(
`SELECT id, status
FROM public.invoice_deliveries
WHERE company_id = $1 AND invoice_id = $2
ORDER BY created_at`,
[companyId, invoiceId],
)
expect(states.rows).toEqual(expect.arrayContaining([
{ id: deliveryId, status: 'pending' },
{ id: reservation.rows[0].id, status: 'preparing' },
]))
return reservation.rows[0].id
})
expect(nextReservationId).not.toBe(deliveryId)
})
it('allows a failed attempt to release and delete its unsent PDF', async () => {
@@ -340,7 +677,7 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
await getPool().query(`SELECT public.redact_expired_invoice_delivery_pii()`)
const delivery = await getPool().query(
`SELECT to_addresses, body_text, subject, provider_message_id,
`SELECT to_addresses, cc_addresses, bcc_addresses, body_text, subject, provider_message_id,
attachment_filename, attachment_sha256, pii_redacted_at
FROM public.invoice_deliveries
WHERE id = $1`,
@@ -348,6 +685,8 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
)
expect(delivery.rows[0]).toMatchObject({
to_addresses: [],
cc_addresses: [],
bcc_addresses: [],
body_text: null,
subject: null,
provider_message_id: null,
+109 -88
View File
@@ -4,10 +4,14 @@ import type { EmailService } from '@/lib/email/service'
const mockUploadDocument = vi.fn()
const mockDeleteDocument = vi.fn()
const mockCreateServiceClient = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
deleteDocument: (...args: unknown[]) => mockDeleteDocument(...args),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => mockCreateServiceClient(),
}))
import {
InvoiceDeliverySnapshotError,
@@ -17,63 +21,46 @@ import {
} from '../invoice-deliveries'
function makeSupabase(options?: {
insertData?: Record<string, unknown> | null
insertError?: { message: string; code?: string } | null
existingData?: Record<string, unknown> | null
snapshotData?: Record<string, unknown> | null
reserveData?: string | null
reserveError?: { message: string } | null
snapshotData?: string | null
snapshotError?: { message: string } | null
terminalData?: string | null
terminalError?: { message: string } | null
manualData?: Record<string, unknown> | null
manualError?: { message: string } | null
}) {
const insertResult = {
data: options?.insertData === undefined ? { id: 'delivery-1' } : options.insertData,
error: options?.insertError ?? null,
}
const updateResults = [
{
data: options?.snapshotData === undefined ? { id: 'delivery-1' } : options.snapshotData,
error: options?.snapshotError ?? null,
},
{ data: null, error: options?.terminalError ?? null },
]
const insertSpy = vi.fn(() => ({
select: vi.fn(() => ({
single: vi.fn().mockResolvedValue(insertResult),
})),
}))
const updateSpy = vi.fn(() => {
const result = updateResults.shift() ?? { data: null, error: null }
const chain: Record<string, unknown> & {
eq: ReturnType<typeof vi.fn>
select: ReturnType<typeof vi.fn>
single: ReturnType<typeof vi.fn>
then: (resolve: (value: typeof result) => void) => void
} = {
eq: vi.fn(),
select: vi.fn(),
single: vi.fn().mockResolvedValue(result),
then: (resolve) => resolve(result),
const rpcSpy = vi.fn((name: string) => {
if (name === 'reserve_invoice_delivery') {
return Promise.resolve({
data: options?.reserveData === undefined ? 'delivery-1' : options.reserveData,
error: options?.reserveError ?? null,
})
}
chain.eq.mockReturnValue(chain)
chain.select.mockReturnValue(chain)
return chain
if (name === 'capture_invoice_delivery_payload') {
return Promise.resolve({
data: options?.snapshotData === undefined ? 'delivery-1' : options.snapshotData,
error: options?.snapshotError ?? null,
})
}
if (name === 'finalize_invoice_delivery') {
return Promise.resolve({
data: options?.terminalData === undefined ? 'delivery-1' : options.terminalData,
error: options?.terminalError ?? null,
})
}
return Promise.resolve({
data: options?.manualData === undefined
? { id: 'delivery-1', channel: 'manual', status: 'marked_sent' }
: options.manualData,
error: options?.manualError ?? null,
})
})
const existingResult = { data: options?.existingData ?? null, error: null }
const selectChain: Record<string, unknown> & {
eq: ReturnType<typeof vi.fn>
maybeSingle: ReturnType<typeof vi.fn>
} = {
eq: vi.fn(),
maybeSingle: vi.fn().mockResolvedValue(existingResult),
}
selectChain.eq.mockReturnValue(selectChain)
const selectSpy = vi.fn(() => selectChain)
const from = vi.fn(() => ({ insert: insertSpy, update: updateSpy, select: selectSpy }))
mockCreateServiceClient.mockReturnValue({ rpc: rpcSpy })
return {
supabase: { from } as unknown as SupabaseClient,
insertSpy,
updateSpy,
supabase: {} as SupabaseClient,
rpcSpy,
}
}
@@ -87,6 +74,7 @@ function makeInput(supabase: SupabaseClient, emailService: EmailService) {
deliveryId: 'delivery-1',
to: 'customer@example.com',
cc: ['accounting@example.com'],
bcc: ['archive@example.com'],
replyTo: 'sender@example.com',
fromName: 'Example AB',
subject: 'Faktura F-1001',
@@ -97,7 +85,7 @@ function makeInput(supabase: SupabaseClient, emailService: EmailService) {
}
}
function makeEmailService(sendEmail: ReturnType<typeof vi.fn>): EmailService {
function makeEmailService(sendEmail: EmailService['sendEmail']): EmailService {
return { isConfigured: () => true, sendEmail }
}
@@ -112,7 +100,7 @@ describe('invoice delivery tracking', () => {
})
it('persists the exact payload before sending and records provider success', async () => {
const { supabase, updateSpy } = makeSupabase()
const { supabase, rpcSpy } = makeSupabase()
const sendEmail = vi.fn().mockResolvedValue({
success: true,
provider: 'resend',
@@ -123,31 +111,40 @@ describe('invoice delivery tracking', () => {
makeInput(supabase, makeEmailService(sendEmail)),
)
expect(updateSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({
status: 'pending',
to_addresses: ['customer@example.com'],
cc_addresses: ['accounting@example.com'],
subject: 'Faktura F-1001',
body_text: 'Hej!',
body_html: '<p>Hej!</p>',
document_attachment_id: 'document-1',
attachment_filename: 'faktura-f-1001.pdf',
attachment_sha256: 'sha256-exact-pdf',
}))
expect(rpcSpy).toHaveBeenNthCalledWith(
1,
'capture_invoice_delivery_payload',
expect.objectContaining({
p_to_addresses: ['customer@example.com'],
p_cc_addresses: ['accounting@example.com'],
p_bcc_addresses: ['archive@example.com'],
p_subject: 'Faktura F-1001',
p_body_text: 'Hej!',
p_body_html: '<p>Hej!</p>',
p_document_attachment_id: 'document-1',
p_attachment_filename: 'faktura-f-1001.pdf',
p_attachment_sha256: 'sha256-exact-pdf',
}),
)
expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({
subject: 'Faktura F-1001',
text: 'Hej!',
html: '<p>Hej!</p>',
bcc: ['archive@example.com'],
attachments: [expect.objectContaining({
filename: 'faktura-f-1001.pdf',
content: Buffer.from('exact-pdf'),
})],
}))
expect(updateSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({
status: 'sent',
provider: 'resend',
provider_message_id: 'provider-message-1',
}))
expect(rpcSpy).toHaveBeenNthCalledWith(
2,
'finalize_invoice_delivery',
expect.objectContaining({
p_status: 'sent',
p_provider: 'resend',
p_provider_message_id: 'provider-message-1',
}),
)
expect(result).toMatchObject({
success: true,
deliveryId: 'delivery-1',
@@ -174,7 +171,7 @@ describe('invoice delivery tracking', () => {
})
it('records a failed provider attempt without changing the saved payload', async () => {
const { supabase, updateSpy } = makeSupabase()
const { supabase, rpcSpy } = makeSupabase()
const sendEmail = vi.fn().mockResolvedValue({
success: false,
provider: 'resend',
@@ -186,13 +183,15 @@ describe('invoice delivery tracking', () => {
makeInput(supabase, makeEmailService(sendEmail)),
)
expect(updateSpy).toHaveBeenCalledWith(expect.objectContaining({
status: 'failed',
provider: 'resend',
provider_message_id: null,
error_code: 'provider_failed',
document_attachment_id: null,
}))
expect(rpcSpy).toHaveBeenCalledWith(
'finalize_invoice_delivery',
expect.objectContaining({
p_status: 'failed',
p_provider: 'resend',
p_provider_message_id: null,
p_error_code: 'provider_failed',
}),
)
expect(mockDeleteDocument).toHaveBeenCalledWith(supabase, 'company-1', 'document-1')
expect(result.success).toBe(false)
})
@@ -208,11 +207,35 @@ describe('invoice delivery tracking', () => {
expect(result.trackingWarning).toBe('finalize_failed')
})
it('reuses an existing preparing reservation after a unique conflict', async () => {
it('treats an unexpected terminal delivery id as a finalize failure', async () => {
const { supabase } = makeSupabase({ terminalData: 'delivery-other' })
const sendEmail = vi.fn().mockResolvedValue({ success: true })
const result = await sendTrackedInvoiceEmail(
makeInput(supabase, makeEmailService(sendEmail)),
)
expect(result.trackingWarning).toBe('finalize_failed')
})
it('does not delete the archived PDF when the failed terminal id is unexpected', async () => {
const { supabase } = makeSupabase({ terminalData: 'delivery-other' })
const sendEmail = vi.fn().mockResolvedValue({
success: false,
error: 'provider rejected the request',
})
const result = await sendTrackedInvoiceEmail(
makeInput(supabase, makeEmailService(sendEmail)),
)
expect(result.trackingWarning).toBe('failure_record_failed')
expect(mockDeleteDocument).not.toHaveBeenCalled()
})
it('returns the reservation selected by the privileged RPC', async () => {
const { supabase } = makeSupabase({
insertData: null,
insertError: { message: 'duplicate', code: '23505' },
existingData: { id: 'delivery-existing' },
reserveData: 'delivery-existing',
})
await expect(reserveInvoiceDelivery({
@@ -229,7 +252,7 @@ describe('invoice delivery tracking', () => {
channel: 'manual',
status: 'marked_sent',
}
const { supabase, insertSpy } = makeSupabase({ insertData: manualDelivery })
const { supabase, rpcSpy } = makeSupabase({ manualData: manualDelivery })
await recordManualInvoiceDelivery({
supabase,
@@ -239,13 +262,11 @@ describe('invoice delivery tracking', () => {
sentAt: '2026-07-22T10:30:00.000Z',
})
expect(insertSpy).toHaveBeenCalledWith({
company_id: 'company-1',
user_id: 'user-1',
invoice_id: 'invoice-1',
channel: 'manual',
status: 'marked_sent',
sent_at: '2026-07-22T10:30:00.000Z',
expect(rpcSpy).toHaveBeenCalledWith('record_manual_invoice_delivery', {
p_company_id: 'company-1',
p_actor_user_id: 'user-1',
p_invoice_id: 'invoice-1',
p_sent_at: '2026-07-22T10:30:00.000Z',
})
})
})
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import {
InvoicePaymentAccountMissingError,
assertInvoicePaymentAccountForRender,
companyWithInvoicePaymentAccount,
hasRequiredInvoicePaymentAccount,
hasUsableInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
resolveInvoicePaymentAccount,
} from '@/lib/invoices/payment-accounts'
import { makeInvoice } from '@/tests/helpers'
import type { CompanySettings } from '@/types'
function company(overrides: Partial<CompanySettings> = {}): CompanySettings {
return {
bank_name: 'Legacy bank',
clearing_number: '1234',
account_number: '1234567',
bankgiro: '123-4567',
plusgiro: null,
swish: null,
iban: 'SE0011111111111111111111',
bic: 'LEGASESS',
...overrides,
} as CompanySettings
}
describe('invoice payment accounts', () => {
it('uses legacy payment details for SEK only', () => {
const settings = company()
expect(resolveInvoicePaymentAccount(settings, 'SEK')?.iban).toBe('SE0011111111111111111111')
expect(resolveInvoicePaymentAccount(settings, 'EUR')).toBeNull()
})
it('selects the account matching the invoice currency', () => {
const settings = company({
invoice_payment_accounts: {
EUR: {
bank_name: 'EUR bank',
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: 'SE0022222222222222222222',
bic: 'EURRSESS',
},
},
})
const rendered = companyWithInvoicePaymentAccount(settings, 'EUR')
expect(rendered.bank_name).toBe('EUR bank')
expect(rendered.iban).toBe('SE0022222222222222222222')
expect(rendered.bankgiro).toBeNull()
})
it('clears legacy SEK details when a foreign account is missing', () => {
const rendered = companyWithInvoicePaymentAccount(company(), 'EUR')
expect(rendered.iban).toBeNull()
expect(rendered.bankgiro).toBeNull()
})
it('requires an IBAN for a foreign-currency payment account', () => {
const withoutIban = resolveInvoicePaymentAccount(company({
invoice_payment_accounts: {
EUR: {
bank_name: 'EUR bank',
clearing_number: '1234',
account_number: '1234567',
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
bic: null,
},
},
}), 'EUR')
expect(hasUsableInvoicePaymentAccount(withoutIban, 'EUR')).toBe(false)
})
it('blocks payable rendering in every currency without a usable account', () => {
const emptySettings = company({
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
})
expect(() => assertInvoicePaymentAccountForRender(company(), 'EUR')).toThrow(
InvoicePaymentAccountMissingError,
)
expect(() => assertInvoicePaymentAccountForRender(emptySettings, 'SEK')).toThrow(
InvoicePaymentAccountMissingError,
)
expect(() => assertInvoicePaymentAccountForRender(company(), 'SEK')).not.toThrow()
})
it('requires payment accounts only for payable invoice documents', () => {
expect(invoiceRequiresPaymentAccount(makeInvoice())).toBe(true)
expect(invoiceRequiresPaymentAccount(makeInvoice({ credited_invoice_id: 'invoice-original' }))).toBe(false)
expect(invoiceRequiresPaymentAccount(makeInvoice({ document_type: 'delivery_note' }))).toBe(false)
expect(invoiceRequiresPaymentAccount(makeInvoice({ document_type: 'proforma' }))).toBe(false)
})
it('accepts non-payable documents without an account', () => {
const emptySettings = company({
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
})
expect(hasRequiredInvoicePaymentAccount(
emptySettings,
makeInvoice({ document_type: 'proforma' }),
)).toBe(true)
expect(hasRequiredInvoicePaymentAccount(emptySettings, makeInvoice())).toBe(false)
})
})
@@ -66,6 +66,55 @@ describe('prepareInvoicePdfRender: logo resolution (issue #772)', () => {
vi.restoreAllMocks()
})
it('renders only the payment account matching the invoice currency', async () => {
const company = makeCompanySettings({
iban: 'SE0011111111111111111111',
invoice_payment_accounts: {
EUR: {
bank_name: 'EUR Bank',
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: 'SE0022222222222222222222',
bic: 'EURRSESS',
},
},
})
const { company: resolved } = await prepareInvoicePdfRender(company, 'EUR')
expect(resolved.bank_name).toBe('EUR Bank')
expect(resolved.iban).toBe('SE0022222222222222222222')
expect(resolved.bankgiro).toBeNull()
})
it('rejects a foreign-currency PDF without a usable payment account', async () => {
const company = makeCompanySettings({ invoice_payment_accounts: {} })
await expect(prepareInvoicePdfRender(company, 'EUR')).rejects.toMatchObject({
code: 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING',
currency: 'EUR',
})
})
it('renders non-payable foreign documents without requiring an account or leaking SEK details', async () => {
const company = makeCompanySettings({
iban: 'SE0011111111111111111111',
invoice_payment_accounts: {},
})
const { company: resolved } = await prepareInvoicePdfRender(
company,
'EUR',
{ paymentAccountRequired: false },
)
expect(resolved.iban).toBeNull()
expect(resolved.bankgiro).toBeNull()
})
it('embeds an SVG logo as a PNG data URL so @react-pdf can draw it', async () => {
const fetchMock = mockFetchOnce(SVG_LOGO, 'image/svg+xml')
const company = makeCompanySettings({
@@ -49,6 +49,7 @@ const mockSendTrackedInvoiceEmail = vi.fn(async (input: {
emailService: { sendEmail: (options: unknown) => Promise<Record<string, unknown>> }
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
subject: string
html: string
text: string
@@ -60,6 +61,7 @@ const mockSendTrackedInvoiceEmail = vi.fn(async (input: {
...(await input.emailService.sendEmail({
to: input.to,
cc: input.cc,
bcc: input.bcc,
subject: input.subject,
html: input.html,
text: input.text,
@@ -220,6 +222,9 @@ describe('executeRecurringSchedule auto-send', () => {
const company = makeCompanySettings({
company_name: 'Oppy Sverige',
accounting_method: 'accrual',
bankgiro: '123-4567',
invoice_email_cc_addresses: ['fixed-copy@test.se'],
invoice_email_bcc_addresses: ['fixed-archive@test.se'],
})
function makeSchedule() {
@@ -329,7 +334,12 @@ describe('executeRecurringSchedule auto-send', () => {
expect(result.autoSent).toBe(true)
expect(result.warning).toBeNull()
expect(mockSendTrackedInvoiceEmail).toHaveBeenCalledWith(
expect.objectContaining({ companyId: 'company-1', invoiceId: 'inv-1' }),
expect.objectContaining({
companyId: 'company-1',
invoiceId: 'inv-1',
cc: ['fixed-copy@test.se'],
bcc: ['fixed-archive@test.se'],
}),
)
expect(mockApplyPaymentLink).toHaveBeenCalledTimes(1)
expect(mockApplyPaymentLink).toHaveBeenCalledWith(
@@ -368,6 +378,50 @@ describe('executeRecurringSchedule auto-send', () => {
expect(mockSendEmail).toHaveBeenCalledTimes(1)
})
it('does not reserve a delivery when the customer email is blank', async () => {
const customerWithoutEmail = { ...customer, email: ' ' }
enqueue({ data: customerWithoutEmail, error: null })
enqueue({ data: makeInsertedInvoice(), error: null })
enqueue({ data: null, error: null })
enqueue({
data: { ...makeCompleteInvoice(), customer: customerWithoutEmail },
error: null,
})
const result = await executeRecurringSchedule(client, makeSchedule(), today)
expect(result.autoSent).toBe(false)
expect(result.warning).toContain('Auto-utskick misslyckades')
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('does not reserve an auto-send delivery when configured recipients exceed the limit', async () => {
enqueue({ data: customer, error: null })
enqueue({ data: makeInsertedInvoice(), error: null })
enqueue({ data: null, error: null })
enqueue({ data: makeCompleteInvoice(), error: null })
enqueue({
data: {
...company,
invoice_email_cc_addresses: Array.from(
{ length: 20 },
(_, index) => `fixed-${index}@example.test`,
),
invoice_email_bcc_addresses: [],
},
error: null,
})
const result = await executeRecurringSchedule(client, makeSchedule(), today)
expect(result.autoSent).toBe(false)
expect(result.warning).not.toBeNull()
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockRenderToBuffer).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('never auto-sends from a sandbox company; invoice stays a numbered draft', async () => {
mockIsSandbox.mockResolvedValue(true)
// Sandbox bails before company_settings/payment-link/render/email, so the
+155
View File
@@ -0,0 +1,155 @@
export const MAX_INVOICE_EMAIL_RECIPIENTS = 20
export const MAX_INVOICE_EMAIL_COPY_RECIPIENTS = MAX_INVOICE_EMAIL_RECIPIENTS - 1
export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export interface ResolveInvoiceEmailRecipientsInput {
to: string | readonly string[]
configuredCc?: readonly string[] | null
configuredBcc?: readonly string[] | null
legacyCc?: string | null
additionalCc?: readonly string[]
additionalBcc?: readonly string[]
}
export interface ResolvedInvoiceEmailRecipients {
to: string[]
cc: string[]
bcc: string[]
}
export function invoiceEmailRecipientCount(
recipients: ResolvedInvoiceEmailRecipients,
): number {
return recipients.to.length + recipients.cc.length + recipients.bcc.length
}
export function exceedsInvoiceEmailRecipientLimit(
recipients: ResolvedInvoiceEmailRecipients,
): boolean {
return invoiceEmailRecipientCount(recipients) > MAX_INVOICE_EMAIL_RECIPIENTS
}
export interface InvoiceEmailRecipientCollision {
address: string
field: 'additional_cc' | 'additional_bcc'
conflicts_with:
| 'to'
| 'configured_cc'
| 'configured_bcc'
| 'additional_cc'
| 'additional_bcc'
}
function normalizedKey(address: string): string {
return address.trim().toLocaleLowerCase('en-US')
}
function uniqueAddresses(
addresses: readonly string[],
used: Set<string>,
): string[] {
const result: string[] = []
for (const rawAddress of addresses) {
const address = rawAddress.trim()
const key = normalizedKey(address)
if (!key || used.has(key)) continue
used.add(key)
result.push(address)
}
return result
}
/**
* Build the exact recipient lists submitted to the email provider.
*
* A null company CC list means the company has never configured the new
* setting, so the historical automatic-copy address remains in effect. An
* explicit empty list disables that fallback. Recipients are de-duplicated
* with To taking precedence over CC and CC taking precedence over BCC.
*/
export function resolveInvoiceEmailRecipients(
input: ResolveInvoiceEmailRecipientsInput,
): ResolvedInvoiceEmailRecipients {
const used = new Set<string>()
const rawTo = typeof input.to === 'string' ? [input.to] : input.to
const to = uniqueAddresses(rawTo, used)
const fixedCc = input.configuredCc === null || input.configuredCc === undefined
? input.legacyCc
? [input.legacyCc]
: []
: input.configuredCc
const cc = uniqueAddresses(
[...fixedCc, ...(input.additionalCc ?? [])],
used,
)
const bcc = uniqueAddresses(
[...(input.configuredBcc ?? []), ...(input.additionalBcc ?? [])],
used,
)
return { to, cc, bcc }
}
/**
* Report explicit per-send recipients that would be silently moved or omitted
* by deterministic To, CC, BCC precedence. Company-level configuration keeps
* its historical de-duplication behavior, while caller-supplied collisions are
* rejected before invoice number allocation so the caller can correct them.
*/
export function findAdditionalInvoiceRecipientCollisions(
input: ResolveInvoiceEmailRecipientsInput,
): InvoiceEmailRecipientCollision[] {
const occupied = new Map<string, InvoiceEmailRecipientCollision['conflicts_with']>()
const rawTo = typeof input.to === 'string' ? [input.to] : input.to
for (const address of rawTo) {
const key = normalizedKey(address)
if (key) occupied.set(key, 'to')
}
const fixedCc = input.configuredCc === null || input.configuredCc === undefined
? input.legacyCc
? [input.legacyCc]
: []
: input.configuredCc
for (const address of fixedCc) {
const key = normalizedKey(address)
if (key && !occupied.has(key)) occupied.set(key, 'configured_cc')
}
for (const address of input.configuredBcc ?? []) {
const key = normalizedKey(address)
if (key && !occupied.has(key)) occupied.set(key, 'configured_bcc')
}
const collisions: InvoiceEmailRecipientCollision[] = []
for (const address of input.additionalCc ?? []) {
const key = normalizedKey(address)
if (!key) continue
const conflict = occupied.get(key)
if (conflict) {
collisions.push({ address: address.trim(), field: 'additional_cc', conflicts_with: conflict })
continue
}
occupied.set(key, 'additional_cc')
}
for (const address of input.additionalBcc ?? []) {
const key = normalizedKey(address)
if (!key) continue
const conflict = occupied.get(key)
if (conflict) {
collisions.push({ address: address.trim(), field: 'additional_bcc', conflicts_with: conflict })
continue
}
occupied.set(key, 'additional_bcc')
}
return collisions
}
export function parseInvoiceRecipientText(value: string): string[] {
const used = new Set<string>()
return uniqueAddresses(value.split(/[\n,;]+/), used)
}
+82 -88
View File
@@ -1,6 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { EmailService, SendEmailOptions, SendEmailResult } from '@/lib/email/service'
import { deleteDocument, uploadDocument } from '@/lib/core/documents/document-service'
import { createServiceClient } from '@/lib/supabase/server'
import type { InvoiceDelivery } from '@/types'
const PDF_CONTENT_TYPE = 'application/pdf'
@@ -21,6 +22,7 @@ export interface TrackedInvoiceEmailInput {
deliveryId: string
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
replyTo?: string
fromName?: string
subject: string
@@ -44,6 +46,9 @@ function addresses(value?: string | string[]): string[] {
/**
* Persist a reusable delivery attempt before allocating an invoice number.
* The unique preparing row is also the concurrency lock for one invoice send.
* The stateless service-role client is only transport for the service-only RPC:
* the RPC re-authorizes userId as a writable member of companyId and scopes the
* invoice row to the same company before it can write anything.
*/
export async function reserveInvoiceDelivery(args: {
supabase: SupabaseClient
@@ -51,31 +56,13 @@ export async function reserveInvoiceDelivery(args: {
userId: string
invoiceId: string
}): Promise<string> {
const { data, error } = await args.supabase
.from('invoice_deliveries')
.insert({
company_id: args.companyId,
user_id: args.userId,
invoice_id: args.invoiceId,
channel: 'email',
status: 'preparing',
})
.select('id')
.single()
const { data, error } = await createServiceClient().rpc('reserve_invoice_delivery', {
p_company_id: args.companyId,
p_invoice_id: args.invoiceId,
p_actor_user_id: args.userId,
})
if (data?.id) return data.id
if ((error as { code?: string } | null)?.code === '23505') {
const { data: existing, error: existingError } = await args.supabase
.from('invoice_deliveries')
.select('id')
.eq('company_id', args.companyId)
.eq('invoice_id', args.invoiceId)
.eq('status', 'preparing')
.maybeSingle()
if (!existingError && existing?.id) return existing.id
}
if (!error && typeof data === 'string') return data
throw new InvoiceDeliverySnapshotError(
`Failed to reserve invoice delivery: ${error?.message || 'unknown error'}`,
@@ -94,6 +81,7 @@ export async function sendTrackedInvoiceEmail(
deliveryId,
to,
cc,
bcc,
replyTo,
fromName,
subject,
@@ -116,30 +104,33 @@ export async function sendTrackedInvoiceEmail(
{ upload_source: 'system' },
)
const { data: delivery, error: deliveryError } = await supabase
.from('invoice_deliveries')
.update({
status: 'pending',
to_addresses: addresses(to),
cc_addresses: addresses(cc),
reply_to: replyTo || null,
from_name: fromName || null,
subject,
body_text: text,
body_html: html,
document_attachment_id: document.id,
attachment_filename: filename,
attachment_content_type: PDF_CONTENT_TYPE,
attachment_sha256: document.sha256_hash,
})
.eq('id', deliveryId)
.eq('company_id', companyId)
.eq('invoice_id', invoiceId)
.eq('status', 'preparing')
.select('*')
.single()
// These service-only RPCs re-authorize userId against companyId and bind
// every transition to the reserved invoice, so no caller-supplied tenant or
// actor identifier is trusted merely because this client bypasses RLS.
const deliveryWriter = createServiceClient()
const { data: capturedDeliveryId, error: deliveryError } = await deliveryWriter.rpc(
'capture_invoice_delivery_payload',
{
p_delivery_id: deliveryId,
p_company_id: companyId,
p_invoice_id: invoiceId,
p_actor_user_id: userId,
p_to_addresses: addresses(to),
p_cc_addresses: addresses(cc),
p_bcc_addresses: addresses(bcc),
p_reply_to: replyTo || null,
p_from_name: fromName || null,
p_subject: subject,
p_body_text: text,
p_body_html: html,
p_document_attachment_id: document.id,
p_attachment_filename: filename,
p_attachment_content_type: PDF_CONTENT_TYPE,
p_attachment_sha256: document.sha256_hash,
},
)
if (deliveryError || !delivery) {
if (deliveryError || capturedDeliveryId !== deliveryId) {
try {
await deleteDocument(supabase, companyId, document.id)
} catch {
@@ -154,6 +145,7 @@ export async function sendTrackedInvoiceEmail(
const emailOptions: SendEmailOptions = {
to,
cc,
bcc,
subject,
html,
text,
@@ -170,22 +162,22 @@ export async function sendTrackedInvoiceEmail(
const result = await emailService.sendEmail(emailOptions)
if (!result.success) {
const { error: failureRecordError } = await supabase
.from('invoice_deliveries')
.update({
status: 'failed',
provider: result.provider || null,
provider_message_id: null,
error_code: 'provider_failed',
document_attachment_id: null,
failed_at: new Date().toISOString(),
})
.eq('id', delivery.id)
.eq('company_id', companyId)
.eq('status', 'pending')
const { data: failedDeliveryId, error: failureRecordError } = await deliveryWriter.rpc(
'finalize_invoice_delivery',
{
p_delivery_id: deliveryId,
p_company_id: companyId,
p_actor_user_id: userId,
p_status: 'failed',
p_provider: result.provider || null,
p_provider_message_id: null,
p_error_code: 'provider_failed',
},
)
const failureRecorded = !failureRecordError && failedDeliveryId === deliveryId
let cleanupFailed = false
if (!failureRecordError) {
if (failureRecorded) {
try {
const cleanup = await deleteDocument(supabase, companyId, document.id)
cleanupFailed = !cleanup.ok
@@ -196,9 +188,9 @@ export async function sendTrackedInvoiceEmail(
return {
...result,
deliveryId: delivery.id,
deliveryId,
documentId: document.id,
...(failureRecordError
...(!failureRecorded
? { trackingWarning: 'failure_record_failed' as const }
: cleanupFailed
? { trackingWarning: 'failure_cleanup_failed' as const }
@@ -206,23 +198,31 @@ export async function sendTrackedInvoiceEmail(
}
}
const { error: finalizeError } = await supabase
.from('invoice_deliveries')
.update({
status: 'sent',
provider: result.provider || null,
provider_message_id: result.messageId || null,
sent_at: new Date().toISOString(),
})
.eq('id', delivery.id)
.eq('company_id', companyId)
.eq('status', 'pending')
const { data: finalizedDeliveryId, error: finalizeError } = await deliveryWriter.rpc(
'finalize_invoice_delivery',
{
p_delivery_id: deliveryId,
p_company_id: companyId,
p_actor_user_id: userId,
p_status: 'sent',
p_provider: result.provider || null,
p_provider_message_id: result.messageId || null,
p_error_code: null,
},
)
// Delivery is irreversible once the provider succeeds. A failed terminal
// transition is returned as a reconciliation warning; each caller still
// advances the invoice to sent, and ordinary send routes reject non-drafts,
// so a pending evidence row never becomes permission to send a duplicate.
// Pending rows are outside the preparing-only reservation lock, so retained
// evidence also cannot block a later explicitly authorized resend.
const finalized = !finalizeError && finalizedDeliveryId === deliveryId
return {
...result,
deliveryId: delivery.id,
deliveryId,
documentId: document.id,
...(finalizeError ? { trackingWarning: 'finalize_failed' as const } : {}),
...(!finalized ? { trackingWarning: 'finalize_failed' as const } : {}),
}
}
@@ -233,18 +233,12 @@ export async function recordManualInvoiceDelivery(args: {
invoiceId: string
sentAt?: string
}): Promise<InvoiceDelivery> {
const { data, error } = await args.supabase
.from('invoice_deliveries')
.insert({
company_id: args.companyId,
user_id: args.userId,
invoice_id: args.invoiceId,
channel: 'manual',
status: 'marked_sent',
sent_at: args.sentAt || new Date().toISOString(),
})
.select('*')
.single()
const { data, error } = await createServiceClient().rpc('record_manual_invoice_delivery', {
p_company_id: args.companyId,
p_invoice_id: args.invoiceId,
p_actor_user_id: args.userId,
p_sent_at: args.sentAt || null,
})
if (error || !data) {
throw new InvoiceDeliverySnapshotError(
+144
View File
@@ -0,0 +1,144 @@
import type {
CompanySettings,
Currency,
Invoice,
InvoicePaymentAccount,
} from '@/types'
export const INVOICE_PAYMENT_ACCOUNT_CURRENCIES: readonly Currency[] = [
'SEK',
'EUR',
'USD',
'GBP',
'NOK',
'DKK',
]
const PAYMENT_FIELDS: readonly (keyof InvoicePaymentAccount)[] = [
'bank_name',
'clearing_number',
'account_number',
'bankgiro',
'plusgiro',
'swish',
'iban',
'bic',
]
function clean(value: string | null | undefined): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
export function legacySekInvoicePaymentAccount(
company: Pick<CompanySettings, keyof InvoicePaymentAccount>,
): InvoicePaymentAccount {
return {
bank_name: clean(company.bank_name),
clearing_number: clean(company.clearing_number),
account_number: clean(company.account_number),
bankgiro: clean(company.bankgiro),
plusgiro: clean(company.plusgiro),
swish: clean(company.swish),
iban: clean(company.iban),
bic: clean(company.bic),
}
}
export function normalizeInvoicePaymentAccount(
account: Partial<InvoicePaymentAccount>,
): InvoicePaymentAccount {
return {
bank_name: clean(account.bank_name),
clearing_number: clean(account.clearing_number),
account_number: clean(account.account_number),
bankgiro: clean(account.bankgiro),
plusgiro: clean(account.plusgiro),
swish: clean(account.swish),
iban: clean(account.iban)?.replace(/\s/g, '').toUpperCase() ?? null,
bic: clean(account.bic)?.replace(/\s/g, '').toUpperCase() ?? null,
}
}
export function resolveInvoicePaymentAccount(
company: CompanySettings,
currency: Currency,
): InvoicePaymentAccount | null {
const configured = company.invoice_payment_accounts?.[currency]
if (configured) return normalizeInvoicePaymentAccount(configured)
return currency === 'SEK' ? legacySekInvoicePaymentAccount(company) : null
}
export function hasUsableInvoicePaymentAccount(
account: InvoicePaymentAccount | null,
currency: Currency,
): boolean {
if (!account) return false
if (currency !== 'SEK') return !!account.iban
return !!(
account.iban
|| account.bankgiro
|| account.plusgiro
|| account.swish
|| (account.clearing_number && account.account_number)
)
}
export function invoiceRequiresPaymentAccount(
invoice: Pick<Invoice, 'credited_invoice_id' | 'document_type'>,
): boolean {
return !invoice.credited_invoice_id
&& invoice.document_type !== 'delivery_note'
&& invoice.document_type !== 'proforma'
}
export function hasRequiredInvoicePaymentAccount(
company: CompanySettings,
invoice: Pick<Invoice, 'credited_invoice_id' | 'currency' | 'document_type'>,
): boolean {
return !invoiceRequiresPaymentAccount(invoice)
|| hasUsableInvoicePaymentAccount(
resolveInvoicePaymentAccount(company, invoice.currency),
invoice.currency,
)
}
export class InvoicePaymentAccountMissingError extends Error {
readonly code = 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING'
readonly currency: Currency
constructor(currency: Currency) {
super(`Invoice payment account is missing for ${currency}.`)
this.name = 'InvoicePaymentAccountMissingError'
this.currency = currency
}
}
export function assertInvoicePaymentAccountForRender(
company: CompanySettings,
currency: Currency,
): void {
if (
!hasUsableInvoicePaymentAccount(
resolveInvoicePaymentAccount(company, currency),
currency,
)
) {
throw new InvoicePaymentAccountMissingError(currency)
}
}
/**
* Return invoice render settings with only the matching payment account.
* Foreign invoices never inherit the legacy SEK payment details.
*/
export function companyWithInvoicePaymentAccount(
company: CompanySettings,
currency: Currency,
): CompanySettings {
const account = resolveInvoicePaymentAccount(company, currency)
const updates = Object.fromEntries(
PAYMENT_FIELDS.map((field) => [field, account?.[field] ?? null]),
) as Pick<CompanySettings, keyof InvoicePaymentAccount>
return { ...company, ...updates }
}
+22 -6
View File
@@ -20,13 +20,17 @@
*/
import QRCode from 'qrcode'
import type { CompanySettings, Invoice } from '@/types'
import type { CompanySettings, Currency, Invoice } from '@/types'
import { brandingFromCompanySettings, SHOW_SWISH_ON_INVOICE, type InvoiceBranding } from '@/lib/invoices/pdf-template'
import { buildSwishQrPayload } from '@/lib/payments/swish'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { createLogger } from '@/lib/logger'
import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
import { prepareInvoiceFont } from '@/lib/invoices/pdf-fonts'
import {
assertInvoicePaymentAccountForRender,
companyWithInvoicePaymentAccount,
} from '@/lib/invoices/payment-accounts'
const log = createLogger('invoice.swish-qr')
const paymentLinkLog = createLogger('invoice.payment-link-qr')
@@ -42,6 +46,10 @@ export interface InvoicePdfRenderExtras {
company: CompanySettings
}
export interface InvoicePdfRenderOptions {
paymentAccountRequired?: boolean
}
// A company's logo is reused across every invoice render, and twice per send
// (preflight + final render), and once per invoice in recurring/batch loops:
// so cache the re-encoded result keyed by logo URL. Only successes are cached
@@ -144,18 +152,26 @@ async function encodeLogo(logoUrl: string): Promise<string | null> {
export async function prepareInvoicePdfRender(
company: CompanySettings,
currency?: Currency,
options: InvoicePdfRenderOptions = {},
): Promise<InvoicePdfRenderExtras> {
if (currency && options.paymentAccountRequired !== false) {
assertInvoicePaymentAccountForRender(company, currency)
}
const branding = await prepareInvoiceFont(
company,
brandingFromCompanySettings(company),
)
if (!company.logo_url) return { branding, company }
const paymentCompany = currency
? companyWithInvoicePaymentAccount(company, currency)
: company
if (!paymentCompany.logo_url) return { branding, company: paymentCompany }
const dataUrl = await resolveLogoDataUrl(company.logo_url)
const dataUrl = await resolveLogoDataUrl(paymentCompany.logo_url)
const resolved =
dataUrl && dataUrl !== company.logo_url
? { ...company, logo_url: dataUrl }
: company
dataUrl && dataUrl !== paymentCompany.logo_url
? { ...paymentCompany, logo_url: dataUrl }
: paymentCompany
return { branding, company: resolved }
}
+37 -8
View File
@@ -40,6 +40,14 @@ import {
reserveInvoiceDelivery,
sendTrackedInvoiceEmail,
} from '@/lib/invoices/invoice-deliveries'
import {
exceedsInvoiceEmailRecipientLimit,
invoiceEmailRecipientCount,
resolveInvoiceEmailRecipients,
} from '@/lib/invoices/email-recipients'
import {
hasRequiredInvoicePaymentAccount,
} from '@/lib/invoices/payment-accounts'
import { createLogger } from '@/lib/logger'
import type {
Invoice,
@@ -439,7 +447,7 @@ async function sendInvoiceFromSchedule(
})
return false
}
if (!invoice.customer.email) {
if (!invoice.customer.email?.trim()) {
log.warn('customer has no email; recurring schedule cannot auto-send', {
invoiceId: invoice.id,
customerId: invoice.customer.id,
@@ -456,7 +464,26 @@ async function sendInvoiceFromSchedule(
if (!company) {
throw new Error('company settings missing: cannot send invoice')
}
if (!hasRequiredInvoicePaymentAccount(company, invoice)) {
log.warn('invoice currency has no usable payment account; recurring schedule cannot auto-send', {
invoiceId: invoice.id,
currency: invoice.currency,
})
return false
}
const recipients = resolveInvoiceEmailRecipients({
to: invoice.customer.email,
configuredCc: company.invoice_email_cc_addresses,
configuredBcc: company.invoice_email_bcc_addresses,
legacyCc: company.email,
})
if (exceedsInvoiceEmailRecipientLimit(recipients)) {
log.warn('invoice has too many email recipients; recurring schedule cannot auto-send', {
invoiceId: invoice.id,
recipientCount: invoiceEmailRecipientCount(recipients),
})
return false
}
let deliveryId: string
try {
deliveryId = await reserveInvoiceDelivery({
@@ -498,8 +525,11 @@ async function sendInvoiceFromSchedule(
// Render PDF with status overridden to 'sent' so the customer doesn't
// receive a "UTKAST" stamp.
const renderableInvoice = { ...invoice, status: 'sent' as const }
const { branding, company: renderCompany } = await prepareInvoicePdfRender(company)
const swishQrDataUrl = await buildSwishQrDataUrl(company, renderableInvoice)
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company,
renderableInvoice.currency,
)
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice)
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(renderableInvoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
@@ -522,8 +552,6 @@ async function sendInvoiceFromSchedule(
invoiceDate: invoice.invoice_date,
documentType: invoice.document_type,
})
const ccAddress = company.email || undefined
const subject = generateInvoiceEmailSubject(emailData)
const html = generateInvoiceEmailHtml(emailData)
const text = generateInvoiceEmailText(emailData)
@@ -536,8 +564,9 @@ async function sendInvoiceFromSchedule(
userId,
invoiceId: invoice.id,
deliveryId,
to: invoice.customer.email,
cc: ccAddress,
to: recipients.to,
cc: recipients.cc,
bcc: recipients.bcc,
subject,
html,
text,
@@ -6,7 +6,12 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { createQueuedMockSupabase, makeInvoice, makeFiscalPeriod } from '@/tests/helpers'
import {
createQueuedMockSupabase,
makeCustomer,
makeInvoice,
makeFiscalPeriod,
} from '@/tests/helpers'
import type { PendingOperation } from '@/types'
vi.mock('@/lib/core/bookkeeping/period-service', async () => {
@@ -54,10 +59,27 @@ vi.mock('@/lib/transactions/categorize-core', async () => {
}
})
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) }
})
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({
isConfigured: () => true,
sendEmail: vi.fn(),
}),
}))
vi.mock('@/lib/invoices/ensure-invoice-number', () => ({
ensureInvoiceNumber: vi.fn(),
}))
const mockRecordManualInvoiceDelivery = vi.fn().mockResolvedValue({ id: 'delivery-1' })
const mockReserveInvoiceDelivery = vi.fn().mockResolvedValue('delivery-1')
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
recordManualInvoiceDelivery: (...args: unknown[]) => mockRecordManualInvoiceDelivery(...args),
reserveInvoiceDelivery: vi.fn().mockResolvedValue('delivery-1'),
reserveInvoiceDelivery: (...args: unknown[]) => mockReserveInvoiceDelivery(...args),
sendTrackedInvoiceEmail: vi.fn(),
}))
@@ -68,6 +90,7 @@ import { executeSIEImport } from '@/lib/import/sie-import'
import { commitAnnualPostings } from '@/lib/bokslut/assets/depreciation-engine'
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { categorizeMatchedTransaction } from '@/lib/transactions/categorize-core'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
@@ -193,8 +216,11 @@ describe('commitPendingOperation: credit-note issuance guard', () => {
}),
error: null,
})
enqueue({
data: { accounting_method: 'cash', entity_type: 'enskild_firma', bankgiro: '123-4567' },
error: null,
})
enqueue({ data: null, error: null }) // status update
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
enqueue({ data: null, error: null }) // dispatcher update
const op = makePendingOp({
@@ -217,6 +243,134 @@ describe('commitPendingOperation: credit-note issuance guard', () => {
invoiceId: 'invoice-1',
})
})
it.each(['SEK', 'EUR'] as const)(
'rejects a %s invoice without a payment account before number allocation',
async (currency) => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: makeInvoice({
id: 'invoice-1',
status: 'draft',
invoice_number: null,
credited_invoice_id: null,
currency,
}),
error: null,
})
enqueue({ data: { invoice_payment_accounts: {} }, error: null })
enqueue({ data: null, error: null }) // dispatcher rejected update
const op = makePendingOp({
operation_type: 'mark_invoice_sent',
params: { invoice_id: 'invoice-1' },
})
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
op,
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(ensureInvoiceNumber).not.toHaveBeenCalled()
expect(mockRecordManualInvoiceDelivery).not.toHaveBeenCalled()
},
)
})
describe('commitPendingOperation: invoice send payment account guard', () => {
it.each(['SEK', 'EUR'] as const)(
'rejects a %s invoice before delivery reservation and number allocation',
async (currency) => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: makeInvoice({
id: 'invoice-1',
status: 'draft',
invoice_number: null,
currency,
customer: makeCustomer({ id: 'customer-1', email: 'customer@example.test' }),
items: [],
}),
error: null,
})
enqueue({
data: {
company_name: 'Test AB',
invoice_payment_accounts: {},
},
error: null,
})
enqueue({ data: null, error: null }) // dispatcher's rejected update
const op = makePendingOp({
operation_type: 'send_invoice',
params: { invoice_id: 'invoice-1' },
})
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
op,
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(ensureInvoiceNumber).not.toHaveBeenCalled()
expect(supabase.from).not.toHaveBeenCalledWith('invoice_deliveries')
},
)
})
describe('commitPendingOperation: invoice send recipient limit', () => {
it('rejects an oversized configured recipient set before reservation and allocation', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: makeInvoice({
id: 'invoice-1',
status: 'draft',
invoice_number: null,
customer: makeCustomer({ id: 'customer-1', email: 'customer@example.test' }),
items: [],
}),
error: null,
})
enqueue({
data: {
company_name: 'Test AB',
bankgiro: '123-4567',
invoice_email_cc_addresses: Array.from(
{ length: 20 },
(_, index) => `fixed-${index}@example.test`,
),
invoice_email_bcc_addresses: [],
},
error: null,
})
enqueue({ data: null, error: null }) // dispatcher's rejected update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
operation_type: 'send_invoice',
params: { invoice_id: 'invoice-1' },
}),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(ensureInvoiceNumber).not.toHaveBeenCalled()
})
})
// ─── post_annual_depreciation ───────────────────────────────────────
+63 -9
View File
@@ -73,6 +73,15 @@ import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import {
hasRequiredInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
import {
exceedsInvoiceEmailRecipientLimit,
invoiceEmailRecipientCount,
resolveInvoiceEmailRecipients,
} from '@/lib/invoices/email-recipients'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import {
@@ -1496,13 +1505,38 @@ async function commitSendInvoice(
}
const customer = invoice.customer as Customer
if (!customer.email) return { error: 'Customer has no email address', status: 400 }
if (!customer.email?.trim()) return { error: 'Customer has no email address', status: 400 }
const { data: company, error: companyError } = await supabase
.from('company_settings').select('*').eq('company_id', companyId).single()
if (companyError || !company) return { error: 'Company settings missing', status: 500 }
const paymentAccountRequired = invoiceRequiresPaymentAccount(invoice as Invoice)
if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, invoice as Invoice)) {
return {
error:
getErrorEntry('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')?.message_sv
?? 'Betalningskonto saknas för fakturans valuta.',
status: 400,
}
}
const recipients = resolveInvoiceEmailRecipients({
to: customer.email,
configuredCc: company.invoice_email_cc_addresses,
configuredBcc: company.invoice_email_bcc_addresses,
legacyCc: company.email || userEmail,
})
if (exceedsInvoiceEmailRecipientLimit(recipients)) {
return {
error:
getErrorEntry('INVOICE_SEND_TOO_MANY_RECIPIENTS')?.message_sv
?? `Ett fakturautskick får inte ha ${invoiceEmailRecipientCount(recipients)} mottagare.`,
status: 400,
}
}
const items = (invoice.items as InvoiceItem[]).sort(
(a: InvoiceItem, b: InvoiceItem) => a.sort_order - b.sort_order
)
@@ -1523,7 +1557,11 @@ async function commitSendInvoice(
const isFreshAllocation = !invoice.invoice_number
if (isFreshAllocation) {
try {
const preflight = await prepareInvoicePdfRender(company as CompanySettings)
const preflight = await prepareInvoicePdfRender(
company as CompanySettings,
(invoice as Invoice).currency,
{ paymentAccountRequired },
)
await renderToBuffer(
InvoicePDF({
invoice: { ...(invoice as Invoice), invoice_number: 'F-PREVIEW' },
@@ -1578,8 +1616,10 @@ async function commitSendInvoice(
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company as CompanySettings,
renderableInvoice.currency,
{ paymentAccountRequired },
)
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, renderableInvoice)
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: renderableInvoice,
@@ -1603,7 +1643,6 @@ async function commitSendInvoice(
isCreditNote,
})
const ccAddress = company.email || userEmail
const emailData = { invoice: renderableInvoice, customer, company: company as CompanySettings }
const subject = generateInvoiceEmailSubject(emailData)
const html = generateInvoiceEmailHtml(emailData)
@@ -1617,8 +1656,9 @@ async function commitSendInvoice(
userId,
invoiceId,
deliveryId,
to: customer.email,
cc: ccAddress,
to: recipients.to,
cc: recipients.cc,
bcc: recipients.bcc,
subject,
html,
text,
@@ -1708,6 +1748,23 @@ async function commitMarkInvoiceSent(
}
if (invoice.status !== 'draft') return { error: 'Only draft invoices can be marked as sent', status: 409 }
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('accounting_method, entity_type, invoice_payment_accounts, bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic')
.eq('company_id', companyId)
.single()
if (settingsError || !settings) return { error: 'Company settings missing', status: 500 }
if (!hasRequiredInvoicePaymentAccount(settings as CompanySettings, invoice as Invoice)) {
return {
error:
getErrorEntry('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')?.message_sv
?? 'Betalningskonto saknas för fakturans valuta.',
status: 400,
}
}
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
@@ -1731,9 +1788,6 @@ async function commitMarkInvoiceSent(
deliveryHistoryWarning = 'Fakturan markerades som skickad men utskickshistoriken kunde inte sparas.'
}
const { data: settings } = await supabase
.from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
@@ -593,6 +593,10 @@ describe('generateFullArchive', () => {
expect(items).toEqual([
{ id: 'item-1', invoice_id: 'inv-1', description: 'Konsulttid' },
])
expect(supabase.rpc).toHaveBeenCalledWith(
'export_invoice_delivery_evidence',
{ p_company_id: 'company-1' },
)
})
it('skips raw SIE blobs when include_documents is false but keeps metadata', async () => {
+122 -33
View File
@@ -539,28 +539,24 @@ describe('generateTrialBalance', () => {
expect(result.isBalanced).toBe(true)
})
// ── excludeYearEndClosing symmetry ───────────────────────────────
// A reversed year_end entry keeps status='reversed' and stays in the
// ledger; its storno carries source_type='storno'. Excluding on
// source_type alone drops the original but keeps the counter-entry,
// inflating the P&L by exactly the reversed amount. The filter must also
// exclude entries chained to year_end entries via reverses_id /
// correction_of_id.
// ── final-closing precision ──────────────────────────────────────
// Tax and appropriations are also source_type='year_end'. The statutory
// pre-closing report must exclude only fiscal_periods.closing_entry_id.
it('excludes stornos and corrections chained to year_end entries', async () => {
it('excludes only the fiscal period closing entry', async () => {
mockResults = {
fiscal_periods: [
{
data: { period_start: '2025-01-01', period_end: '2025-12-31', opening_balance_entry_id: null },
data: {
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
closing_entry_id: 'closing-1',
},
error: null,
},
],
journal_entries: [
// 1st: the year_end entry-id fetch added for chain exclusion
{ data: [{ id: 'ye-1' }, { id: 'ye-2' }], error: null },
// 2nd: the entries step of the period-lines fetch
{ data: [{ id: 'entry-1' }], error: null },
],
journal_entries: [{ data: [{ id: 'tax-1' }, { id: 'appropriation-1' }], error: null }],
journal_entry_lines: [
{
data: [
@@ -574,34 +570,124 @@ describe('generateTrialBalance', () => {
}
await generateTrialBalance(supabase, 'company-1', 'period-1', {
excludeYearEndClosing: true,
excludeFinalClosingEntry: true,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
const orCalls = builders.flatMap(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(b: any) => (b.or ? b.or.mock.calls.map((c: unknown[]) => c[0]) : []),
)
expect(orCalls).toContain('reverses_id.is.null,reverses_id.not.in.(ye-1,ye-2)')
expect(orCalls).toContain('correction_of_id.is.null,correction_of_id.not.in.(ye-1,ye-2)')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
expect(neqCalls).toContainEqual(['source_type', 'year_end'])
expect(neqCalls).not.toContainEqual(['source_type', 'year_end'])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const orCalls = builders.flatMap((b: any) => (b.or ? b.or.mock.calls : []))
expect(orCalls).toContainEqual(['id.neq.closing-1,status.neq.posted'])
})
it('skips the chain filters when the company has no year_end entries', async () => {
it('fails closed when a closed period has no linked final closing entry', async () => {
mockResults = {
fiscal_periods: [
{
data: { period_start: '2025-01-01', period_end: '2025-12-31', opening_balance_entry_id: null },
data: {
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
closing_entry_id: null,
is_closed: true,
},
error: null,
},
],
journal_entries: [{ data: [{ id: 'tax-1' }, { id: 'appropriation-1' }], error: null }],
journal_entry_lines: [{ data: [], error: null }],
chart_of_accounts: [{ data: [], error: null }],
}
await expect(
generateTrialBalance(supabase, 'company-1', 'period-1', {
excludeFinalClosingEntry: true,
}),
).rejects.toThrow(/missing closing_entry_id/i)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('keeps year-end adjustments for an open period without a final closing entry', async () => {
mockResults = {
fiscal_periods: [
{
data: {
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
closing_entry_id: null,
is_closed: false,
},
error: null,
},
],
journal_entries: [{ data: [{ id: 'tax-1' }], error: null }],
journal_entry_lines: [{ data: [], error: null }],
chart_of_accounts: [{ data: [], error: null }],
}
await generateTrialBalance(supabase, 'company-1', 'period-1', {
excludeFinalClosingEntry: true,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
expect(neqCalls).not.toContainEqual(['source_type', 'year_end'])
})
it('keeps a linked reversed closing together with its storno', async () => {
mockResults = {
fiscal_periods: [
{
data: {
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
closing_entry_id: 'closing-1',
},
error: null,
},
],
journal_entries: [{ data: [{ id: 'closing-1' }, { id: 'storno-1' }], error: null }],
journal_entry_lines: [{ data: [], error: null }],
chart_of_accounts: [{ data: [], error: null }],
}
await generateTrialBalance(supabase, 'company-1', 'period-1', {
excludeFinalClosingEntry: true,
})
// The OR excludes closing-1 only while status is posted. If it is
// reversed during an administrative undo, both it and its storno remain.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const orCalls = builders.flatMap((b: any) => (b.or ? b.or.mock.calls : []))
expect(orCalls).toContainEqual(['id.neq.closing-1,status.neq.posted'])
})
it('preserves the broad year-end exclusion for operational reports', async () => {
mockResults = {
fiscal_periods: [
{
data: {
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
closing_entry_id: 'closing-1',
},
error: null,
},
],
journal_entries: [
// year_end id fetch: none exist
{ data: [], error: null },
{ data: [{ id: 'entry-1' }], error: null },
{ data: [{ id: 'reversed-year-end-1' }], error: null },
{ data: [{ id: 'ordinary-1' }], error: null },
],
journal_entry_lines: [{ data: [], error: null }],
chart_of_accounts: [{ data: [], error: null }],
@@ -613,14 +699,17 @@ describe('generateTrialBalance', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
const orCalls = builders.flatMap(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(b: any) => (b.or ? b.or.mock.calls : []),
)
expect(orCalls).toHaveLength(0)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
expect(neqCalls).toContainEqual(['source_type', 'year_end'])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const orCalls = builders.flatMap((b: any) => (b.or ? b.or.mock.calls : []))
expect(orCalls).toContainEqual([
'reverses_id.is.null,reverses_id.not.in.(reversed-year-end-1)',
])
expect(orCalls).toContainEqual([
'correction_of_id.is.null,correction_of_id.not.in.(reversed-year-end-1)',
])
})
it('returns empty period activity when the range matches no lines', async () => {
+12 -3
View File
@@ -981,9 +981,17 @@ async function writeMasterData(
for (const t of MASTER_DATA_DUMP_TABLES) {
const pageKey = t.pageKey ?? 'id'
try {
const rows = t.via
? await fetchChildTableRows(supabase, companyId, t)
: await fetchAllRows<Record<string, unknown>>(({ from, to }) => {
const rows = t.name === 'invoice_deliveries'
? await fetchAllRows<Record<string, unknown>>(({ from, to }) =>
supabase
.rpc('export_invoice_delivery_evidence', { p_company_id: companyId })
.order('created_at', { ascending: true })
.order('id', { ascending: true })
.range(from, to),
{ dedupeBy: (row) => String(row.id) })
: t.via
? await fetchChildTableRows(supabase, companyId, t)
: await fetchAllRows<Record<string, unknown>>(({ from, to }) => {
let q = supabase.from(t.name).select('*').eq('company_id', companyId)
if (t.orderBy) {
q = q.order(t.orderBy, { ascending: true })
@@ -997,6 +1005,7 @@ async function writeMasterData(
}, { dedupeBy: (r) => String(r[pageKey]) })
data.file(t.file, JSON.stringify(rows, null, 2))
} catch (err) {
if (t.name === 'invoice_deliveries') throw err
data.file(
t.file,
JSON.stringify(
+38 -17
View File
@@ -37,6 +37,7 @@ export async function generateTrialBalance(
fiscalPeriodId: string,
options?: {
excludeYearEndClosing?: boolean
excludeFinalClosingEntry?: boolean
fromDate?: string
toDate?: string
dimensions?: Record<string, string>
@@ -51,7 +52,7 @@ export async function generateTrialBalance(
// Fetch period for opening balance computation
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, period_end, opening_balance_entry_id')
.select('period_start, period_end, opening_balance_entry_id, closing_entry_id, is_closed')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
@@ -61,19 +62,23 @@ export async function generateTrialBalance(
? options.dimensions
: undefined
// Year-end exclusion must be symmetric: a reversed year_end entry stays in
// the ledger (status='reversed') together with its storno, but the storno
// carries source_type='storno' (and a correction carries 'correction'), so
// filtering on source_type alone drops the original while keeping its
// counter-entry. That inflates the P&L by exactly the reversed amount.
// Fetch the reversed year_end entry ids (company-wide: a storno may land in
// a later period than the entry it reverses) and exclude anything chained
// to them via reverses_id / correction_of_id. Only status='reversed'
// originals can be storno/correction targets (reverseEntry flips the
// original's status atomically), which keeps the id list short: in the
// common no-reversal case the chain filters are skipped entirely.
// Existing operational reports intentionally exclude every year_end entry.
// Statutory annual reports must exclude only the linked final closing entry:
// tax, depreciation, and appropriations also use source_type year_end. A
// closed period without the link is ambiguous, so fail instead of silently
// understating the statutory report.
if (
options?.excludeFinalClosingEntry
&& period?.is_closed === true
&& !period.closing_entry_id
) {
throw new Error(
'Closed fiscal period is missing closing_entry_id; statutory pre-closing balances cannot be generated safely',
)
}
const excludeAllYearEndEntries = options?.excludeYearEndClosing
let yearEndEntryIds: string[] = []
if (options?.excludeYearEndClosing) {
if (excludeAllYearEndEntries) {
yearEndEntryIds = (
await fetchAllRows<{ id: string }>(({ from, to }) =>
supabase
@@ -91,14 +96,24 @@ export async function generateTrialBalance(
let q = query.neq('source_type', 'year_end')
if (yearEndEntryIds.length > 0) {
const idList = `(${yearEndEntryIds.join(',')})`
// `.not('col','in',...)` alone would also drop NULL rows (NULL NOT IN
// (...) is NULL), i.e. every normal entry: OR in the null branch.
q = q.or(`reverses_id.is.null,reverses_id.not.in.${idList}`)
q = q.or(`correction_of_id.is.null,correction_of_id.not.in.${idList}`)
}
return q
}
const closingEntryId = options?.excludeFinalClosingEntry
? period?.closing_entry_id ?? null
: null
// The base query already admits only posted and reversed entries. Exclude a
// posted final closing entry, but retain a reversed one together with its
// storno so the two continue to net to zero. Draft entries never enter the
// base query.
const excludeClosingEntry = (query: EntryLinesQuery): EntryLinesQuery =>
closingEntryId
? query.or(`id.neq.${closingEntryId},status.neq.posted`)
: query
// ── Opening balances (IB) at period_start ──────────────────────
const { balances: obBalances, obEntryId } = await getOpeningBalances(
supabase, companyId, period
@@ -142,9 +157,12 @@ export async function generateTrialBalance(
query = query.neq('id', obEntryId)
}
if (options?.excludeYearEndClosing) {
if (excludeAllYearEndEntries) {
query = excludeYearEndChain(query)
}
if (options?.excludeFinalClosingEntry) {
query = excludeClosingEntry(query)
}
return query
},
@@ -200,9 +218,12 @@ export async function generateTrialBalance(
query = query.neq('id', obEntryId)
}
if (options?.excludeYearEndClosing) {
if (excludeAllYearEndEntries) {
query = excludeYearEndChain(query)
}
if (options?.excludeFinalClosingEntry) {
query = excludeClosingEntry(query)
}
return query
},
+47
View File
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { createServerClientMock, cookiesMock } = vi.hoisted(() => ({
createServerClientMock: vi.fn(),
cookiesMock: vi.fn(),
}))
vi.mock('@supabase/ssr', () => ({
createServerClient: createServerClientMock,
}))
vi.mock('next/headers', () => ({
cookies: cookiesMock,
}))
describe('createServiceClient', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'https://project.supabase.co')
vi.stubEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY', 'anon-key')
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'service-role-key')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('uses the service-role key with a cookie-free client', async () => {
const serviceClient = { kind: 'service' }
createServerClientMock.mockReturnValue(serviceClient)
const { createServiceClient } = await import('../server')
expect(createServiceClient()).toBe(serviceClient)
expect(createServerClientMock).toHaveBeenCalledWith(
'https://project.supabase.co',
'service-role-key',
expect.objectContaining({ cookies: expect.any(Object) }),
)
const options = createServerClientMock.mock.calls[0][2] as {
cookies: { getAll: () => unknown[]; setAll: () => void }
}
expect(options.cookies.getAll()).toEqual([])
expect(() => options.cookies.setAll()).not.toThrow()
expect(cookiesMock).not.toHaveBeenCalled()
})
})