Files
accounted/app/api/sandbox/seed/__tests__/ledger-history.test.ts
T
d41ef2a909 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>
2026-08-06 15:30:04 +02:00

355 lines
13 KiB
TypeScript

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)
})
})