feat(sandbox): seed payroll, articles and a year of ledger history; calm the connect CTAs (#1437)
* feat(sandbox): seed payroll, articles and a year of ledger history; calm the connect CTAs The sandbox showed neither Löner nor a usable set of reports, and the "connect X" surfaces were oversized boxed cards. Sandbox seed: - pays_salaries + employer_registered, so Löner and Anställda appear at all (an enskild firma is not an employer by default). Both seeded employees are employment_type 'employee': an EF may employ staff, just not its own owner. - Two employees, one booked and one open lönekörning, and the three verifikat the booked run must have posted (7210/2710/1930, 7510/2731, 7290+7519/ 2920+2940). Skatteavdrag comes from the real Skatteverket 2026 tables. - Year-to-date ledger history, January through last month, with the quarterly momsredovisning cleared to 2650 and paid on the SFL deadline. Without the settlement the demo collected VAT all year and never remitted it, which left an implausible bank balance and 155 813 kr of moms "att betala". - The history is exempted through journal_entry_no_doc_required, the same way the SIE-import opt-in treats imported books: its kvitton live in the previous system, and unflagged it put 39 "verifikat utan underlag" on the home screen. - Artikelregister, and the BAS accounts the K1 chart omits for an enskild firma. - History is numbered before the invoice and payroll vouchers so the series runs forwards through the year, and its writes are batched. Connect CTAs: - Bank picker: a two-column grid of 95px bordered logo cards becomes flat hairline rows, Lucide icons, and a quiet inline connecting state. - Cloud backup: each provider collapses to one row; the BFL note is shown once for the section and names only configured destinations. - Hem first-run: only the active step argues its case, but every not-done step keeps a reachable action. The Skatteverket nudge becomes one quiet sentence. Mobile assistant FAB: a fresh open is desktop-only, since the bottom nav already has an Assistent tab. A collapsed session keeps its handle everywhere except /chat, which is itself the way back to the conversation. Also closes a real hole: /api/salary/runs/[id]/payslips/send had no sandbox guard, and a seeded booked run put "Skicka lönebesked" one click from an anonymous visitor with live Resend behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sandbox): check the two unchecked Supabase errors and tighten review nits CodeRabbit review on #1437. Major: two calls discarded their error and continued with null data. A failed chart_of_accounts re-select would have written account_id: null onto every ledger-history and salary voucher line, and a failed next_voucher_number would have inserted a posted verifikat with no number, which is a hole in the verifikationsserie (BFNAR 2013:2). Both now throw, and a null voucher number is rejected explicitly. Minor: the A-004 note claimed a 10 % markup on numbers that are 11.1 %; the salary breakdown test's name said the opposite of its assertions after the switch to the real tax table; the ledger-history doc still said 4 to 6 verifikat per month before the quarterly momsredovisning added a seventh in March, May and June. Bank picker: the spinner is aria-hidden, so loading and connecting had no text equivalent and a failed bank fetch was never announced. Added role="status" with an sr-only label, and role="alert" on the error line. Declined: confirm-before-disconnect on the cloud-backup row. Disconnect was unconfirmed before this PR too, so adding a dialog is a behaviour change beyond the redesign rather than a fix to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
43386b4852
commit
d41ef2a909
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildSandboxArticles } from '../articles'
|
||||
|
||||
const input = { userId: 'user-1', companyId: 'company-1' }
|
||||
|
||||
/** The articles CHECK constraints from 20260621120000_artikelregister.sql. */
|
||||
const ALLOWED_TYPES = ['vara', 'tjanst']
|
||||
const ALLOWED_VAT_RATES = [0, 6, 12, 25]
|
||||
|
||||
describe('sandbox article seed data', () => {
|
||||
it('seeds five articles scoped to the sandbox company', () => {
|
||||
const articles = buildSandboxArticles(input)
|
||||
|
||||
expect(articles).toHaveLength(5)
|
||||
expect(articles.every((a) => a.user_id === 'user-1' && a.company_id === 'company-1')).toBe(true)
|
||||
expect(articles.every((a) => a.active === true)).toBe(true)
|
||||
})
|
||||
|
||||
it('numbers the articles A-001 upwards without gaps', () => {
|
||||
const articles = buildSandboxArticles(input)
|
||||
|
||||
expect(articles.map((a) => a.article_number)).toEqual([
|
||||
'A-001',
|
||||
'A-002',
|
||||
'A-003',
|
||||
'A-004',
|
||||
'A-005',
|
||||
])
|
||||
// uq_articles_company_number is unique per company when article_number is set.
|
||||
expect(new Set(articles.map((a) => a.article_number)).size).toBe(articles.length)
|
||||
})
|
||||
|
||||
it('satisfies every CHECK constraint on articles', () => {
|
||||
const articles = buildSandboxArticles(input)
|
||||
|
||||
for (const article of articles) {
|
||||
expect(ALLOWED_TYPES).toContain(article.type)
|
||||
expect(ALLOWED_VAT_RATES).toContain(article.vat_rate)
|
||||
expect(article.unit.length).toBeGreaterThan(0)
|
||||
expect(article.price_excl_vat).toBeGreaterThan(0)
|
||||
expect(Number.isFinite(article.price_excl_vat)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('covers the mix the register is supposed to demonstrate', () => {
|
||||
const articles = buildSandboxArticles(input)
|
||||
|
||||
// Hourly consulting, a fixed-price package, and a resold licence.
|
||||
expect(articles.some((a) => a.type === 'tjanst' && a.unit === 'tim')).toBe(true)
|
||||
expect(articles.some((a) => a.type === 'tjanst' && a.unit === 'st')).toBe(true)
|
||||
expect(articles.some((a) => a.type === 'vara' && a.unit === 'st')).toBe(true)
|
||||
})
|
||||
|
||||
it('only uses a reduced VAT rate where Swedish law allows one', () => {
|
||||
const articles = buildSandboxArticles(input)
|
||||
|
||||
const reduced = articles.filter((a) => a.vat_rate !== 25)
|
||||
// A printed book is 6 % (ML 9 kap. 7 §). Consulting, the fixed-price
|
||||
// package and the rebilled licence are ordinary 25 % supplies: a reduced
|
||||
// rate on any of them would be a VAT error shipped as demo data.
|
||||
expect(reduced).toHaveLength(1)
|
||||
expect(reduced[0].vat_rate).toBe(6)
|
||||
expect(reduced[0].name).toContain('Handbok')
|
||||
expect(articles.filter((a) => a.vat_rate === 25)).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('leaves the revenue account to the VAT treatment', () => {
|
||||
const articles = buildSandboxArticles(input)
|
||||
|
||||
// NULL means "derive from the VAT treatment at line-create time". Pinning
|
||||
// an override would freeze a 25 % account onto the 6 % book line.
|
||||
expect(articles.every((a) => a.revenue_account === null)).toBe(true)
|
||||
})
|
||||
|
||||
it('sets every optional column on every row', () => {
|
||||
const articles = buildSandboxArticles(input)
|
||||
|
||||
// PostgREST normalizes columns across a bulk insert: a key present on one
|
||||
// row and absent on another is sent as NULL instead of falling through to
|
||||
// the schema default.
|
||||
const keySets = articles.map((a) => Object.keys(a).sort().join(','))
|
||||
expect(new Set(keySets).size).toBe(1)
|
||||
for (const article of articles) {
|
||||
expect(article).toHaveProperty('name_en')
|
||||
expect(article).toHaveProperty('cost_price')
|
||||
expect(article).toHaveProperty('ean')
|
||||
expect(article).toHaveProperty('housework_type')
|
||||
expect(article).toHaveProperty('notes')
|
||||
expect(article.currency).toBe('SEK')
|
||||
}
|
||||
})
|
||||
|
||||
it('is deterministic', () => {
|
||||
expect(buildSandboxArticles(input)).toEqual(buildSandboxArticles(input))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,354 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
|
||||
import {
|
||||
buildSandboxLedgerHistory,
|
||||
SANDBOX_LEDGER_ACCOUNT_NUMBERS,
|
||||
type SandboxLedgerLineRow,
|
||||
} from '../ledger-history'
|
||||
|
||||
/**
|
||||
* The journal_entries.source_type allowlist as of
|
||||
* 20260712100500_journal_source_type_stripe_payout.sql. A value outside this
|
||||
* set is rejected by Postgres with 23514, so the seed would fail at runtime.
|
||||
*/
|
||||
const ALLOWED_SOURCE_TYPES = [
|
||||
'manual',
|
||||
'bank_transaction',
|
||||
'invoice_created',
|
||||
'invoice_paid',
|
||||
'invoice_cash_payment',
|
||||
'credit_note',
|
||||
'salary_payment',
|
||||
'opening_balance',
|
||||
'year_end',
|
||||
'storno',
|
||||
'correction',
|
||||
'import',
|
||||
'system',
|
||||
'inbox_item',
|
||||
'supplier_invoice_registered',
|
||||
'supplier_invoice_paid',
|
||||
'supplier_invoice_cash_payment',
|
||||
'supplier_credit_note',
|
||||
'currency_revaluation',
|
||||
'supplier_invoice_privately_paid',
|
||||
'reminder_fee',
|
||||
'accrual',
|
||||
'result_appropriation',
|
||||
'rot_rut_payout',
|
||||
'vat_settlement',
|
||||
'stripe_payout',
|
||||
]
|
||||
|
||||
const BAS_ACCOUNT_NUMBERS = new Set(BAS_REFERENCE.map((a) => a.account_number))
|
||||
|
||||
const accountMap = Object.fromEntries(
|
||||
SANDBOX_LEDGER_ACCOUNT_NUMBERS.map((n) => [n, `account-${n}`]),
|
||||
)
|
||||
|
||||
/** August 6th: the history then covers January through July. */
|
||||
const input = {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
fiscalPeriodId: 'fp-1',
|
||||
today: new Date(2026, 7, 6),
|
||||
accountMap,
|
||||
}
|
||||
|
||||
function sums(lines: SandboxLedgerLineRow[]) {
|
||||
return {
|
||||
debit: roundOre(lines.reduce((n, l) => n + l.debit_amount, 0)),
|
||||
credit: roundOre(lines.reduce((n, l) => n + l.credit_amount, 0)),
|
||||
}
|
||||
}
|
||||
|
||||
function allLines(history: ReturnType<typeof buildSandboxLedgerHistory>) {
|
||||
return history.linesByEntryIndex.flat()
|
||||
}
|
||||
|
||||
describe('sandbox ledger history', () => {
|
||||
it('emits one line bucket per entry', () => {
|
||||
const history = buildSandboxLedgerHistory(input)
|
||||
|
||||
expect(history.entries.length).toBe(history.linesByEntryIndex.length)
|
||||
expect(history.entries.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('covers January through the month before today, 4 to 7 entries per month', () => {
|
||||
const history = buildSandboxLedgerHistory(input)
|
||||
|
||||
const byMonth = new Map<string, number>()
|
||||
for (const entry of history.entries) {
|
||||
const month = entry.entry_date.slice(0, 7)
|
||||
byMonth.set(month, (byMonth.get(month) ?? 0) + 1)
|
||||
}
|
||||
|
||||
expect([...byMonth.keys()]).toEqual([
|
||||
'2026-01',
|
||||
'2026-02',
|
||||
'2026-03',
|
||||
'2026-04',
|
||||
'2026-05',
|
||||
'2026-06',
|
||||
'2026-07',
|
||||
])
|
||||
// January has no customer payment and no eget uttag yet (no cash in), and
|
||||
// no periodic cost: 4. Every other month is 5 or 6, plus one for the
|
||||
// quarter-close momsredovisning (March, June) and one for the Q1 payment
|
||||
// (May). July has no periodic cost, so it stays at 5.
|
||||
expect(byMonth.get('2026-01')).toBe(4)
|
||||
expect(byMonth.get('2026-03')).toBe(7)
|
||||
expect(byMonth.get('2026-05')).toBe(7)
|
||||
expect(byMonth.get('2026-06')).toBe(7)
|
||||
for (const [, count] of byMonth) {
|
||||
expect(count).toBeGreaterThanOrEqual(4)
|
||||
expect(count).toBeLessThanOrEqual(7)
|
||||
}
|
||||
expect(history.entries).toHaveLength(42)
|
||||
})
|
||||
|
||||
it('produces no entries before the first full month has passed', () => {
|
||||
const january = buildSandboxLedgerHistory({ ...input, today: new Date(2026, 0, 20) })
|
||||
|
||||
expect(january.entries).toEqual([])
|
||||
expect(january.linesByEntryIndex).toEqual([])
|
||||
})
|
||||
|
||||
it('balances every entry exactly, both sides above zero', () => {
|
||||
const history = buildSandboxLedgerHistory(input)
|
||||
|
||||
history.linesByEntryIndex.forEach((lines, index) => {
|
||||
const { debit, credit } = sums(lines)
|
||||
expect(debit, `entry ${index} (${history.entries[index].description})`).toBe(credit)
|
||||
expect(debit).toBeGreaterThan(0)
|
||||
expect(credit).toBeGreaterThan(0)
|
||||
// A line is either a debit or a credit, never both.
|
||||
for (const line of lines) {
|
||||
expect(line.debit_amount === 0 || line.credit_amount === 0).toBe(true)
|
||||
expect(line.debit_amount).toBeGreaterThanOrEqual(0)
|
||||
expect(line.credit_amount).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps every amount rounded to whole öre', () => {
|
||||
for (const line of allLines(buildSandboxLedgerHistory(input))) {
|
||||
expect(line.debit_amount).toBe(roundOre(line.debit_amount))
|
||||
expect(line.credit_amount).toBe(roundOre(line.credit_amount))
|
||||
}
|
||||
})
|
||||
|
||||
it('uses string BAS account numbers that exist in class 1 to 8', () => {
|
||||
for (const line of allLines(buildSandboxLedgerHistory(input))) {
|
||||
expect(typeof line.account_number).toBe('string')
|
||||
expect(line.account_number).toMatch(/^[1-8]\d{3}$/)
|
||||
expect(BAS_ACCOUNT_NUMBERS.has(line.account_number)).toBe(true)
|
||||
expect(SANDBOX_LEDGER_ACCOUNT_NUMBERS).toContain(line.account_number)
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves account_id through the caller-supplied map', () => {
|
||||
const history = buildSandboxLedgerHistory(input)
|
||||
for (const line of allLines(history)) {
|
||||
expect(line.account_id).toBe(`account-${line.account_number}`)
|
||||
}
|
||||
|
||||
// A chart lookup that came back short must degrade to null, not undefined:
|
||||
// undefined would be dropped from the insert payload.
|
||||
const partial = buildSandboxLedgerHistory({ ...input, accountMap: {} })
|
||||
for (const line of allLines(partial)) {
|
||||
expect(line.account_id).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('sets an explicit dimensions bag on every single line', () => {
|
||||
for (const line of allLines(buildSandboxLedgerHistory(input))) {
|
||||
// NOT NULL on journal_entry_lines.dimensions, and PostgREST sends NULL
|
||||
// for a key one row omits while another sets it.
|
||||
expect(Object.prototype.hasOwnProperty.call(line, 'dimensions')).toBe(true)
|
||||
expect(line.dimensions).toBeTypeOf('object')
|
||||
expect(line.dimensions).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('tags revenue lines with the seeded demo dimensions on some months only', () => {
|
||||
const history = buildSandboxLedgerHistory(input)
|
||||
|
||||
const revenueLines = allLines(history).filter((l) => l.account_number === '3001')
|
||||
const tagged = revenueLines.filter((l) => Object.keys(l.dimensions).length > 0)
|
||||
|
||||
expect(tagged.length).toBeGreaterThan(0)
|
||||
expect(tagged.length).toBeLessThan(revenueLines.length)
|
||||
// Only the four dimension_values the seed creates, on SIE dims 1 and 6.
|
||||
for (const line of tagged) {
|
||||
expect(Object.keys(line.dimensions).sort()).toEqual(['1', '6'])
|
||||
expect(['BUTIK', 'WEBB']).toContain(line.dimensions['1'])
|
||||
expect(['P001', 'P002']).toContain(line.dimensions['6'])
|
||||
}
|
||||
expect(tagged.some((l) => l.dimensions['1'] === 'BUTIK' && l.dimensions['6'] === 'P001')).toBe(true)
|
||||
|
||||
// Nothing but revenue carries dimensions.
|
||||
const otherTagged = allLines(history).filter(
|
||||
(l) => l.account_number !== '3001' && Object.keys(l.dimensions).length > 0,
|
||||
)
|
||||
expect(otherTagged).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps every entry inside the fiscal year and on the posted, allowed shape', () => {
|
||||
const history = buildSandboxLedgerHistory(input)
|
||||
|
||||
for (const entry of history.entries) {
|
||||
expect(entry.entry_date >= '2026-01-01').toBe(true)
|
||||
expect(entry.entry_date <= '2026-12-31').toBe(true)
|
||||
expect(entry.entry_date).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||
expect(ALLOWED_SOURCE_TYPES).toContain(entry.source_type)
|
||||
expect(entry.status).toBe('posted')
|
||||
expect(entry.committed_at).toBe(entry.entry_date)
|
||||
expect(entry.voucher_series).toBe('A')
|
||||
expect(entry.fiscal_period_id).toBe('fp-1')
|
||||
expect(entry.user_id).toBe('user-1')
|
||||
expect(entry.company_id).toBe('company-1')
|
||||
expect(entry.description.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('carries no voucher_number: the caller assigns those via the RPC', () => {
|
||||
const history = buildSandboxLedgerHistory(input)
|
||||
|
||||
for (const entry of history.entries) {
|
||||
expect(entry).not.toHaveProperty('voucher_number')
|
||||
expect(entry).not.toHaveProperty('id')
|
||||
}
|
||||
for (const line of allLines(history)) {
|
||||
expect(line).not.toHaveProperty('journal_entry_id')
|
||||
}
|
||||
})
|
||||
|
||||
it('emits the entries in date order so the voucher sequence stays chronological', () => {
|
||||
const dates = buildSandboxLedgerHistory(input).entries.map((e) => e.entry_date)
|
||||
|
||||
expect([...dates].sort()).toEqual(dates)
|
||||
})
|
||||
|
||||
it('leaves a plausibly profitable year to date', () => {
|
||||
const lines = allLines(buildSandboxLedgerHistory(input))
|
||||
|
||||
const revenue = roundOre(
|
||||
lines
|
||||
.filter((l) => l.account_number.startsWith('3'))
|
||||
.reduce((n, l) => n + l.credit_amount - l.debit_amount, 0),
|
||||
)
|
||||
const costs = roundOre(
|
||||
lines
|
||||
.filter((l) => /^[4-7]/.test(l.account_number))
|
||||
.reduce((n, l) => n + l.debit_amount - l.credit_amount, 0),
|
||||
)
|
||||
|
||||
expect(revenue).toBeGreaterThan(0)
|
||||
expect(costs).toBeGreaterThan(0)
|
||||
expect(revenue).toBeGreaterThan(costs * 2)
|
||||
})
|
||||
|
||||
it('keeps the bank account positive throughout the year to date', () => {
|
||||
const history = buildSandboxLedgerHistory(input)
|
||||
|
||||
// A demo Balansrapport with a negative företagskonto reads as a bug. The
|
||||
// January owner contribution is what prevents it: the fiscal year is the
|
||||
// company's first period, so 1930 starts at zero.
|
||||
let balance = 0
|
||||
history.linesByEntryIndex.forEach((lines) => {
|
||||
for (const line of lines) {
|
||||
if (line.account_number !== '1930') continue
|
||||
balance = roundOre(balance + line.debit_amount - line.credit_amount)
|
||||
}
|
||||
expect(balance).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('books output VAT on 2611 and input VAT on 2641', () => {
|
||||
const lines = allLines(buildSandboxLedgerHistory(input))
|
||||
|
||||
const outputVat = lines.filter((l) => l.account_number === '2611')
|
||||
const inputVat = lines.filter((l) => l.account_number === '2641')
|
||||
|
||||
expect(outputVat.length).toBeGreaterThan(0)
|
||||
expect(inputVat.length).toBeGreaterThan(0)
|
||||
|
||||
// Trading entries only: sales credit 2611, purchases debit 2641. The one
|
||||
// exception each quarter is the momsredovisning, which clears both the
|
||||
// other way. Identify it by the 2650 line rather than by sign, so this
|
||||
// stays a real assertion about direction.
|
||||
const settlementIndexes = new Set(
|
||||
buildSandboxLedgerHistory(input)
|
||||
.linesByEntryIndex.map((entryLines, index) =>
|
||||
entryLines.some((l) => l.account_number === '2650') ? index : -1,
|
||||
)
|
||||
.filter((index) => index >= 0),
|
||||
)
|
||||
const tradingLines = buildSandboxLedgerHistory(input).linesByEntryIndex.flatMap(
|
||||
(entryLines, index) => (settlementIndexes.has(index) ? [] : entryLines),
|
||||
)
|
||||
|
||||
expect(
|
||||
tradingLines
|
||||
.filter((l) => l.account_number === '2611')
|
||||
.every((l) => l.credit_amount > 0 && l.debit_amount === 0),
|
||||
).toBe(true)
|
||||
expect(
|
||||
tradingLines
|
||||
.filter((l) => l.account_number === '2641')
|
||||
.every((l) => l.debit_amount > 0 && l.credit_amount === 0),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('settles each closed VAT quarter to 2650 and pays it from the bank', () => {
|
||||
const history = buildSandboxLedgerHistory(input)
|
||||
|
||||
// Each closed quarter clears its 26xx accounts to 2650 on the last day of
|
||||
// the quarter: the ordinary period-close entry, not the filing itself.
|
||||
// Today is 6 August 2026, so Q1 is cleared (31 March) and paid (12 May),
|
||||
// and Q2 is cleared (30 June) but unpaid: its deadline is 17 August.
|
||||
const declaration = history.entries.findIndex(e => e.description === 'Momsredovisning 2026 Q1')
|
||||
const payment = history.entries.findIndex(e => e.description === 'Betald moms 2026 Q1')
|
||||
expect(declaration).toBeGreaterThanOrEqual(0)
|
||||
expect(payment).toBeGreaterThan(declaration)
|
||||
expect(history.entries[declaration].entry_date).toBe('2026-03-31')
|
||||
expect(history.entries[payment].entry_date).toBe('2026-05-12')
|
||||
|
||||
const q2Declaration = history.entries.find(e => e.description === 'Momsredovisning 2026 Q2')
|
||||
expect(q2Declaration?.entry_date).toBe('2026-06-30')
|
||||
expect(history.entries.some(e => e.description === 'Betald moms 2026 Q2')).toBe(false)
|
||||
|
||||
// The declaration carries the settlement SHAPE get_vat_declaration_totals
|
||||
// looks for: a VAT-account line plus a 2650 line. Without it the quarter
|
||||
// would be counted again in the next declaration.
|
||||
const declarationLines = history.linesByEntryIndex[declaration]
|
||||
expect(declarationLines.find(l => l.account_number === '2611')!.debit_amount).toBeGreaterThan(0)
|
||||
expect(declarationLines.find(l => l.account_number === '2641')!.credit_amount).toBeGreaterThan(0)
|
||||
const netPayable = declarationLines.find(l => l.account_number === '2650')!.credit_amount
|
||||
expect(netPayable).toBeGreaterThan(0)
|
||||
|
||||
// The payment moves exactly that amount off the bank, and touches no VAT
|
||||
// account, so it is not itself settlement-shaped.
|
||||
const paymentLines = history.linesByEntryIndex[payment]
|
||||
expect(paymentLines.find(l => l.account_number === '2650')!.debit_amount).toBe(netPayable)
|
||||
expect(paymentLines.find(l => l.account_number === '1930')!.credit_amount).toBe(netPayable)
|
||||
expect(paymentLines.some(l => ['2611', '2641'].includes(l.account_number))).toBe(false)
|
||||
})
|
||||
|
||||
it('is deterministic', () => {
|
||||
const first = buildSandboxLedgerHistory(input)
|
||||
const second = buildSandboxLedgerHistory(input)
|
||||
|
||||
expect(first).toEqual(second)
|
||||
expect(JSON.stringify(first)).toBe(JSON.stringify(second))
|
||||
})
|
||||
|
||||
it('does not depend on the day of the month or the local clock', () => {
|
||||
const early = buildSandboxLedgerHistory({ ...input, today: new Date(2026, 7, 1) })
|
||||
const late = buildSandboxLedgerHistory({ ...input, today: new Date(2026, 7, 31) })
|
||||
|
||||
expect(early).toEqual(late)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildSandboxSalaryVouchers } from '../salary-vouchers'
|
||||
|
||||
const BASE = {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
fiscalPeriodId: 'fp-1',
|
||||
salaryRunId: 'run-1',
|
||||
paymentDate: '2026-07-25',
|
||||
periodYear: 2026,
|
||||
periodMonth: 7,
|
||||
}
|
||||
|
||||
/** Numbers shaped like the seeded run: gross = tax + net, avgifter 31.42%,
|
||||
* vacation accrual 12% of gross with avgifter on top. */
|
||||
const TOTALS = {
|
||||
totalGross: 57600,
|
||||
totalTax: 16416,
|
||||
totalNet: 41184,
|
||||
totalAvgifter: 18097.92,
|
||||
totalVacationAccrual: 6912,
|
||||
totalVacationAvgifter: 2171.75,
|
||||
}
|
||||
|
||||
const sum = (ns: number[]) => Math.round(ns.reduce((a, b) => a + b, 0) * 100) / 100
|
||||
|
||||
describe('buildSandboxSalaryVouchers', () => {
|
||||
it('produces the three engine-equivalent vouchers', () => {
|
||||
const vouchers = buildSandboxSalaryVouchers({ ...BASE, ...TOTALS })
|
||||
expect(vouchers.map((v) => v.runColumn)).toEqual([
|
||||
'salary_entry_id',
|
||||
'avgifter_entry_id',
|
||||
'vacation_entry_id',
|
||||
])
|
||||
})
|
||||
|
||||
it('balances every voucher with a non-zero total', () => {
|
||||
for (const { entry, lines } of buildSandboxSalaryVouchers({ ...BASE, ...TOTALS })) {
|
||||
const debit = sum(lines.map((l) => l.debit_amount))
|
||||
const credit = sum(lines.map((l) => l.credit_amount))
|
||||
expect(debit, `${entry.description} debit vs credit`).toBe(credit)
|
||||
expect(debit).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('books salary on 7210 against 2710 personalskatt and 1930 bank', () => {
|
||||
const [salary] = buildSandboxSalaryVouchers({ ...BASE, ...TOTALS })
|
||||
expect(salary.lines).toEqual([
|
||||
expect.objectContaining({ account_number: '7210', debit_amount: 57600, credit_amount: 0 }),
|
||||
expect.objectContaining({ account_number: '2710', debit_amount: 0, credit_amount: 16416 }),
|
||||
expect.objectContaining({ account_number: '1930', debit_amount: 0, credit_amount: 41184 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('books arbetsgivaravgifter on 7510 against 2731', () => {
|
||||
const [, avgifter] = buildSandboxSalaryVouchers({ ...BASE, ...TOTALS })
|
||||
expect(avgifter.lines.map((l) => l.account_number)).toEqual(['7510', '2731'])
|
||||
})
|
||||
|
||||
it('books the vacation accrual on 7290/2920 and its avgifter on 7519/2940', () => {
|
||||
const [, , vacation] = buildSandboxSalaryVouchers({ ...BASE, ...TOTALS })
|
||||
expect(vacation.lines.map((l) => l.account_number)).toEqual(['7290', '2920', '7519', '2940'])
|
||||
expect(vacation.lines.map((l) => l.sort_order)).toEqual([0, 1, 2, 3])
|
||||
})
|
||||
|
||||
it('omits the vacation voucher when nothing accrued (the engine posts no zero voucher)', () => {
|
||||
const vouchers = buildSandboxSalaryVouchers({
|
||||
...BASE,
|
||||
...TOTALS,
|
||||
totalVacationAccrual: 0,
|
||||
totalVacationAvgifter: 0,
|
||||
})
|
||||
expect(vouchers).toHaveLength(2)
|
||||
expect(vouchers.some((v) => v.runColumn === 'vacation_entry_id')).toBe(false)
|
||||
})
|
||||
|
||||
it('sets every line dimensions bag explicitly (PostgREST bulk-insert normalization)', () => {
|
||||
for (const { lines } of buildSandboxSalaryVouchers({ ...BASE, ...TOTALS })) {
|
||||
for (const line of lines) {
|
||||
expect(line.dimensions).toEqual({})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('stamps every entry as a posted salary_payment on the run', () => {
|
||||
for (const { entry } of buildSandboxSalaryVouchers({ ...BASE, ...TOTALS })) {
|
||||
expect(entry.source_type).toBe('salary_payment')
|
||||
expect(entry.source_id).toBe('run-1')
|
||||
expect(entry.status).toBe('posted')
|
||||
expect(entry.entry_date).toBe('2026-07-25')
|
||||
expect(entry.committed_at).toBe('2026-07-25')
|
||||
expect(entry.voucher_series).toMatch(/^[A-Z]$/)
|
||||
expect(entry.description).toContain('2026-07')
|
||||
}
|
||||
})
|
||||
|
||||
it('account numbers are strings, never numbers', () => {
|
||||
for (const { lines } of buildSandboxSalaryVouchers({ ...BASE, ...TOTALS })) {
|
||||
for (const line of lines) {
|
||||
expect(typeof line.account_number).toBe('string')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('throws rather than writing an unbalanced verifikat', () => {
|
||||
expect(() =>
|
||||
buildSandboxSalaryVouchers({ ...BASE, ...TOTALS, totalNet: 41000 }),
|
||||
).toThrow(/would not balance/)
|
||||
})
|
||||
|
||||
it('rounds to ore instead of accumulating float drift', () => {
|
||||
const [salary] = buildSandboxSalaryVouchers({
|
||||
...BASE,
|
||||
...TOTALS,
|
||||
totalGross: 0.1 + 0.2,
|
||||
totalTax: 0.1,
|
||||
totalNet: 0.2,
|
||||
})
|
||||
expect(salary.lines[0].debit_amount).toBe(0.3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,569 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { getLineItemAccount } from '@/lib/salary/account-mapping'
|
||||
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
|
||||
import { extractBirthDate, validatePersonnummer } from '@/lib/salary/personnummer'
|
||||
import { FALLBACK_TAX_TABLES_2026 } from '@/lib/salary/tax-tables-fallback'
|
||||
import {
|
||||
SANDBOX_EMPLOYEE_LAST_NAMES,
|
||||
SANDBOX_PAYROLL_FIGURES,
|
||||
SANDBOX_TOTAL_VACATION_ACCRUAL_AVGIFTER,
|
||||
buildSandboxEmployees,
|
||||
buildSandboxSalaryLineItems,
|
||||
buildSandboxSalaryRunEmployees,
|
||||
buildSandboxSalaryRuns,
|
||||
mapSandboxEmployeeIds,
|
||||
resolveSandboxSalaryPeriods,
|
||||
} from '../salary'
|
||||
|
||||
// Mid-year reference date: the previous month is May, inside the same
|
||||
// calendar year, so this exercises the normal (non-January) path.
|
||||
const TODAY = new Date(2026, 5, 11) // 2026-06-11, local calendar
|
||||
const USER_ID = 'user-1'
|
||||
const COMPANY_ID = 'company-1'
|
||||
|
||||
const BOOKED_RUN_ID = 'run-booked'
|
||||
const DRAFT_RUN_ID = 'run-draft'
|
||||
const ANNA_ID = 'emp-anna'
|
||||
const ERIK_ID = 'emp-erik'
|
||||
|
||||
/** Records what was handed to encryptPersonnummer so the test can check it. */
|
||||
function fakeEncrypt() {
|
||||
const seen: string[] = []
|
||||
return {
|
||||
seen,
|
||||
encrypt: (personnummer: string) => {
|
||||
seen.push(personnummer)
|
||||
return `cipher:${personnummer}`
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function employees(today: Date = TODAY) {
|
||||
const { encrypt, seen } = fakeEncrypt()
|
||||
const rows = buildSandboxEmployees({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
today,
|
||||
encrypt,
|
||||
})
|
||||
return { rows, plaintext: seen }
|
||||
}
|
||||
|
||||
function runEmployeeRows(today: Date = TODAY) {
|
||||
return buildSandboxSalaryRunEmployees({
|
||||
companyId: COMPANY_ID,
|
||||
today,
|
||||
bookedRunId: BOOKED_RUN_ID,
|
||||
draftRunId: DRAFT_RUN_ID,
|
||||
annaEmployeeId: ANNA_ID,
|
||||
erikEmployeeId: ERIK_ID,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgREST normalizes the key set across a bulk insert: a key present on one
|
||||
* row and missing on another is sent as null for the missing row, which either
|
||||
* violates NOT NULL or clobbers a schema default. Every builder must therefore
|
||||
* emit the same keys on every row.
|
||||
*/
|
||||
function assertUniformKeys(rows: Array<Record<string, unknown>>) {
|
||||
const reference = Object.keys(rows[0]).sort().join(',')
|
||||
for (const row of rows) {
|
||||
expect(Object.keys(row).sort().join(',')).toBe(reference)
|
||||
}
|
||||
}
|
||||
|
||||
const AVGIFTER_RATE = 0.3142
|
||||
|
||||
describe('sandbox employee seed data', () => {
|
||||
it('emits two active employees with every NOT NULL column set', () => {
|
||||
const { rows } = employees()
|
||||
|
||||
expect(rows).toHaveLength(2)
|
||||
assertUniformKeys(rows)
|
||||
|
||||
for (const emp of rows) {
|
||||
expect(emp.user_id).toBe(USER_ID)
|
||||
expect(emp.company_id).toBe(COMPANY_ID)
|
||||
expect(emp.first_name).toBeTruthy()
|
||||
expect(emp.last_name).toBeTruthy()
|
||||
expect(emp.personnummer).toBeTruthy()
|
||||
expect(emp.personnummer_last4).toMatch(/^\d{4}$/)
|
||||
expect(emp.employment_start).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||
expect(emp.is_active).toBe(true)
|
||||
expect(emp.is_sidoinkomst).toBe(false)
|
||||
expect(emp.vacation_days_per_year).toBe(25)
|
||||
}
|
||||
|
||||
expect(rows.map((e) => e.last_name)).toEqual([
|
||||
SANDBOX_EMPLOYEE_LAST_NAMES.anna,
|
||||
SANDBOX_EMPLOYEE_LAST_NAMES.erik,
|
||||
])
|
||||
// AGI FK570 specification numbers: unique per company, 1 and 2 here.
|
||||
expect(rows.map((e) => e.specification_number)).toEqual([1, 2])
|
||||
// No address at all: the payslip-send action is reachable on the seeded
|
||||
// booked run, and a demo address would mean real outbound mail from the
|
||||
// production sending domain to a domain with no MX.
|
||||
expect(rows.map((e) => e.email)).toEqual([null, null])
|
||||
})
|
||||
|
||||
it('takes skatteavdrag from the real Skatteverket 2026 table, not a schablon', () => {
|
||||
const table = FALLBACK_TAX_TABLES_2026[34]
|
||||
const lookup = (income: number) =>
|
||||
table.find(([from, to]) => income >= from && income <= to)![2]
|
||||
|
||||
// Column 1 of table 34: ordinary employment income, under 66.
|
||||
expect(SANDBOX_PAYROLL_FIGURES.anna.taxWithheld).toBe(lookup(38000))
|
||||
expect(SANDBOX_PAYROLL_FIGURES.erik.taxWithheld).toBe(lookup(245 * 80))
|
||||
|
||||
// A flat 30 % / 24 % schablon was the previous behaviour; assert we are
|
||||
// nowhere near it, so a regression back to one fails loudly.
|
||||
expect(SANDBOX_PAYROLL_FIGURES.anna.taxWithheld).not.toBe(38000 * 0.3)
|
||||
expect(SANDBOX_PAYROLL_FIGURES.erik.taxWithheld).not.toBe(245 * 80 * 0.24)
|
||||
})
|
||||
|
||||
it('satisfies every CHECK constraint in migration 20260414120000', () => {
|
||||
const { rows } = employees()
|
||||
|
||||
for (const emp of rows) {
|
||||
expect(['employee', 'company_owner', 'board_member']).toContain(emp.employment_type)
|
||||
expect(['monthly', 'hourly']).toContain(emp.salary_type)
|
||||
expect(emp.tax_column as number).toBeGreaterThanOrEqual(1)
|
||||
expect(emp.tax_column as number).toBeLessThanOrEqual(6)
|
||||
// Widened twice since the original migration (20260512200000 added
|
||||
// 'none', 20260513160559 added 'semesterersattning').
|
||||
expect(['procentregeln', 'sammaloneregeln', 'none', 'semesterersattning']).toContain(
|
||||
emp.vacation_rule,
|
||||
)
|
||||
expect(['a_skatt', 'f_skatt', 'fa_skatt', 'not_verified']).toContain(emp.f_skatt_status)
|
||||
expect(emp.employment_degree as number).toBeGreaterThan(0)
|
||||
expect(emp.employment_degree as number).toBeLessThanOrEqual(100)
|
||||
}
|
||||
})
|
||||
|
||||
it('stores the personnummer encrypted, with the plain last 4 alongside', () => {
|
||||
const { rows, plaintext } = employees()
|
||||
|
||||
expect(plaintext).toHaveLength(2)
|
||||
for (const [i, emp] of rows.entries()) {
|
||||
const plain = plaintext[i]
|
||||
// The ciphertext, never the plaintext, goes into the column.
|
||||
expect(emp.personnummer).toBe(`cipher:${plain}`)
|
||||
expect(emp.personnummer).not.toBe(plain)
|
||||
expect(emp.personnummer_last4).toBe(plain.slice(-4))
|
||||
}
|
||||
})
|
||||
|
||||
it('uses obviously fake samordningsnummer that still pass validation', () => {
|
||||
const { plaintext } = employees()
|
||||
|
||||
for (const pnr of plaintext) {
|
||||
// Passes format + Luhn, so the demo user can open and save the employee
|
||||
// without the update schema rejecting the seeded row.
|
||||
expect(validatePersonnummer(pnr)).toEqual({ valid: true })
|
||||
// Day field carries the +60 samordningsnummer offset: not a real
|
||||
// person's personnummer, by construction.
|
||||
expect(Number(pnr.slice(6, 8))).toBeGreaterThan(60)
|
||||
expect(extractBirthDate(pnr).day).toBeLessThanOrEqual(31)
|
||||
}
|
||||
})
|
||||
|
||||
it('pairs a monthly employee with an hourly one', () => {
|
||||
const [anna, erik] = employees().rows
|
||||
|
||||
expect(anna.salary_type).toBe('monthly')
|
||||
expect(anna.monthly_salary).toBe(38000)
|
||||
expect(anna.hourly_rate).toBeNull()
|
||||
expect(anna.employment_degree).toBe(100)
|
||||
expect(anna.tax_municipality).toBe('Stockholm')
|
||||
|
||||
expect(erik.salary_type).toBe('hourly')
|
||||
expect(erik.hourly_rate).toBe(245)
|
||||
expect(erik.monthly_salary).toBeNull()
|
||||
expect(erik.employment_degree).toBe(50)
|
||||
})
|
||||
|
||||
it('gives both employees payable bank details', () => {
|
||||
for (const emp of employees().rows) {
|
||||
expect(
|
||||
validateEmployeeBankAccount(
|
||||
emp.clearing_number as string,
|
||||
emp.bank_account_number as string,
|
||||
),
|
||||
).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('starts both employments before the booked period', () => {
|
||||
const { rows } = employees()
|
||||
const { booked } = resolveSandboxSalaryPeriods(TODAY)
|
||||
const periodStart = `${booked.year}-${String(booked.month).padStart(2, '0')}-01`
|
||||
|
||||
for (const emp of rows) {
|
||||
expect((emp.employment_start as string) < periodStart).toBe(true)
|
||||
}
|
||||
// Roughly two years and eight months back respectively.
|
||||
expect(rows[0].employment_start).toBe('2024-06-11')
|
||||
expect(rows[1].employment_start).toBe('2025-10-11')
|
||||
})
|
||||
|
||||
it('resolves the inserted rows back to the two employees', () => {
|
||||
expect(
|
||||
mapSandboxEmployeeIds([
|
||||
{ id: ERIK_ID, last_name: SANDBOX_EMPLOYEE_LAST_NAMES.erik },
|
||||
{ id: ANNA_ID, last_name: SANDBOX_EMPLOYEE_LAST_NAMES.anna },
|
||||
]),
|
||||
).toEqual({ annaEmployeeId: ANNA_ID, erikEmployeeId: ERIK_ID })
|
||||
|
||||
expect(() =>
|
||||
mapSandboxEmployeeIds([{ id: ANNA_ID, last_name: SANDBOX_EMPLOYEE_LAST_NAMES.anna }]),
|
||||
).toThrow(/employee ids/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox salary run seed data', () => {
|
||||
it('emits one booked and one draft run with a legal status and series', () => {
|
||||
const runs = buildSandboxSalaryRuns({ userId: USER_ID, companyId: COMPANY_ID, today: TODAY })
|
||||
|
||||
expect(runs).toHaveLength(2)
|
||||
assertUniformKeys(runs)
|
||||
expect(runs.map((r) => r.status)).toEqual(['booked', 'draft'])
|
||||
|
||||
for (const run of runs) {
|
||||
expect(['draft', 'review', 'approved', 'paid', 'booked', 'corrected']).toContain(run.status)
|
||||
expect(run.voucher_series as string).toMatch(/^[A-Z]$/)
|
||||
expect(run.period_month as number).toBeGreaterThanOrEqual(1)
|
||||
expect(run.period_month as number).toBeLessThanOrEqual(12)
|
||||
expect(run.payment_date as string).toMatch(/^\d{4}-\d{2}-25$/)
|
||||
expect(run.user_id).toBe(USER_ID)
|
||||
expect(run.company_id).toBe(COMPANY_ID)
|
||||
// Non-null calculation_params is what makes the run detail page render
|
||||
// the KPI cards, Beräkningsdetaljer and the bokförings-preview.
|
||||
expect(run.calculation_params).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('completes the booked run and leaves the draft untouched', () => {
|
||||
const [booked, draft] = buildSandboxSalaryRuns({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
today: TODAY,
|
||||
})
|
||||
|
||||
expect(booked.paid_at).toBeTruthy()
|
||||
expect(booked.booked_at).toBeTruthy()
|
||||
expect(booked.booked_by).toBe(USER_ID)
|
||||
expect(booked.approved_by).toBe(USER_ID)
|
||||
// Never stamped ahead of "today": in January the payday can still be in
|
||||
// the future when the sandbox is seeded.
|
||||
expect((booked.booked_at as string) <= '2026-06-11T23:59:59.999Z').toBe(true)
|
||||
|
||||
expect(draft.paid_at).toBeNull()
|
||||
expect(draft.booked_at).toBeNull()
|
||||
expect(draft.booked_by).toBeNull()
|
||||
expect(draft.approved_at).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps run totals equal to the sum of the per-employee rows', () => {
|
||||
const runs = buildSandboxSalaryRuns({ userId: USER_ID, companyId: COMPANY_ID, today: TODAY })
|
||||
const perEmployee = runEmployeeRows()
|
||||
|
||||
const runIdOf = (status: string) =>
|
||||
status === 'booked' ? BOOKED_RUN_ID : DRAFT_RUN_ID
|
||||
|
||||
for (const run of runs) {
|
||||
const rows = perEmployee.filter((r) => r.salary_run_id === runIdOf(run.status))
|
||||
expect(rows).toHaveLength(2)
|
||||
|
||||
const sum = (pick: (r: (typeof rows)[number]) => number) =>
|
||||
roundOre(rows.reduce((n, r) => n + pick(r), 0))
|
||||
|
||||
expect(run.total_gross).toBe(sum((r) => r.gross_salary))
|
||||
expect(run.total_tax).toBe(sum((r) => r.tax_withheld))
|
||||
expect(run.total_net).toBe(sum((r) => r.net_salary))
|
||||
expect(run.total_avgifter).toBe(sum((r) => r.avgifter_amount))
|
||||
expect(run.total_vacation_accrual).toBe(sum((r) => r.vacation_accrual))
|
||||
expect(run.total_employer_cost).toBe(
|
||||
sum(
|
||||
(r) =>
|
||||
r.gross_salary +
|
||||
r.avgifter_amount +
|
||||
r.vacation_accrual +
|
||||
r.vacation_accrual_avgifter,
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('exports the vacation-avgifter total the semester voucher needs', () => {
|
||||
const rows = runEmployeeRows().filter((r) => r.salary_run_id === BOOKED_RUN_ID)
|
||||
|
||||
expect(SANDBOX_TOTAL_VACATION_ACCRUAL_AVGIFTER).toBe(
|
||||
roundOre(rows.reduce((n, r) => n + r.vacation_accrual_avgifter, 0)),
|
||||
)
|
||||
// Not a salary_runs column: it must never leak onto the run row.
|
||||
const [booked] = buildSandboxSalaryRuns({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
today: TODAY,
|
||||
})
|
||||
expect(Object.keys(booked)).not.toContain('total_vacation_accrual_avgifter')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox salary periods', () => {
|
||||
it('puts the booked run in the previous month during the rest of the year', () => {
|
||||
expect(resolveSandboxSalaryPeriods(new Date(2026, 5, 11))).toEqual({
|
||||
booked: { year: 2026, month: 5, paymentDate: '2026-05-25' },
|
||||
draft: { year: 2026, month: 6, paymentDate: '2026-06-25' },
|
||||
})
|
||||
expect(resolveSandboxSalaryPeriods(new Date(2026, 11, 31))).toEqual({
|
||||
booked: { year: 2026, month: 11, paymentDate: '2026-11-25' },
|
||||
draft: { year: 2026, month: 12, paymentDate: '2026-12-25' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps both January runs inside the current fiscal year', () => {
|
||||
// The sandbox seeds exactly one fiscal period, the current calendar year.
|
||||
// December of the previous year has no period to book against.
|
||||
const periods = resolveSandboxSalaryPeriods(new Date(2026, 0, 3))
|
||||
expect(periods).toEqual({
|
||||
booked: { year: 2026, month: 1, paymentDate: '2026-01-25' },
|
||||
draft: { year: 2026, month: 2, paymentDate: '2026-02-25' },
|
||||
})
|
||||
|
||||
const runs = buildSandboxSalaryRuns({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
today: new Date(2026, 0, 3),
|
||||
})
|
||||
for (const run of runs) {
|
||||
expect(run.period_year).toBe(2026)
|
||||
expect(run.payment_date as string).toMatch(/^2026-/)
|
||||
}
|
||||
expect(runs.map((r) => r.period_month)).toEqual([1, 2])
|
||||
// And the completion stamps stay on or before "today" even though the
|
||||
// January payday has not arrived yet.
|
||||
expect(runs[0].booked_at).toBe('2026-01-03T09:00:00.000Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox salary run employee seed data', () => {
|
||||
it('emits both employees on both runs', () => {
|
||||
const rows = runEmployeeRows()
|
||||
|
||||
expect(rows).toHaveLength(4)
|
||||
assertUniformKeys(rows)
|
||||
expect(rows.filter((r) => r.salary_run_id === BOOKED_RUN_ID)).toHaveLength(2)
|
||||
expect(rows.filter((r) => r.salary_run_id === DRAFT_RUN_ID)).toHaveLength(2)
|
||||
|
||||
for (const row of rows) {
|
||||
expect(row.company_id).toBe(COMPANY_ID)
|
||||
expect([ANNA_ID, ERIK_ID]).toContain(row.employee_id)
|
||||
expect(['monthly', 'hourly']).toContain(row.salary_type)
|
||||
expect(['standard', 'reduced_65plus', 'youth', 'vaxa_stod', 'exempt']).toContain(
|
||||
row.avgifter_category,
|
||||
)
|
||||
expect(row.tax_table_number).toBe(34)
|
||||
expect(row.tax_column).toBe(1)
|
||||
expect(row.tax_table_year).toBe(2026)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps net = taxable - tax - net deductions on every row', () => {
|
||||
for (const row of runEmployeeRows()) {
|
||||
expect(row.net_salary).toBe(
|
||||
roundOre(row.taxable_income - row.tax_withheld - row.net_deductions),
|
||||
)
|
||||
// No förmåner in the demo, so the tax base is the gross salary.
|
||||
expect(row.taxable_income).toBe(row.gross_salary)
|
||||
expect(row.benefit_values).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('derives avgifter and semesteravsättning from the 2026 rates', () => {
|
||||
for (const row of runEmployeeRows()) {
|
||||
expect(row.avgifter_rate).toBe(AVGIFTER_RATE)
|
||||
expect(row.avgifter_basis).toBe(row.gross_salary)
|
||||
expect(row.avgifter_amount).toBe(roundOre(row.gross_salary * AVGIFTER_RATE))
|
||||
// Procentregeln, 25 semesterdagar.
|
||||
expect(row.vacation_accrual).toBe(roundOre(row.gross_salary * 0.12))
|
||||
expect(row.vacation_accrual_avgifter).toBe(roundOre(row.vacation_accrual * AVGIFTER_RATE))
|
||||
}
|
||||
})
|
||||
|
||||
it('snapshots the monthly and hourly branches the way the engine does', () => {
|
||||
const rows = runEmployeeRows()
|
||||
const anna = rows.find((r) => r.employee_id === ANNA_ID)!
|
||||
const erik = rows.find((r) => r.employee_id === ERIK_ID)!
|
||||
|
||||
expect(anna.monthly_salary).toBe(38000)
|
||||
expect(anna.hours_worked).toBeNull()
|
||||
expect(anna.gross_salary).toBe(38000)
|
||||
|
||||
// createSalaryRunWithEmployees writes 0 for an hourly employee's monthly
|
||||
// salary snapshot; the gross comes from timlön × timmar.
|
||||
expect(erik.monthly_salary).toBe(0)
|
||||
expect(erik.hours_worked).toBe(80)
|
||||
expect(erik.gross_salary).toBe(roundOre(80 * 245))
|
||||
})
|
||||
|
||||
it('accumulates YTD from the booked run into the draft run', () => {
|
||||
const rows = runEmployeeRows()
|
||||
|
||||
for (const employeeId of [ANNA_ID, ERIK_ID]) {
|
||||
const booked = rows.find(
|
||||
(r) => r.employee_id === employeeId && r.salary_run_id === BOOKED_RUN_ID,
|
||||
)!
|
||||
const draft = rows.find(
|
||||
(r) => r.employee_id === employeeId && r.salary_run_id === DRAFT_RUN_ID,
|
||||
)!
|
||||
|
||||
// The booked run is the earliest payroll in the sandbox, so its YTD is
|
||||
// its own month.
|
||||
expect(booked.ytd_gross).toBe(booked.gross_salary)
|
||||
expect(booked.ytd_tax).toBe(booked.tax_withheld)
|
||||
expect(booked.ytd_net).toBe(booked.net_salary)
|
||||
|
||||
expect(draft.ytd_gross).toBe(roundOre(booked.ytd_gross + draft.gross_salary))
|
||||
expect(draft.ytd_tax).toBe(roundOre(booked.ytd_tax + draft.tax_withheld))
|
||||
expect(draft.ytd_net).toBe(roundOre(booked.ytd_net + draft.net_salary))
|
||||
}
|
||||
})
|
||||
|
||||
it('carries a calculation breakdown whose skatteavdrag step names the real table', () => {
|
||||
for (const row of runEmployeeRows()) {
|
||||
const breakdown = row.calculation_breakdown as {
|
||||
steps: Array<{ label: string; formula: string; output: number | null }>
|
||||
}
|
||||
expect(breakdown.steps.length).toBeGreaterThan(0)
|
||||
|
||||
const tax = breakdown.steps.find((s) => s.label.startsWith('Skatteavdrag'))!
|
||||
expect(tax.output).toBe(row.tax_withheld)
|
||||
// The step names the table it came from, and it really came from there.
|
||||
expect(tax.formula).toContain('skattetabell 34')
|
||||
expect(tax.formula).toContain('kolumn 1')
|
||||
|
||||
const total = breakdown.steps.find((s) => s.label === 'Total arbetsgivarkostnad')!
|
||||
expect(total.output).toBe(
|
||||
roundOre(
|
||||
row.gross_salary +
|
||||
row.avgifter_amount +
|
||||
row.vacation_accrual +
|
||||
row.vacation_accrual_avgifter,
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('rounds every money column to öre', () => {
|
||||
const moneyColumns = [
|
||||
'gross_salary',
|
||||
'taxable_income',
|
||||
'tax_withheld',
|
||||
'net_salary',
|
||||
'avgifter_amount',
|
||||
'avgifter_basis',
|
||||
'vacation_accrual',
|
||||
'vacation_accrual_avgifter',
|
||||
'ytd_gross',
|
||||
'ytd_tax',
|
||||
'ytd_net',
|
||||
] as const
|
||||
|
||||
for (const row of runEmployeeRows()) {
|
||||
for (const column of moneyColumns) {
|
||||
const value = row[column]
|
||||
expect(Number.isFinite(value)).toBe(true)
|
||||
expect(value).toBe(roundOre(value))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox salary line item seed data', () => {
|
||||
const inserted = [
|
||||
{ id: 'sre-booked-anna', employee_id: ANNA_ID },
|
||||
{ id: 'sre-booked-erik', employee_id: ERIK_ID },
|
||||
{ id: 'sre-draft-anna', employee_id: ANNA_ID },
|
||||
{ id: 'sre-draft-erik', employee_id: ERIK_ID },
|
||||
]
|
||||
|
||||
function lineItems() {
|
||||
return buildSandboxSalaryLineItems({
|
||||
companyId: COMPANY_ID,
|
||||
annaEmployeeId: ANNA_ID,
|
||||
erikEmployeeId: ERIK_ID,
|
||||
runEmployees: inserted,
|
||||
})
|
||||
}
|
||||
|
||||
it('emits one payslip line per calculation row', () => {
|
||||
const rows = lineItems()
|
||||
|
||||
expect(rows).toHaveLength(4)
|
||||
assertUniformKeys(rows)
|
||||
expect(rows.map((r) => r.salary_run_employee_id)).toEqual(inserted.map((r) => r.id))
|
||||
for (const row of rows) {
|
||||
expect(row.company_id).toBe(COMPANY_ID)
|
||||
expect(['monthly_salary', 'hourly_salary']).toContain(row.item_type)
|
||||
expect(row.is_taxable).toBe(true)
|
||||
expect(row.is_avgift_basis).toBe(true)
|
||||
expect(row.is_vacation_basis).toBe(true)
|
||||
expect(row.is_gross_deduction).toBe(false)
|
||||
expect(row.is_net_deduction).toBe(false)
|
||||
expect(row.sort_order).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('takes the BAS account from the salary account map, as a string', () => {
|
||||
for (const row of lineItems()) {
|
||||
expect(typeof row.account_number).toBe('string')
|
||||
expect(row.account_number).toBe(getLineItemAccount(row.item_type, 'employee'))
|
||||
// Ordinary employees: 7210 Löner till tjänstemän.
|
||||
expect(row.account_number).toBe('7210')
|
||||
}
|
||||
})
|
||||
|
||||
it('prices the hourly line as timmar × timlön', () => {
|
||||
const rows = lineItems()
|
||||
const anna = rows.find((r) => r.salary_run_employee_id === 'sre-booked-anna')!
|
||||
const erik = rows.find((r) => r.salary_run_employee_id === 'sre-booked-erik')!
|
||||
|
||||
expect(anna.item_type).toBe('monthly_salary')
|
||||
expect(anna.description).toBe('Grundlön')
|
||||
expect(anna.amount).toBe(38000)
|
||||
|
||||
expect(erik.item_type).toBe('hourly_salary')
|
||||
expect(erik.description).toBe('Timlön')
|
||||
expect(erik.quantity).toBe(80)
|
||||
expect(erik.unit_price).toBe(245)
|
||||
expect(erik.amount).toBe(roundOre(80 * 245))
|
||||
})
|
||||
|
||||
it('matches the gross salary on the calculation row it belongs to', () => {
|
||||
const runEmployees = runEmployeeRows()
|
||||
const rows = buildSandboxSalaryLineItems({
|
||||
companyId: COMPANY_ID,
|
||||
annaEmployeeId: ANNA_ID,
|
||||
erikEmployeeId: ERIK_ID,
|
||||
runEmployees: runEmployees.map((r, i) => ({ id: `sre-${i}`, employee_id: r.employee_id })),
|
||||
})
|
||||
|
||||
for (const [i, row] of rows.entries()) {
|
||||
expect(row.amount).toBe(runEmployees[i].gross_salary)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses to build a line for an unknown employee', () => {
|
||||
expect(() =>
|
||||
buildSandboxSalaryLineItems({
|
||||
companyId: COMPANY_ID,
|
||||
annaEmployeeId: ANNA_ID,
|
||||
erikEmployeeId: ERIK_ID,
|
||||
runEmployees: [{ id: 'sre-x', employee_id: 'someone-else' }],
|
||||
}),
|
||||
).toThrow(/unknown employee/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user