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:
Jakob Wennberg
2026-08-06 15:30:04 +02:00
committed by GitHub
co-authored by Claude Opus 5 Jakob Wennberg
parent 43386b4852
commit d41ef2a909
18 changed files with 3718 additions and 412 deletions
@@ -19,6 +19,16 @@ vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/email/service', () => ({ getEmailService: vi.fn() }))
// The sandbox guard issues a company_settings query at the top of the route;
// short-circuit it in tests since the queued mock-supabase is shaped for the
// route's existing fetch chain, not an extra pre-flight read. Mirrors the same
// mock on the sibling /api/invoices/[id]/send route.
vi.mock('@/lib/sandbox/guard', () => ({
guardSandbox: vi.fn().mockResolvedValue(null),
isSandboxCompany: vi.fn().mockResolvedValue(false),
sandboxBlockedResponse: vi.fn(),
}))
vi.mock('@/lib/entitlements/has-capability', () => ({
requireCapability: vi.fn().mockResolvedValue(null),
}))
@@ -84,6 +94,25 @@ describe('POST /api/salary/runs/[id]/payslips/send', () => {
expect(response.status).toBe(401)
})
it('refuses to send for a sandbox company', async () => {
// The demo ships a booked salary run, which puts "Skicka lönebesked" one
// click from an anonymous visitor. Without this gate the route reaches the
// live mail provider and bounces off the production sending domain.
const { guardSandbox } = await import('@/lib/sandbox/guard')
vi.mocked(guardSandbox).mockResolvedValueOnce(
NextResponse.json({ sandbox_blocked: true }, { status: 403 }),
)
const { supabase } = createQueuedMockSupabase()
authed(supabase)
const sendEmail = mockEmail({ success: true, messageId: 'm-1' })
const request = createMockRequest('/api/salary/runs/run-1/payslips/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
expect(response.status).toBe(403)
expect(sendEmail).not.toHaveBeenCalled()
})
it('returns 403 when the company lacks the email_send capability', async () => {
const { requireCapability } = await import('@/lib/entitlements/has-capability')
vi.mocked(requireCapability).mockResolvedValueOnce(
@@ -9,6 +9,7 @@ import { buildPayslipLinkEmail } from '@/lib/salary/payslips/email-template'
import { getCompanyDisplayName } from '@/lib/company/context'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { guardSandbox } from '@/lib/sandbox/guard'
ensureInitialized()
@@ -26,6 +27,14 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
async (_request, { supabase, companyId, user, log, requestId }, { params }) => {
const { id } = await params
// The sandbox must never send a real email (lib/sandbox/guard.ts): this
// route reaches the live Resend service on hosted, and the demo now ships
// with a booked salary run, which puts "Skicka lönebesked" one click from
// an anonymous visitor. Sibling send paths (/api/invoices/[id]/send) have
// always been guarded; this one was missed.
const sandboxBlocked = await guardSandbox(supabase, companyId)
if (sandboxBlocked) return sandboxBlocked
const blocked = await requireCapability(supabase, companyId, CAPABILITY.email_send)
if (blocked) return blocked
@@ -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/)
})
})
+124
View File
@@ -0,0 +1,124 @@
/**
* Demo articles (artikelregister) for the sandbox company.
*
* /articles renders the Package empty state in a fresh sandbox, which hides an
* entire surface: reusable invoice-line presets are how most users actually
* build a faktura. Five rows are enough to show the register's shape (numbering,
* vara vs tjanst, unit, price excl VAT, VAT rate, active flag) without turning
* the demo into a catalogue.
*
* Modelled on a one-person Swedish IT/design consultancy (enskild firma), which
* is what the rest of the seed portrays: hourly work on löpande räkning, one
* fixed-price package, one rebilled licence, and one printed book.
*
* VAT rates are law, not decoration:
* - Consulting and design services, the fixed-price package and the rebilled
* software licence are all ordinary 25 % supplies (ML 6 kap.).
* - The printed handbook is 6 %: böcker, broschyrer och häften carry the
* reduced rate (ML 9 kap., the 6 % rates sit in §§ 8-15). No other article
* here qualifies for a reduced rate, so the rest stay at 25 %.
*
* revenue_account is left NULL on every row on purpose: NULL means "derive the
* revenue account from the VAT treatment at line-create time", which is the
* behaviour we want to demonstrate. Pinning an override would freeze a 25 %
* account onto the 6 % book line and misreport ruta 05.
*
* Every row sets every optional column explicitly (null where empty). PostgREST
* normalizes columns across the rows of a bulk insert, so a key present on one
* row and absent on another is sent as NULL rather than falling through to the
* schema default: the same gotcha documented on the journal lines in route.ts.
*/
export interface SandboxArticlesInput {
userId: string
companyId: string
}
export function buildSandboxArticles({ userId, companyId }: SandboxArticlesInput) {
const base = { user_id: userId, company_id: companyId, currency: 'SEK', active: true }
return [
{
...base,
article_number: 'A-001',
name: 'Konsulttimme, systemutveckling',
name_en: 'Consulting hour, software development',
type: 'tjanst',
unit: 'tim',
price_excl_vat: 1150,
vat_rate: 25,
revenue_account: null,
cost_price: null,
ean: null,
housework_type: null,
notes: 'Löpande räkning. Faktureras månadsvis i efterskott.',
},
{
...base,
article_number: 'A-002',
name: 'Konsulttimme, UX och gränssnittsdesign',
name_en: 'Consulting hour, UX and interface design',
type: 'tjanst',
unit: 'tim',
price_excl_vat: 995,
vat_rate: 25,
revenue_account: null,
cost_price: null,
ean: null,
housework_type: null,
notes: 'Löpande räkning. Faktureras månadsvis i efterskott.',
},
{
...base,
article_number: 'A-003',
name: 'Startpaket webbplats, fast pris',
name_en: 'Website starter package, fixed price',
type: 'tjanst',
unit: 'st',
price_excl_vat: 38000,
vat_rate: 25,
revenue_account: null,
cost_price: null,
ean: null,
housework_type: null,
notes: 'Fast pris: design, uppsättning och överlämning. Halva beloppet vid start.',
},
{
...base,
article_number: 'A-004',
name: 'Vidarefakturerad licens, designverktyg (1 plats, 12 mån)',
name_en: 'Rebilled licence, design tool (1 seat, 12 months)',
// A resold licence is a vara in the register's sense: a unit the customer
// buys, not time we spend. cost_price is display/margin only and is never
// posted to the ledger.
type: 'vara',
unit: 'st',
price_excl_vat: 5400,
vat_rate: 25,
revenue_account: null,
cost_price: 4860,
ean: null,
housework_type: null,
notes: 'Inköpspris plus påslag. Faktureras när licensen förnyas.',
},
{
...base,
article_number: 'A-005',
name: 'Handbok: Designsystem i praktiken (tryckt)',
name_en: 'Handbook: Design systems in practice (print)',
type: 'vara',
unit: 'st',
price_excl_vat: 320,
vat_rate: 6,
revenue_account: null,
cost_price: 118,
ean: null,
housework_type: null,
// The one reduced rate in the register, and the reason it is here: a
// printed book is 6 % under ML 9 kap., so the demo shows a mixed-rate
// register instead of a wall of 25 %. The note stays at chapter level:
// a paragraph-precise citation in demo copy is a liability if it drifts.
notes: 'Tryckt bok: 6 % moms enligt ML 9 kap.',
},
]
}
+455
View File
@@ -0,0 +1,455 @@
/**
* A month-by-month posted ledger for the sandbox's current fiscal year.
*
* The base seed posts two verifikat, which leaves Resultatrapport, Balansrapport,
* Nyckeltal and momsrapporten looking like a broken page rather than a demo. This
* builder produces the missing year-to-date: January through the month BEFORE
* today's month, at 4 to 7 verifikat per month, for the one-person Swedish
* IT/design consultancy (enskild firma) the rest of the seed portrays.
*
* Pure by construction. `today` is an input, there is no Math.random and no
* Date.now, and every amount is derived from a literal table or a modulo of the
* month index, so the same inputs always produce byte-identical rows and the
* unit test can assert the whole set.
*
* The monthly rhythm:
* day 3 Programvaror (SaaS-verktyg), 25 % ingående moms
* day 8 Mobiltelefon och abonnemang, 25 % ingående moms
* day 12 A rotating larger or periodic cost (some months have none)
* day 20 Kundinbetalning of the PREVIOUS month's faktura (not in January)
* day 25 Konsultarvode, faktura to a customer, 25 % utgående moms
* day 27 Eget uttag (not in January: no cash has come in yet)
* plus, in January only, an owner's capital contribution on day 2. The fiscal
* year is the sandbox company's first period and therefore has no ingående
* balans, so without that contribution konto 1930 would go negative before the
* first customer pays.
*
* Why the result is large: in an enskild firma the owner's own work is NOT a
* cost (compensation happens through egna uttag on 2013, an equity movement),
* so a consultancy billing ~1.1 MSEK legitimately shows a result of roughly the
* same order. That is what a real NE-bilaga looks like.
*
* Accounts are restricted to the set seed_chart_of_accounts activates for
* enskild_firma, so every row in the reports carries its BAS name instead of the
* "Konto 6212" fallback.
*
* The entries deliberately carry NO voucher_number: the caller assigns those by
* calling the next_voucher_number RPC once per entry, in array order, so the
* sequence stays unbroken (BFNAR 2013:2). Lines carry no journal_entry_id for
* the same reason.
*/
import { roundOre } from '@/lib/money'
/**
* Every BAS account this builder can emit. Exported so the seed's
* chart_of_accounts lookup can be widened in one place instead of drifting out
* of sync with the entries below.
*/
export const SANDBOX_LEDGER_ACCOUNT_NUMBERS: readonly string[] = [
'1510', // Kundfordringar
'1930', // Företagskonto / checkkonto
'2013', // Övriga egna uttag
'2018', // Övriga egna insättningar
'2611', // Utgående moms försäljning inom Sverige, 25 %
'2641', // Debiterad ingående moms
'2650', // Redovisningskonto för moms
'3001', // Försäljning inom Sverige, 25 % moms
'5410', // Förbrukningsinventarier
'5420', // Programvaror
'5460', // Förbrukningsmaterial
'5800', // Resekostnader
'5910', // Annonsering
'6110', // Kontorsmateriel
'6212', // Mobiltelefon
'6230', // Datakommunikation
'6530', // Redovisningstjänster
'6570', // Bankavgifter
]
export interface SandboxLedgerHistoryInput {
userId: string
companyId: string
fiscalPeriodId: string
/** "Now" as the seed sees it. The history stops at the end of the previous month. */
today: Date
/** account_number to chart_of_accounts.id. Missing entries fall back to null. */
accountMap: Record<string, string | undefined>
}
/** A journal_entries row WITHOUT voucher_number: the caller assigns that. */
export interface SandboxLedgerEntryRow {
user_id: string
company_id: string
fiscal_period_id: string
voucher_series: string
entry_date: string
description: string
source_type: string
source_id: null
status: string
committed_at: string
}
/** A journal_entry_lines row WITHOUT journal_entry_id: the caller fills it in. */
export interface SandboxLedgerLineRow {
account_number: string
account_id: string | null
debit_amount: number
credit_amount: number
line_description: string
sort_order: number
/**
* Always set, never omitted. PostgREST normalizes columns across a bulk
* insert, so a row missing the key while a sibling sets it sends NULL and
* violates the NOT NULL on journal_entry_lines.dimensions.
*/
dimensions: Record<string, string>
}
export interface SandboxLedgerHistory {
entries: SandboxLedgerEntryRow[]
/** linesByEntryIndex[i] belongs to entries[i]. */
linesByEntryIndex: SandboxLedgerLineRow[][]
}
/**
* 'manual' is the only allowed source_type that dereferences nothing: every
* other candidate ('invoice_created', 'bank_transaction', ...) makes the voucher
* detail view look for a source row that this history does not have. The value
* must come from the journal_entries_source_type_check allowlist (see
* 20260712100500_journal_source_type_stripe_payout.sql).
*/
const SOURCE_TYPE = 'manual'
const VOUCHER_SERIES = 'A'
const MONTH_NAMES_SV = [
'januari',
'februari',
'mars',
'april',
'maj',
'juni',
'juli',
'augusti',
'september',
'oktober',
'november',
'december',
]
/** Billed hours per month. July is semester, autumn is the busy season. */
const CONSULTING_HOURS = [96, 104, 120, 112, 108, 88, 40, 72, 116, 124, 118, 84]
const HOURLY_RATE = 950
/** Owner's opening capital contribution, January only. */
const OWNER_CONTRIBUTION = 60000
interface PeriodicCost {
accountNumber: string
description: string
/** Amount excluding VAT. */
net: number
/** Integer percent. 0 means a VAT-exempt supply: no 2641 line at all. */
vatRate: number
}
/**
* The day-12 cost, by month index (0 = January). null means the month has none,
* which is what makes it read as occasional rather than as a subscription.
*
* VAT rates follow the supply, not the account: persontransport inom Sverige is
* 6 % and hotellrum 12 % (ML 9 kap.), and banktjänster are undantagna från
* moms (ML 10 kap. 33 §), so the December bank fee books gross with no input
* VAT. Deductible input VAT always lands on 2641 regardless of the rate.
*/
const PERIODIC_COSTS: Array<PeriodicCost | null> = [
null,
{ accountNumber: '5910', description: 'Annonsering, kampanj sociala medier', net: 2400, vatRate: 25 },
{ accountNumber: '6530', description: 'Redovisningskonsult, avstämning inför deklaration', net: 6500, vatRate: 25 },
{ accountNumber: '6230', description: 'Bredband kontorsplats, första halvåret', net: 1490, vatRate: 25 },
{ accountNumber: '5800', description: 'Tågbiljetter, kundmöten Göteborg', net: 1480, vatRate: 6 },
{ accountNumber: '5410', description: 'Extern skärm och dockningsstation', net: 4990, vatRate: 25 },
null,
{ accountNumber: '6110', description: 'Kontorsmateriel', net: 780, vatRate: 25 },
{ accountNumber: '5800', description: 'Hotell, kundprojekt Malmö', net: 3950, vatRate: 12 },
{ accountNumber: '6230', description: 'Bredband kontorsplats, andra halvåret', net: 1490, vatRate: 25 },
{ accountNumber: '5460', description: 'Förbrukningsmaterial, kontor', net: 1150, vatRate: 25 },
{ accountNumber: '6570', description: 'Bankavgifter, årsavgift företagskonto', net: 1200, vatRate: 0 },
]
/** Internal line shape: exactly one of debit/credit is set. */
interface LineSpec {
accountNumber: string
debit?: number
credit?: number
lineDescription: string
dimensions?: Record<string, string>
}
/** yyyy-MM-dd from calendar parts. Avoids toISOString(), which shifts to UTC. */
function dateStr(year: number, month: number, day: number): string {
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}
function vatOf(net: number, vatRate: number): number {
return roundOre((net * vatRate) / 100)
}
/** Day 0 of the next month is the last day of this one, leap years included. */
function lastDayOfMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate()
}
/**
* Deterministic per-month variation. `(month * factor) % cycle` walks the cycle
* in a non-obvious order, so the amounts look hand-entered without any
* randomness: the builder must be reproducible.
*/
function vary(base: number, month: number, factor: number, cycle: number, step: number): number {
return roundOre(base + (((month * factor) % cycle) * step))
}
/**
* Revenue-line dimension bag, by month. Three-month cycle so the dimension P&L
* report has data on both kostnadsställe/projekt pairs the seed creates AND a
* meaningful untagged remainder. Every other line in the history is untagged.
*/
function revenueDimensions(month: number): Record<string, string> {
const phase = month % 3
if (phase === 1) return { '1': 'BUTIK', '6': 'P001' }
if (phase === 2) return { '1': 'WEBB', '6': 'P002' }
return {}
}
export function buildSandboxLedgerHistory({
userId,
companyId,
fiscalPeriodId,
today,
accountMap,
}: SandboxLedgerHistoryInput): SandboxLedgerHistory {
const year = today.getFullYear()
/** 1-based; the history covers January through the month before this one. */
const lastMonth = today.getMonth()
const entries: SandboxLedgerEntryRow[] = []
const linesByEntryIndex: SandboxLedgerLineRow[][] = []
/**
* Running VAT, so the quarterly momsredovisning below settles the exact
* amounts the period's own entries produced rather than a re-derivation.
*/
let quarterOutputVat = 0
let quarterInputVat = 0
const accountId = (accountNumber: string): string | null =>
accountMap[accountNumber] ?? null
function addEntry(entryDate: string, description: string, lines: LineSpec[]): void {
entries.push({
user_id: userId,
company_id: companyId,
fiscal_period_id: fiscalPeriodId,
voucher_series: VOUCHER_SERIES,
entry_date: entryDate,
description,
source_type: SOURCE_TYPE,
source_id: null,
status: 'posted',
committed_at: entryDate,
})
linesByEntryIndex.push(
lines.map((line, index) => ({
account_number: line.accountNumber,
account_id: accountId(line.accountNumber),
debit_amount: roundOre(line.debit ?? 0),
credit_amount: roundOre(line.credit ?? 0),
line_description: line.lineDescription,
sort_order: index,
dimensions: line.dimensions ?? {},
})),
)
}
/** Dr cost (+ Dr 2641), Cr 1930. The bank pays it the day it happens. */
function addBankPaidCost(entryDate: string, cost: PeriodicCost): void {
const vat = vatOf(cost.net, cost.vatRate)
const gross = roundOre(cost.net + vat)
const lines: LineSpec[] = [
{ accountNumber: cost.accountNumber, debit: cost.net, lineDescription: cost.description },
]
if (vat > 0) {
quarterInputVat = roundOre(quarterInputVat + vat)
lines.push({
accountNumber: '2641',
debit: vat,
lineDescription: `Ingående moms ${cost.vatRate} %`,
})
}
lines.push({
accountNumber: '1930',
credit: gross,
lineDescription: 'Betalt från företagskontot',
})
addEntry(entryDate, cost.description, lines)
}
/**
* Close one VAT quarter: clear 2611 and 2641 into 2650, then pay 2650 from
* the bank on the SFL 26 kap. 26 § deadline.
*
* Without this the sandbox reads as a company that has collected VAT all
* year and never remitted a krona: the moms liability and the bank balance
* both climb without limit, and every cash KPI derived from them is
* nonsense. The company files quarterly (company_settings.moms_period =
* 'quarterly'), so each quarter is declared after it closes and paid on the
* 12th of the second month after it.
*/
/** Payments owed but not yet emitted, keyed by the month they fall due. */
const vatPaymentsDue = new Map<number, { amount: number; label: string }>()
function declareVatQuarter(quarterEndMonth: number, paymentMonth: number, label: string): void {
const output = quarterOutputVat
const input = quarterInputVat
quarterOutputVat = 0
quarterInputVat = 0
const netPayable = roundOre(output - input)
// A refund quarter would need the opposite sign on 2650 and a bank
// deposit. This history is comfortably output-heavy every quarter, so
// rather than emit a voucher whose direction was never exercised, skip.
if (netPayable <= 0) return
// 2611 debit + 2641 credit + 2650 is the shape get_vat_declaration_totals
// classifies as a momsredovisning and drops from the period's totals, which
// is exactly right: a settlement must not feed the next declaration.
addEntry(
dateStr(year, quarterEndMonth, lastDayOfMonth(year, quarterEndMonth)),
`Momsredovisning ${label}`,
[
{ accountNumber: '2611', debit: output, lineDescription: 'Utgående moms för perioden' },
{ accountNumber: '2641', credit: input, lineDescription: 'Ingående moms för perioden' },
{ accountNumber: '2650', credit: netPayable, lineDescription: 'Redovisningskonto för moms' },
],
)
vatPaymentsDue.set(paymentMonth, { amount: netPayable, label })
}
/**
* Emitted from inside the payment month's own block, never from the quarter
* close: a 12 May voucher appended while building March would put the entry
* list out of date order, and the caller assigns voucher numbers in array
* order (BFNAR 2013:2 expects the sequence to follow the books).
*/
function payVatIfDue(month: number): void {
const due = vatPaymentsDue.get(month)
if (!due) return
vatPaymentsDue.delete(month)
addEntry(dateStr(year, month, 12), `Betald moms ${due.label}`, [
{ accountNumber: '2650', debit: due.amount, lineDescription: 'Redovisningskonto för moms' },
{ accountNumber: '1930', credit: due.amount, lineDescription: 'Betalt till skattekontot' },
])
}
for (let month = 1; month <= lastMonth; month++) {
const monthName = MONTH_NAMES_SV[month - 1]
const hours = CONSULTING_HOURS[month - 1]
const revenueNet = hours * HOURLY_RATE
const revenueVat = vatOf(revenueNet, 25)
const revenueGross = roundOre(revenueNet + revenueVat)
// ── day 2, January only: owner funds the business ────────────────────
// The fiscal year is the company's first period, so there is no ingående
// balans on 1930. Without this the bank account goes negative in January.
if (month === 1) {
addEntry(dateStr(year, month, 2), 'Egen insättning, startkapital', [
{ accountNumber: '1930', debit: OWNER_CONTRIBUTION, lineDescription: 'Insättning på företagskontot' },
{ accountNumber: '2018', credit: OWNER_CONTRIBUTION, lineDescription: 'Övriga egna insättningar' },
])
}
// ── day 3: software subscriptions ────────────────────────────────────
addBankPaidCost(dateStr(year, month, 3), {
accountNumber: '5420',
description: `Programvaror, molntjänster ${monthName}`,
net: vary(890, month, 7, 5, 60),
vatRate: 25,
})
// ── day 8: mobile and subscription ───────────────────────────────────
addBankPaidCost(dateStr(year, month, 8), {
accountNumber: '6212',
description: `Mobiltelefon och abonnemang, ${monthName}`,
net: vary(429, month, 5, 4, 35),
vatRate: 25,
})
// ── day 12: the rotating larger or periodic cost ─────────────────────
const periodic = PERIODIC_COSTS[month - 1]
if (periodic) {
addBankPaidCost(dateStr(year, month, 12), periodic)
}
// ── day 12: the previous quarter's moms leaves the bank ──────────────
// SFL 26 kap. 26 §: a quarterly filer declares and pays on the 12th of
// the second month after the period.
payVatIfDue(month)
// ── day 20: the previous month's faktura is paid ─────────────────────
// 30 day terms, so January has nothing to collect.
if (month > 1) {
const previousMonthName = MONTH_NAMES_SV[month - 2]
const previousNet = CONSULTING_HOURS[month - 2] * HOURLY_RATE
const previousGross = roundOre(previousNet + vatOf(previousNet, 25))
addEntry(
dateStr(year, month, 20),
`Kundinbetalning, konsultarvode ${previousMonthName}`,
[
{ accountNumber: '1930', debit: previousGross, lineDescription: 'Insättning på företagskontot' },
{ accountNumber: '1510', credit: previousGross, lineDescription: 'Kvittad kundfordran' },
],
)
}
// ── day 25: the month's consulting invoice ───────────────────────────
addEntry(
dateStr(year, month, 25),
`Faktura, konsultarvode ${monthName} (${hours} tim)`,
[
{ accountNumber: '1510', debit: revenueGross, lineDescription: 'Kundfordran' },
{
accountNumber: '3001',
credit: revenueNet,
lineDescription: `Konsultarvode ${hours} tim à ${HOURLY_RATE} kr`,
dimensions: revenueDimensions(month),
},
{ accountNumber: '2611', credit: revenueVat, lineDescription: 'Utgående moms 25 %' },
],
)
quarterOutputVat = roundOre(quarterOutputVat + revenueVat)
// ── day 27: eget uttag ───────────────────────────────────────────────
// Enskild firma: the owner is not an employee, so their compensation is an
// equity withdrawal on 2013, never a personnel cost. Skipped in January for
// the same cash reason as the customer payment.
if (month > 1) {
const withdrawal = vary(38000, month, 3, 4, 4000)
addEntry(dateStr(year, month, 27), `Eget uttag, ${monthName}`, [
{ accountNumber: '2013', debit: withdrawal, lineDescription: 'Övriga egna uttag' },
{ accountNumber: '1930', credit: withdrawal, lineDescription: 'Uttag från företagskontot' },
])
}
// ── quarter close: declare and pay the moms the quarter produced ──────
// Emitted inside the loop rather than appended afterwards so the
// verifikat stay in date order, which is what the voucher numbering the
// caller assigns will follow.
if (month === 3) declareVatQuarter(3, 5, `${year} Q1`)
if (month === 6) declareVatQuarter(6, 8, `${year} Q2`)
if (month === 9) declareVatQuarter(9, 11, `${year} Q3`)
}
return { entries, linesByEntryIndex }
}
+344 -4
View File
@@ -7,8 +7,30 @@ import { createLogger } from '@/lib/logger'
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
import { truncateIp } from '@/lib/api/v1/with-api-v1'
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import { encryptPersonnummer } from '@/lib/salary/personnummer'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
import { buildSandboxCustomers } from './customers'
import { buildSandboxPendingOperations } from './pending-operations'
import { buildSandboxArticles } from './articles'
import {
buildSandboxLedgerHistory,
SANDBOX_LEDGER_ACCOUNT_NUMBERS,
} from './ledger-history'
import {
buildSandboxEmployees,
buildSandboxSalaryLineItems,
buildSandboxSalaryRunEmployees,
buildSandboxSalaryRuns,
mapSandboxEmployeeIds,
resolveSandboxSalaryPeriods,
SANDBOX_RUN_TOTALS,
SANDBOX_TOTAL_VACATION_ACCRUAL_AVGIFTER,
} from './salary'
import {
buildSandboxSalaryVouchers,
SANDBOX_SALARY_ACCOUNT_NUMBERS,
} from './salary-vouchers'
// Anonymous sign-in is enabled in all environments so visitors can try the
// product; a per-/24 cap on the seed endpoint keeps a single network from
@@ -153,6 +175,14 @@ export async function POST(request: Request) {
is_sandbox: true,
// Dimensions demo: the register/pickers render out of the box.
dimensions_enabled: true,
// Payroll demo. `pays_salaries` is the UI gate DashboardNav reads to
// decide whether Löner and Anställda appear at all (an enskild firma
// is not an employer by default), and `employer_registered` is the
// AGI gate. An EF may absolutely employ staff; what it may not do is
// put its OWNER on payroll, which is why both seeded employees carry
// employment_type 'employee' rather than 'company_owner'.
pays_salaries: true,
employer_registered: true,
})
if (settingsError) throw settingsError
@@ -357,18 +387,178 @@ export async function POST(request: Request) {
if (itemsError) throw itemsError
// 8. Resolve account IDs for journal entries
const { data: accounts } = await supabase
// 8. Resolve account IDs for journal entries.
//
// seed_chart_of_accounts lays down the K1 subset, and its 7xxx personnel
// block is gated on p_entity_type = 'aktiebolag'. The sandbox company is an
// enskild firma, so none of the payroll accounts exist yet, and neither do
// the semesterlöneskuld pair. Create the missing ones from the BAS 2026
// reference first, exactly as ensureSalaryAccountsExist does before the
// real booking path posts a salary run.
const neededAccounts = [
...new Set([
...SANDBOX_LEDGER_ACCOUNT_NUMBERS,
...SANDBOX_SALARY_ACCOUNT_NUMBERS,
'1510',
'1930',
'2611',
'3001',
// The 6 % article (a printed book) derives 3003/2631 at invoice-line
// time; neither is in the K1 chart, so invoicing it would post to an
// account the company does not have.
'2631',
'3003',
]),
]
const { data: existingAccounts, error: existingAccountsError } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.in('account_number', neededAccounts)
if (existingAccountsError) throw existingAccountsError
const existingAccountNumbers = new Set(
(existingAccounts ?? []).map(a => a.account_number as string)
)
const missingAccounts = neededAccounts
.filter(n => !existingAccountNumbers.has(n))
.map(accountNumber => {
const ref = getBASReference(accountNumber)
// An account with no BAS 2026 reference would have to be invented here.
// Better to fail the seed than to write a chart row with guessed
// class/type/normal_balance that every report would then trust.
if (!ref) {
throw new Error(`Sandbox seed: no BAS reference for account ${accountNumber}`)
}
return {
user_id: userId,
company_id: companyId,
account_number: accountNumber,
account_name: ref.account_name,
account_class: ref.account_class,
account_group: ref.account_group,
account_type: ref.account_type,
normal_balance: ref.normal_balance,
sru_code: ref.sru_code,
k2_excluded: ref.k2_excluded,
plan_type: 'full_bas',
is_active: true,
is_system_account: false,
}
})
if (missingAccounts.length > 0) {
const { error: missingAccountsError } = await supabase
.from('chart_of_accounts')
.insert(missingAccounts)
if (missingAccountsError) throw missingAccountsError
}
// Errors are fatal here, not tolerable: a failed read would leave
// accountMap empty and silently write account_id: null onto every
// ledger-history and salary voucher line, producing a sandbox whose
// vouchers reference no account at all.
const { data: accounts, error: accountsError } = await supabase
.from('chart_of_accounts')
.select('id, account_number')
.eq('company_id', companyId)
.in('account_number', ['1510', '1930', '2611', '3001'])
.in('account_number', neededAccounts)
if (accountsError) throw accountsError
const accountMap = Object.fromEntries(
(accounts ?? []).map(a => [a.account_number, a.id])
)
// 9. Create journal entries (inserted directly, not via engine, to avoid event emission)
// 9. Year-to-date ledger history (January through last month).
//
// Without it the company has two verifikat and every report is a flat
// line: Resultatrapport, Balansrapport, Nyckeltal and Momsrapport all read
// as broken rather than empty.
//
// Seeded BEFORE the invoice and payroll vouchers below on purpose.
// next_voucher_number hands out numbers in call order, so seeding January
// last would have produced A-1 dated in July followed by A-3 dated in
// January: a gap-free sequence that runs backwards through the year, which
// is not what BFNAR 2013:2 means by a chronological verifikationsserie.
const ledgerHistory = buildSandboxLedgerHistory({
userId,
companyId,
fiscalPeriodId: fiscalPeriod.id,
today,
accountMap,
})
// One RPC per voucher: next_voucher_number is a counter table with a row
// lock (not MAX+1), so sequential calls are safe and gap-free. The two
// writes are batched rather than run per entry, which is what turns ~130
// round trips into ~45 for a seed that runs on every sandbox visit.
const historyVoucherNumbers: number[] = []
for (const historyEntry of ledgerHistory.entries) {
const { data: historyVoucherNumber, error: historyVoucherError } = await supabase.rpc(
'next_voucher_number',
{
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriod.id,
p_series: historyEntry.voucher_series,
},
)
if (historyVoucherError) throw historyVoucherError
historyVoucherNumbers.push(historyVoucherNumber as number)
}
const { data: insertedHistoryEntries, error: historyEntryError } = await supabase
.from('journal_entries')
.insert(
ledgerHistory.entries.map((historyEntry, index) => ({
...historyEntry,
voucher_number: historyVoucherNumbers[index],
})),
)
.select('id, voucher_number')
if (historyEntryError) throw historyEntryError
// Match on voucher_number, not on array position: PostgREST does not
// promise the returned rows come back in insertion order, and
// (company_id, fiscal_period_id, voucher_series, voucher_number) is unique.
const historyIdByVoucher = new Map(
(insertedHistoryEntries ?? []).map(row => [row.voucher_number as number, row.id as string]),
)
const historyEntryIds = historyVoucherNumbers.map(voucherNumber => {
const entryId = historyIdByVoucher.get(voucherNumber)
if (!entryId) {
throw new Error(`Sandbox seed: ledger history voucher ${voucherNumber} was not inserted`)
}
return entryId
})
const { error: historyLinesError } = await supabase
.from('journal_entry_lines')
.insert(
ledgerHistory.linesByEntryIndex.flatMap((lines, index) =>
lines.map(line => ({ ...line, journal_entry_id: historyEntryIds[index] })),
),
)
if (historyLinesError) throw historyLinesError
// The history is the company's books from before it arrived in Accounted:
// its kvitton live in the previous system's binder, not here. Left
// unflagged, every one of these vouchers would land on Hem as "Verifikat
// utan underlag" and the demo's first screen would read as a compliance
// mess. Marking them exempt is the same move the SIE-import opt-in makes
// for exactly the same reason, through the same sanctioned sidecar table
// (journal_entry_no_doc_required), so the verifikat themselves stay
// immutable per BFL.
await markEntriesNoDocRequired(
supabase,
companyId,
userId,
historyEntryIds,
'Historisk bokföring: underlag arkiverade i det tidigare systemet.',
)
// 10. Invoice vouchers (inserted directly, not via engine, to avoid event emission)
const { data: voucherNum1 } = await supabase.rpc('next_voucher_number', {
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriod.id,
@@ -861,6 +1051,148 @@ export async function POST(request: Request) {
if (pendOpsError) throw pendOpsError
// 18. Artikelregister, so /articles shows reusable invoice-line presets
// instead of the Package empty state.
const { error: articlesError } = await supabase
.from('articles')
.insert(buildSandboxArticles({ userId, companyId }))
if (articlesError) throw articlesError
// 20. Payroll. An enskild firma may employ staff (it just may not put its
// own owner on payroll), so the demo runs two employees through one booked
// and one open lönekörning.
const { data: employeeRows, error: employeesError } = await supabase
.from('employees')
.insert(
buildSandboxEmployees({
userId,
companyId,
today,
// employees.personnummer stores AES-256-GCM ciphertext; the builder
// stays pure by taking the cipher as an argument.
encrypt: encryptPersonnummer,
}),
)
.select('id, last_name')
if (employeesError) throw employeesError
const { annaEmployeeId, erikEmployeeId } = mapSandboxEmployeeIds(employeeRows)
const { data: salaryRunRows, error: salaryRunsError } = await supabase
.from('salary_runs')
.insert(buildSandboxSalaryRuns({ userId, companyId, today }))
.select('id, status')
if (salaryRunsError) throw salaryRunsError
const bookedRun = salaryRunRows.find(r => r.status === 'booked')
const draftRun = salaryRunRows.find(r => r.status === 'draft')
if (!bookedRun || !draftRun) {
throw new Error('Sandbox seed: expected one booked and one draft salary run')
}
const { data: runEmployeeRows, error: runEmployeesError } = await supabase
.from('salary_run_employees')
.insert(
buildSandboxSalaryRunEmployees({
companyId,
today,
bookedRunId: bookedRun.id,
draftRunId: draftRun.id,
annaEmployeeId,
erikEmployeeId,
}),
)
.select('id, employee_id')
if (runEmployeesError) throw runEmployeesError
const { error: salaryLineItemsError } = await supabase
.from('salary_line_items')
.insert(
buildSandboxSalaryLineItems({
companyId,
annaEmployeeId,
erikEmployeeId,
runEmployees: runEmployeeRows,
}),
)
if (salaryLineItemsError) throw salaryLineItemsError
// 21. Verifikat for the BOOKED run. A run in status 'booked' that posted
// nothing would be a lie: the real path (bookPaidSalaryRun) always writes
// these through the engine before advancing the status. The seed inserts
// journal rows directly to avoid event emission, so ./salary-vouchers
// mirrors the engine's account structure instead.
const bookedPeriod = resolveSandboxSalaryPeriods(today).booked
const salaryVouchers = buildSandboxSalaryVouchers({
userId,
companyId,
fiscalPeriodId: fiscalPeriod.id,
salaryRunId: bookedRun.id,
paymentDate: bookedPeriod.paymentDate,
periodYear: bookedPeriod.year,
periodMonth: bookedPeriod.month,
totalGross: SANDBOX_RUN_TOTALS.total_gross,
totalTax: SANDBOX_RUN_TOTALS.total_tax,
totalNet: SANDBOX_RUN_TOTALS.total_net,
totalAvgifter: SANDBOX_RUN_TOTALS.total_avgifter,
totalVacationAccrual: SANDBOX_RUN_TOTALS.total_vacation_accrual,
// salary_runs has no column for avgifter on the vacation accrual (it is
// a per-employee figure), so ./salary exports the sum of the same
// figures the salary_run_employees rows were written from.
totalVacationAvgifter: SANDBOX_TOTAL_VACATION_ACCRUAL_AVGIFTER,
})
const runEntryLinks: Record<string, string> = {}
for (const voucher of salaryVouchers) {
const { data: salaryVoucherNumber, error: salaryVoucherError } = await supabase.rpc(
'next_voucher_number',
{
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriod.id,
p_series: voucher.entry.voucher_series,
},
)
// A posted verifikat with no voucher number is a hole in the
// verifikationsserie (BFNAR 2013:2), so a failed counter read has to stop
// the seed rather than insert one.
if (salaryVoucherError) throw salaryVoucherError
if (salaryVoucherNumber == null) {
throw new Error('Sandbox seed: next_voucher_number returned no number for a salary voucher')
}
const { data: insertedSalaryEntry, error: salaryEntryError } = await supabase
.from('journal_entries')
.insert({ ...voucher.entry, voucher_number: salaryVoucherNumber })
.select('id')
.single()
if (salaryEntryError) throw salaryEntryError
const { error: salaryEntryLinesError } = await supabase
.from('journal_entry_lines')
.insert(
voucher.lines.map(line => ({
...line,
account_id: accountMap[line.account_number] ?? null,
journal_entry_id: insertedSalaryEntry.id,
})),
)
if (salaryEntryLinesError) throw salaryEntryLinesError
runEntryLinks[voucher.runColumn] = insertedSalaryEntry.id
}
const { error: linkRunError } = await supabase
.from('salary_runs')
.update(runEntryLinks)
.eq('id', bookedRun.id)
.eq('company_id', companyId)
if (linkRunError) throw linkRunError
return NextResponse.json({ seeded: true })
} catch (err) {
log.error('failed to seed sandbox data', { error: err, userId: user.id, companyId })
@@ -877,6 +1209,14 @@ export async function POST(request: Request) {
* at the company_settings idempotency check above, so they never get the
* agent_profile without this hook. Delegates to ensureSandboxAgentProfile
* so the profile data stays in exactly one place.
*
* Deliberately NOT extended to the payroll and ledger-history additions: those
* are one correlated dataset (a chart of accounts, a year of vouchers, a
* roster, two runs and their verifikat) that cannot be half-applied coherently,
* and a partial top-up would produce a booked lönekörning whose verifikat
* numbering interleaves with vouchers that already exist. Sandboxes are deleted
* after 24 hours, so the window where this matters closes on its own; a visitor
* who wants the payroll demo starts a new sandbox.
*/
async function topUpSandboxAdditions(
supabase: SupabaseClient,
+247
View File
@@ -0,0 +1,247 @@
/**
* Journal entries for the sandbox's BOOKED salary run.
*
* A run in status 'booked' that produced no verifikat would be a lie: in the
* real product `bookPaidSalaryRun` posts 2-4 entries through the bookkeeping
* engine before it advances the status. The sandbox seed cannot call that path
* (it goes through the engine, which emits events, and the seed deliberately
* inserts journal rows directly), so this module mirrors the account structure
* of `createSalaryRunEntries` in lib/salary/salary-entries.ts instead.
*
* Accounts come from SALARY_ACCOUNTS (lib/salary/account-mapping.ts), not from
* literals here, so a future BAS remap moves the seed with the engine.
*
* Pure builders, in the same style as ./customers.ts and ./pending-operations.ts:
* the caller assigns voucher numbers and journal_entry_id foreign keys.
*/
import { SALARY_ACCOUNTS } from '@/lib/salary/account-mapping'
/** Money rounding, per the project rule: never toFixed(). */
const ore = (n: number) => Math.round(n * 100) / 100
/**
* Every account these vouchers can touch, for the seed's chart-of-accounts
* check. seed_chart_of_accounts only lays down its 7xxx personnel block for
* aktiebolag, and the sandbox company is an enskild firma, so all of these are
* missing until the seed creates them.
*
* Derived from SALARY_ACCOUNTS rather than listed, so it cannot fall behind the
* account map. SICK_PAY and VACATION_PAY are in the map but unused by this
* seed; including them is harmless (the account simply exists in the chart) and
* keeps the list free of a hand-maintained subset.
*/
export const SANDBOX_SALARY_ACCOUNT_NUMBERS: readonly string[] =
Object.values(SALARY_ACCOUNTS)
export interface SalaryVoucherTotals {
/** Sum of gross_salary across the run's employees. */
totalGross: number
/** Sum of tax_withheld. */
totalTax: number
/** Sum of net_salary. */
totalNet: number
/** Sum of avgifter_amount (arbetsgivaravgifter on the payroll). */
totalAvgifter: number
/** Sum of vacation_accrual (semesterlöneskuld change). */
totalVacationAccrual: number
/** Sum of vacation_accrual_avgifter (avgifter on the accrued vacation). */
totalVacationAvgifter: number
}
export interface SalaryVoucherInput extends SalaryVoucherTotals {
userId: string
companyId: string
fiscalPeriodId: string
salaryRunId: string
/** ISO date the run pays out on; the entry date for all three vouchers. */
paymentDate: string
periodYear: number
periodMonth: number
}
export interface SeedJournalLine {
account_number: string
debit_amount: number
credit_amount: number
sort_order: number
/** Always set explicitly: PostgREST normalizes columns across a bulk insert,
* so omitting it on some rows while another row sets it sends null and
* violates NOT NULL instead of falling through to the '{}' default. */
dimensions: Record<string, string>
}
export interface SeedJournalEntry {
user_id: string
company_id: string
fiscal_period_id: string
entry_date: string
description: string
source_type: 'salary_payment'
source_id: string
status: 'posted'
committed_at: string
voucher_series: string
}
export interface SalaryVoucher {
entry: SeedJournalEntry
lines: SeedJournalLine[]
/** Which salary_runs column should point at this entry once it is inserted. */
runColumn: 'salary_entry_id' | 'avgifter_entry_id' | 'vacation_entry_id'
}
/**
* Build the vouchers for one booked salary run.
*
* Entry 1 (lön): D 7210 brutto / C 2710 personalskatt + C 1930 netto
* Entry 2 (avgifter): D 7510 sociala avgifter / C 2731 avräkning sociala avgifter
* Entry 3 (semester): D 7290 + D 7519 / C 2920 + C 2940
*
* Entry 3 is omitted when the run accrued no vacation, mirroring the engine
* (a zero-amount voucher would violate the balance trigger's "total > 0").
*
* Throws when gross != tax + net. That identity holds for the seeded run
* (no benefits, no gross or net deductions), and an unbalanced verifikat must
* fail loudly rather than reach the ledger.
*/
export function buildSandboxSalaryVouchers(input: SalaryVoucherInput): SalaryVoucher[] {
const {
userId,
companyId,
fiscalPeriodId,
salaryRunId,
paymentDate,
periodYear,
periodMonth,
totalGross,
totalTax,
totalNet,
totalAvgifter,
totalVacationAccrual,
totalVacationAvgifter,
} = input
const gross = ore(totalGross)
const tax = ore(totalTax)
const net = ore(totalNet)
const avgifter = ore(totalAvgifter)
const vacation = ore(totalVacationAccrual)
const vacationAvgifter = ore(totalVacationAvgifter)
if (ore(tax + net) !== gross) {
throw new Error(
`Sandbox salary voucher would not balance: gross ${gross} != tax ${tax} + net ${net}`,
)
}
const periodLabel = `${periodYear}-${String(periodMonth).padStart(2, '0')}`
const base = {
user_id: userId,
company_id: companyId,
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
source_type: 'salary_payment' as const,
source_id: salaryRunId,
status: 'posted' as const,
committed_at: paymentDate,
voucher_series: 'A',
}
const vouchers: SalaryVoucher[] = [
{
runColumn: 'salary_entry_id',
entry: { ...base, description: `Lön ${periodLabel}` },
lines: [
{
account_number: SALARY_ACCOUNTS.SALARY_EMPLOYEE,
debit_amount: gross,
credit_amount: 0,
sort_order: 0,
dimensions: {},
},
{
account_number: SALARY_ACCOUNTS.TAX_WITHHELD,
debit_amount: 0,
credit_amount: tax,
sort_order: 1,
dimensions: {},
},
{
account_number: SALARY_ACCOUNTS.BANK,
debit_amount: 0,
credit_amount: net,
sort_order: 2,
dimensions: {},
},
],
},
{
runColumn: 'avgifter_entry_id',
entry: { ...base, description: `Lön ${periodLabel}: arbetsgivaravgifter` },
lines: [
{
account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE,
debit_amount: avgifter,
credit_amount: 0,
sort_order: 0,
dimensions: {},
},
{
account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: avgifter,
sort_order: 1,
dimensions: {},
},
],
},
]
if (vacation > 0 || vacationAvgifter > 0) {
const lines: SeedJournalLine[] = []
if (vacation > 0) {
lines.push(
{
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_EXPENSE,
debit_amount: vacation,
credit_amount: 0,
sort_order: lines.length,
dimensions: {},
},
{
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_LIABILITY,
debit_amount: 0,
credit_amount: vacation,
sort_order: lines.length + 1,
dimensions: {},
},
)
}
if (vacationAvgifter > 0) {
lines.push(
{
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_EXPENSE,
debit_amount: vacationAvgifter,
credit_amount: 0,
sort_order: lines.length,
dimensions: {},
},
{
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: vacationAvgifter,
sort_order: lines.length + 1,
dimensions: {},
},
)
}
vouchers.push({
runColumn: 'vacation_entry_id',
entry: { ...base, description: `Lön ${periodLabel}: semesterlöneskuld` },
lines,
})
}
return vouchers
}
+799
View File
@@ -0,0 +1,799 @@
/**
* Sandbox payroll seed: the Löner story the demo user lands on.
*
* /salary → two lönekörningar (one Bokförd, one Utkast)
* /salary/employees → two anställda (one månadslön, one timlön)
* /salary/runs/{id} → per-employee calculation rows + payslip line items
*
* Everything here is a PURE ROW BUILDER, same contract as ./customers.ts and
* ./pending-operations.ts: no Supabase, no `new Date()` inside, dates and ids
* arrive as arguments. route.ts owns the inserts.
*
* Insert order (foreign keys):
* 1. employees → buildSandboxEmployees
* 2. salary_runs → buildSandboxSalaryRuns
* 3. salary_run_employees → buildSandboxSalaryRunEmployees (needs 1 + 2)
* 4. salary_line_items → buildSandboxSalaryLineItems (needs 3)
*
* Two seeding hazards this module is written around:
*
* PostgREST bulk-insert normalization. Every row in one `.insert([...])`
* array must carry the SAME key set: a key present on one row and absent on
* another is sent as null for the missing row, which either violates NOT NULL
* or overwrites a schema default. So the builders below spell out nulls
* explicitly instead of omitting keys.
*
* The January edge. The sandbox has exactly one fiscal period, the current
* calendar year. In January the "previous month" falls in the previous year,
* which has no period, so resolveSandboxSalaryPeriods keeps both runs inside
* the current year (January booked, February draft) instead.
*/
import { roundOre } from '@/lib/money'
import { FALLBACK_TAX_TABLES_2026 } from '@/lib/salary/tax-tables-fallback'
import { getLineItemAccount } from '@/lib/salary/account-mapping'
import type { SalaryLineItemType } from '@/types'
// ============================================================
// Statutory + demo rates
// ============================================================
/**
* Arbetsgivaravgifter 2026, matching salary_payroll_config.avgifter_total
* seeded by migration 20260414120000. Both employees are in the standard
* age band (born 1988 and 1996), so no reduction applies.
*/
const AVGIFTER_RATE = 0.3142
/**
* Procentregeln at 25 semesterdagar: 12 % of the vacation basis (SemL 16 b §).
* The engine switches to 14.4 % at 30+ days; both demo employees are on 25.
*/
const VACATION_ACCRUAL_RATE = 0.12
/** Skatteverket tax table + column both demo employees are registered on. */
const TAX_TABLE_NUMBER = 34
const TAX_COLUMN = 1
/**
* Real skatteavdrag, from the Skatteverket 2026 tables the repo already ships
* (SKV 434, generated into lib/salary/tax-tables-fallback.ts).
*
* A flat schablon was tempting here and wrong. The draft run ships with
* calculation_params set, so its "Beräkna om" is live: a visitor who presses it
* gets the engine's real table lookup, and any approximation would make the
* open run jump away from the booked one it is supposed to mirror. It would
* also put a wrong skatteavdrag on screen in the payslip, the 2710 verifikat
* line and the AGI figures, which is not a thing an accounting product should
* demo. The fallback module is a plain synchronous constant, no DB and no
* network, so there is nothing to depend on being loaded.
*
* Column 1 is ordinary employment income for someone below 66 (SKV 434), which
* is what both demo employees are registered as.
*/
function lookupTaxWithheld(monthlyIncome: number): number {
const table = FALLBACK_TAX_TABLES_2026[TAX_TABLE_NUMBER]
if (!table) {
throw new Error(`Sandbox seed: no 2026 tax table ${TAX_TABLE_NUMBER}`)
}
// Rows are [incomeFrom, incomeTo, col1..col6] and the brackets are integer
// kronor, so round before comparing.
const income = Math.round(monthlyIncome)
const row = table.find(([from, to]) => income >= from && income <= to)
if (!row) {
throw new Error(
`Sandbox seed: income ${income} is outside tax table ${TAX_TABLE_NUMBER}`,
)
}
return row[1 + TAX_COLUMN]
}
/**
* Frozen payroll-config snapshot written to salary_runs.calculation_params.
*
* A real run stores serializePayrollConfig(config): the full 2026 config row.
* The seed writes the documented subset that consumers actually read
* (`slpRate` in lib/salary/salary-entries.ts, `sjuklonRate` in the AGI
* generator) plus the rates these demo figures were computed with. The column
* being non-null is also what makes the run detail page treat the run as
* calculated: KPI cards, Beräkningsdetaljer and the bokförings-preview all
* hang off `calculation_params != null`.
*/
const DEMO_CALCULATION_PARAMS = {
configYear: 2026,
avgifterTotal: AVGIFTER_RATE,
avgifterReduced65plus: 0.1021,
egenavgifterTotal: 0.2897,
slpRate: 0.2426,
prisbasbelopp: 59200,
sjuklonRate: 0.8,
karensavdragFactor: 0.2,
reducedAvgiftAge: 67,
// Marks the snapshot as seeded rather than calculated, so anything reading
// it later can tell the difference from a real frozen config.
demoSeed: true,
} as const
// ============================================================
// Employee facts
// ============================================================
/**
* Fabricated identity numbers. Both are SAMORDNINGSNUMMER (the day field
* carries the +60 offset Skatteverket uses for people without a personnummer),
* which makes them unmistakably not a real person's personnummer while still
* passing every gate the app applies: validatePersonnummer (format + Luhn over
* the printed digits) and the AGI generator's IDENTITET_PATTERN both accept the
* offset form. An invalid-Luhn number would write fine but would be rejected
* the moment the demo user opened the employee and saved.
*
* 198805741231 → born 1988-05-14 (74 = 14 + 60)
* 199611624561 → born 1996-11-02 (62 = 2 + 60)
*/
const ANNA_PERSONNUMMER = '198805741231'
const ERIK_PERSONNUMMER = '199611624561'
/** Months before `today` each employment started. */
const ANNA_EMPLOYMENT_MONTHS_AGO = 24
const ERIK_EMPLOYMENT_MONTHS_AGO = 8
const ANNA_MONTHLY_SALARY = 38000
const ERIK_HOURLY_RATE = 245
const ERIK_HOURS_WORKED = 80
/**
* Last names are the seed's stable handle on the two employees: the ciphertext
* in `personnummer` differs on every encrypt (random IV), so it cannot be used
* to match inserted rows back to the builder's intent.
*/
export const SANDBOX_EMPLOYEE_LAST_NAMES = {
anna: 'Lindqvist',
erik: 'Sandström',
} as const
// ============================================================
// Date helpers (pure: every one takes its reference date as an argument)
// ============================================================
function pad2(n: number): string {
return String(n).padStart(2, '0')
}
/** Local-calendar YYYY-MM-DD, same convention as route.ts's toDateStr. */
function toDateString(year: number, month: number, day: number): string {
return `${year}-${pad2(month)}-${pad2(day)}`
}
/** Last calendar day of a 1-12 month. */
function lastDayOfMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate()
}
/**
* Shift a date by whole calendar months, clamping the day to the target
* month's length so 31 March minus 1 month is 28/29 February, never 3 March.
*/
function shiftMonths(from: Date, months: number): string {
const total = from.getFullYear() * 12 + from.getMonth() + months
const year = Math.floor(total / 12)
const month = (total % 12) + 1
const day = Math.min(from.getDate(), lastDayOfMonth(year, month))
return toDateString(year, month, day)
}
/** Earlier of two YYYY-MM-DD strings (ISO dates compare lexicographically). */
function earlierDate(a: string, b: string): string {
return a <= b ? a : b
}
// ============================================================
// Periods
// ============================================================
export interface SandboxSalaryPeriod {
year: number
/** 1-12. */
month: number
/** The 25th, the ordinary Swedish payday. */
paymentDate: string
}
export interface SandboxSalaryPeriods {
/** Status 'booked': last month's payroll, paid and posted. */
booked: SandboxSalaryPeriod
/** Status 'draft': this month's payroll, still open. */
draft: SandboxSalaryPeriod
}
/**
* Resolve the two demo periods.
*
* Normal case: the booked run is last month, the draft run is this month.
*
* January: "last month" would be December of the PREVIOUS calendar year, and
* the sandbox seeds exactly one fiscal period ({currentYear}-01-01 ..
* {currentYear}-12-31). A run in an unseeded year has no period to book
* against, and the demo would show a payroll history that predates the
* company's books. So in January both runs stay inside the current year:
* January booked, February draft.
*/
export function resolveSandboxSalaryPeriods(today: Date): SandboxSalaryPeriods {
const year = today.getFullYear()
const month = today.getMonth() + 1
const [bookedMonth, draftMonth] = month === 1 ? [1, 2] : [month - 1, month]
return {
booked: { year, month: bookedMonth, paymentDate: toDateString(year, bookedMonth, 25) },
draft: { year, month: draftMonth, paymentDate: toDateString(year, draftMonth, 25) },
}
}
// ============================================================
// Figures (computed once, shared by the run rows and the employee rows)
// ============================================================
export interface SandboxPayrollFigures {
grossSalary: number
taxableIncome: number
taxWithheld: number
netDeductions: number
netSalary: number
avgifterRate: number
avgifterBasis: number
avgifterAmount: number
vacationAccrual: number
vacationAccrualAvgifter: number
totalEmployerCost: number
}
/**
* Mirror of lib/salary/calculation-engine for the one shape the seed uses:
* a single salary line, no benefits, no absence, no gross or net deductions.
*
* taxable = gross + förmåner (0 here)
* net = gross - skatt - nettoavdrag
* avgifter = (gross + förmåner) × 31,42 %
* semesterskuld = semesterunderlag × 12 %
* employer cost = gross + avgifter + semesteravsättning + avgifter på den
*/
function computeFigures(grossSalary: number): SandboxPayrollFigures {
const gross = roundOre(grossSalary)
const taxWithheld = lookupTaxWithheld(gross)
const netDeductions = 0
const vacationAccrual = roundOre(gross * VACATION_ACCRUAL_RATE)
const vacationAccrualAvgifter = roundOre(vacationAccrual * AVGIFTER_RATE)
const avgifterAmount = roundOre(gross * AVGIFTER_RATE)
return {
grossSalary: gross,
taxableIncome: gross,
taxWithheld,
netDeductions,
netSalary: roundOre(gross - taxWithheld - netDeductions),
avgifterRate: AVGIFTER_RATE,
avgifterBasis: gross,
avgifterAmount,
vacationAccrual,
vacationAccrualAvgifter,
totalEmployerCost: roundOre(
gross + avgifterAmount + vacationAccrual + vacationAccrualAvgifter,
),
}
}
/** The two demo employees' per-run figures. Identical on both runs. */
export const SANDBOX_PAYROLL_FIGURES = {
anna: computeFigures(ANNA_MONTHLY_SALARY),
erik: computeFigures(ERIK_HOURLY_RATE * ERIK_HOURS_WORKED),
} as const
/**
* Run-level totals, summed from the same figures the per-employee rows are
* written from. Computed here rather than typed out so salary_runs.total_* can
* never drift from sum(salary_run_employees).
*/
export const SANDBOX_RUN_TOTALS = {
total_gross: roundOre(
SANDBOX_PAYROLL_FIGURES.anna.grossSalary + SANDBOX_PAYROLL_FIGURES.erik.grossSalary,
),
total_tax: roundOre(
SANDBOX_PAYROLL_FIGURES.anna.taxWithheld + SANDBOX_PAYROLL_FIGURES.erik.taxWithheld,
),
total_net: roundOre(
SANDBOX_PAYROLL_FIGURES.anna.netSalary + SANDBOX_PAYROLL_FIGURES.erik.netSalary,
),
total_avgifter: roundOre(
SANDBOX_PAYROLL_FIGURES.anna.avgifterAmount + SANDBOX_PAYROLL_FIGURES.erik.avgifterAmount,
),
total_vacation_accrual: roundOre(
SANDBOX_PAYROLL_FIGURES.anna.vacationAccrual + SANDBOX_PAYROLL_FIGURES.erik.vacationAccrual,
),
total_employer_cost: roundOre(
SANDBOX_PAYROLL_FIGURES.anna.totalEmployerCost +
SANDBOX_PAYROLL_FIGURES.erik.totalEmployerCost,
),
} as const
/**
* Sum of vacation_accrual_avgifter across the run. Deliberately NOT part of
* SANDBOX_RUN_TOTALS: salary_runs has no column for it, and everything in that
* object is spread straight onto the run row. The semester voucher of the
* booked run needs the figure though (7519 / 2940), so it is exported here
* rather than recomputed by the caller.
*/
export const SANDBOX_TOTAL_VACATION_ACCRUAL_AVGIFTER = roundOre(
SANDBOX_PAYROLL_FIGURES.anna.vacationAccrualAvgifter +
SANDBOX_PAYROLL_FIGURES.erik.vacationAccrualAvgifter,
)
// ============================================================
// 1. employees
// ============================================================
export interface SandboxEmployeesInput {
userId: string
companyId: string
/** Reference date; employment start dates are derived from it. */
today: Date
/**
* `encryptPersonnummer` from lib/salary/personnummer, injected so this
* builder stays pure and unit-testable. employees.personnummer stores the
* AES-256-GCM ciphertext; personnummer_last4 stores the plain last four.
*/
encrypt: (personnummer: string) => string
}
/**
* The demo roster: one månadsavlönad tjänsteman and one timavlönad
* deltidsanställd, which between them exercise both salary_type branches in
* the calculation engine and both payslip line-item shapes.
*/
export function buildSandboxEmployees({
userId,
companyId,
today,
encrypt,
}: SandboxEmployeesInput) {
const base = {
user_id: userId,
company_id: companyId,
// Both are ordinary employees (7210), not företagsledare (7220) or
// styrelseledamot (7240): getLineItemAccount keys off this.
employment_type: 'employee',
tax_table_number: TAX_TABLE_NUMBER,
tax_column: TAX_COLUMN,
tax_municipality: 'Stockholm',
is_sidoinkomst: false,
f_skatt_status: 'a_skatt',
vacation_rule: 'procentregeln',
vacation_days_per_year: 25,
is_active: true,
}
return [
{
...base,
first_name: 'Anna',
last_name: SANDBOX_EMPLOYEE_LAST_NAMES.anna,
personnummer: encrypt(ANNA_PERSONNUMMER),
personnummer_last4: ANNA_PERSONNUMMER.slice(-4),
employment_start: shiftMonths(today, -ANNA_EMPLOYMENT_MONTHS_AGO),
employment_degree: 100,
salary_type: 'monthly',
monthly_salary: ANNA_MONTHLY_SALARY,
hourly_rate: null,
// Deliberately null, not a demo@example.com address. The payslip-send
// action is reachable on the seeded BOOKED run, and example.com has no
// MX: a visitor pressing it would hard-bounce mail off the production
// sending domain. Both approve routes treat a missing address as a
// warning, and the send route records "saknar e-postadress" instead of
// calling the mail provider. (The route also now refuses sandbox
// companies outright; this is the second lock on the same door.)
email: null,
// Swedbank: the only 5-digit clearings in the Swedish system start with
// 8. Stored as plain digits because every consumer (payment files,
// payslip PDF) normalizes away spaces and hyphens anyway.
clearing_number: '83271',
bank_account_number: '1234567',
specification_number: 1,
},
{
...base,
first_name: 'Erik',
last_name: SANDBOX_EMPLOYEE_LAST_NAMES.erik,
personnummer: encrypt(ERIK_PERSONNUMMER),
personnummer_last4: ERIK_PERSONNUMMER.slice(-4),
employment_start: shiftMonths(today, -ERIK_EMPLOYMENT_MONTHS_AGO),
employment_degree: 50,
salary_type: 'hourly',
monthly_salary: null,
hourly_rate: ERIK_HOURLY_RATE,
email: null,
// SEB (clearing 5000-5999), 4 digits.
clearing_number: '5000',
bank_account_number: '7654321',
specification_number: 2,
},
]
}
export interface SandboxEmployeeIds {
annaEmployeeId: string
erikEmployeeId: string
}
/**
* Map the inserted employee rows back to the two demo employees.
* Insert with `.select('id, last_name')` and pass the result straight in.
*/
export function mapSandboxEmployeeIds(
rows: Array<{ id: string; last_name: string }>,
): SandboxEmployeeIds {
const byLastName = new Map(rows.map((r) => [r.last_name, r.id]))
const annaEmployeeId = byLastName.get(SANDBOX_EMPLOYEE_LAST_NAMES.anna)
const erikEmployeeId = byLastName.get(SANDBOX_EMPLOYEE_LAST_NAMES.erik)
if (!annaEmployeeId || !erikEmployeeId) {
throw new Error('Sandbox seed: could not resolve both employee ids from the inserted rows')
}
return { annaEmployeeId, erikEmployeeId }
}
// ============================================================
// 2. salary_runs
// ============================================================
export interface SandboxSalaryRunsInput {
userId: string
companyId: string
today: Date
}
/**
* Two runs: last month booked and paid, this month still a draft. Both carry
* the same denormalized totals because the roster and the amounts are the
* same in both periods; the totals come from SANDBOX_RUN_TOTALS, which is
* summed from the per-employee figures.
*/
export function buildSandboxSalaryRuns({ userId, companyId, today }: SandboxSalaryRunsInput) {
const periods = resolveSandboxSalaryPeriods(today)
const todayDate = toDateString(today.getFullYear(), today.getMonth() + 1, today.getDate())
// Never stamp a completion timestamp in the future: in the January case the
// booked run's payday (25 January) can still be ahead of `today`.
const settledOn = earlierDate(periods.booked.paymentDate, todayDate)
const settledAt = `${settledOn}T09:00:00.000Z`
const base = {
user_id: userId,
company_id: companyId,
voucher_series: 'A',
...SANDBOX_RUN_TOTALS,
calculation_params: DEMO_CALCULATION_PARAMS,
}
return [
{
...base,
period_year: periods.booked.year,
period_month: periods.booked.month,
payment_date: periods.booked.paymentDate,
status: 'booked',
approved_by: userId,
approved_at: settledAt,
paid_at: settledAt,
booked_at: settledAt,
booked_by: userId,
notes: 'Demolönekörning: godkänd, utbetald och bokförd.',
},
{
...base,
period_year: periods.draft.year,
period_month: periods.draft.month,
payment_date: periods.draft.paymentDate,
status: 'draft',
// Spelled out rather than omitted: PostgREST normalizes the key set
// across a bulk insert, so a key present only on the row above would be
// sent as null here regardless.
approved_by: null,
approved_at: null,
paid_at: null,
booked_at: null,
booked_by: null,
notes: 'Demolönekörning: utkast, klar att granska.',
},
]
}
// ============================================================
// 3. salary_run_employees
// ============================================================
export interface SandboxSalaryRunEmployeesInput {
companyId: string
today: Date
bookedRunId: string
draftRunId: string
annaEmployeeId: string
erikEmployeeId: string
}
interface BreakdownStep {
label: string
formula: string
input: Record<string, number | string>
output: number | null
}
/**
* calculation_breakdown, in the shape RunCalculationDetails renders
* ({ steps: [{ label, formula, output }] }) and the engine produces. The
* skatteavdrag step names the table and column it actually came from, because
* it actually came from them (see lookupTaxWithheld).
*/
function buildBreakdownSteps(
figures: SandboxPayrollFigures,
baseStep: BreakdownStep,
): { steps: BreakdownStep[] } {
return {
steps: [
baseStep,
{
label: 'Bruttolön',
formula: 'grundlön + tillägg + frånvaro − bruttoavdrag',
input: { base: figures.grossSalary, additions: 0, absence: 0, gross_deductions: 0 },
output: figures.grossSalary,
},
{
label: 'Skattegrundande inkomst',
formula: 'bruttolön + förmåner',
input: { gross_salary: figures.grossSalary, benefit_values: 0 },
output: figures.taxableIncome,
},
{
label: 'Skatteavdrag',
formula: `skattetabell ${TAX_TABLE_NUMBER}, kolumn ${TAX_COLUMN}`,
input: {
taxable_income: figures.taxableIncome,
tax_table: TAX_TABLE_NUMBER,
tax_column: TAX_COLUMN,
},
output: figures.taxWithheld,
},
{
label: 'Nettolön',
formula: 'bruttolön − skatt − nettoavdrag',
input: {
gross: figures.grossSalary,
tax: figures.taxWithheld,
net_deductions: figures.netDeductions,
},
output: figures.netSalary,
},
{
label: 'Arbetsgivaravgifter',
formula: 'avgiftsunderlag × 31,42 %',
input: { avgifter_basis: figures.avgifterBasis, rate: figures.avgifterRate },
output: figures.avgifterAmount,
},
{
label: 'Semesteravsättning (procentregeln 12,00 %)',
formula: 'semesterunderlag × 12,00 %',
input: { vacation_basis: figures.grossSalary, rate: VACATION_ACCRUAL_RATE },
output: figures.vacationAccrual,
},
{
label: 'Arbetsgivaravgifter på semesteravsättning',
formula: 'semesteravsättning × 31,42 %',
input: {
vacation_accrual: figures.vacationAccrual,
avgifter_rate: figures.avgifterRate,
},
output: figures.vacationAccrualAvgifter,
},
{
label: 'Total arbetsgivarkostnad',
formula: 'bruttolön + avgifter + semesteravsättning + avgifter på semester',
input: {
gross: figures.grossSalary,
avgifter: figures.avgifterAmount,
vacation_accrual: figures.vacationAccrual,
vacation_avgifter: figures.vacationAccrualAvgifter,
},
output: figures.totalEmployerCost,
},
],
}
}
const ANNA_BREAKDOWN = buildBreakdownSteps(
SANDBOX_PAYROLL_FIGURES.anna,
{
label: 'Grundlön',
formula: 'månadslön × (sysselsättningsgrad / 100)',
input: { monthly_salary: ANNA_MONTHLY_SALARY, employment_degree: 100 },
output: SANDBOX_PAYROLL_FIGURES.anna.grossSalary,
},
)
const ERIK_BREAKDOWN = buildBreakdownSteps(
SANDBOX_PAYROLL_FIGURES.erik,
{
label: 'Grundlön (timavlönad)',
formula: 'timlön × arbetade timmar',
input: { hourly_rate: ERIK_HOURLY_RATE, hours_worked: ERIK_HOURS_WORKED },
output: SANDBOX_PAYROLL_FIGURES.erik.grossSalary,
},
)
/**
* Per-employee calculation results for both runs.
*
* YTD: the booked run is the earliest payroll in the sandbox, so its YTD is
* its own month; the draft run's YTD is the booked run plus itself. That is
* exactly what runSalaryCalculation would compute (it aggregates prior BOOKED
* runs inside the same period_year and adds the current result), so the demo
* stays consistent if the user recalculates the draft.
*
* Rows come back in the documented order booked/Anna, booked/Erik,
* draft/Anna, draft/Erik, but nothing downstream depends on it:
* buildSandboxSalaryLineItems keys off employee_id.
*/
export function buildSandboxSalaryRunEmployees({
companyId,
today,
bookedRunId,
draftRunId,
annaEmployeeId,
erikEmployeeId,
}: SandboxSalaryRunEmployeesInput) {
const periods = resolveSandboxSalaryPeriods(today)
const common = {
company_id: companyId,
tax_table_number: TAX_TABLE_NUMBER,
tax_column: TAX_COLUMN,
// Both runs pay out in the current calendar year (see the January note on
// resolveSandboxSalaryPeriods), so one year covers both.
tax_table_year: periods.booked.year,
avgifter_category: 'standard',
}
const anna = SANDBOX_PAYROLL_FIGURES.anna
const erik = SANDBOX_PAYROLL_FIGURES.erik
/** Figure columns shared by both runs for one employee. */
const results = (f: SandboxPayrollFigures) => ({
gross_salary: f.grossSalary,
gross_deductions: 0,
benefit_values: 0,
taxable_income: f.taxableIncome,
tax_withheld: f.taxWithheld,
net_deductions: f.netDeductions,
net_salary: f.netSalary,
avgifter_rate: f.avgifterRate,
avgifter_amount: f.avgifterAmount,
avgifter_basis: f.avgifterBasis,
vacation_accrual: f.vacationAccrual,
vacation_accrual_avgifter: f.vacationAccrualAvgifter,
})
const annaSnapshot = {
employee_id: annaEmployeeId,
employment_degree: 100,
monthly_salary: ANNA_MONTHLY_SALARY,
salary_type: 'monthly',
// Månadsavlönade have no hour count; the column is nullable.
hours_worked: null,
calculation_breakdown: ANNA_BREAKDOWN,
...results(anna),
}
const erikSnapshot = {
employee_id: erikEmployeeId,
employment_degree: 50,
// NOT NULL on the table, and 0 is what createSalaryRunWithEmployees writes
// for an hourly employee: the gross comes from timlön × timmar instead.
monthly_salary: 0,
salary_type: 'hourly',
hours_worked: ERIK_HOURS_WORKED,
calculation_breakdown: ERIK_BREAKDOWN,
...results(erik),
}
return [
{
...common,
...annaSnapshot,
salary_run_id: bookedRunId,
ytd_gross: anna.grossSalary,
ytd_tax: anna.taxWithheld,
ytd_net: anna.netSalary,
},
{
...common,
...erikSnapshot,
salary_run_id: bookedRunId,
ytd_gross: erik.grossSalary,
ytd_tax: erik.taxWithheld,
ytd_net: erik.netSalary,
},
{
...common,
...annaSnapshot,
salary_run_id: draftRunId,
ytd_gross: roundOre(anna.grossSalary * 2),
ytd_tax: roundOre(anna.taxWithheld * 2),
ytd_net: roundOre(anna.netSalary * 2),
},
{
...common,
...erikSnapshot,
salary_run_id: draftRunId,
ytd_gross: roundOre(erik.grossSalary * 2),
ytd_tax: roundOre(erik.taxWithheld * 2),
ytd_net: roundOre(erik.netSalary * 2),
},
]
}
// ============================================================
// 4. salary_line_items
// ============================================================
export interface SandboxSalaryLineItemsInput {
companyId: string
annaEmployeeId: string
erikEmployeeId: string
/**
* The inserted salary_run_employees rows. Insert with
* `.select('id, employee_id')` and pass the result straight in: the line
* item's content depends only on which employee the row belongs to, since
* both runs pay the same amounts.
*/
runEmployees: Array<{ id: string; employee_id: string }>
}
/**
* One payslip line per calculation row: Grundlön for the månadsavlönad,
* Timlön (80 tim × 245 kr) for the timavlönad. Account numbers come from
* getLineItemAccount so the seed cannot drift from the salary account map
* (both resolve to 7210 Löner till tjänstemän for employment_type 'employee').
*/
export function buildSandboxSalaryLineItems({
companyId,
annaEmployeeId,
erikEmployeeId,
runEmployees,
}: SandboxSalaryLineItemsInput) {
return runEmployees.map((sre) => {
const isAnna = sre.employee_id === annaEmployeeId
const isErik = sre.employee_id === erikEmployeeId
if (!isAnna && !isErik) {
throw new Error(
`Sandbox seed: salary_run_employee ${sre.id} belongs to an unknown employee`,
)
}
const itemType: SalaryLineItemType = isAnna ? 'monthly_salary' : 'hourly_salary'
return {
salary_run_employee_id: sre.id,
company_id: companyId,
item_type: itemType,
description: isAnna ? 'Grundlön' : 'Timlön',
quantity: isAnna ? 1 : ERIK_HOURS_WORKED,
unit_price: isAnna ? ANNA_MONTHLY_SALARY : ERIK_HOURLY_RATE,
amount: isAnna
? SANDBOX_PAYROLL_FIGURES.anna.grossSalary
: SANDBOX_PAYROLL_FIGURES.erik.grossSalary,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
is_gross_deduction: false,
is_net_deduction: false,
account_number: getLineItemAccount(itemType, 'employee'),
sort_order: 0,
}
})
}