From d41ef2a9098cc2bfbd66eb53c359aa9fb78f9457 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 6 Aug 2026 15:30:04 +0200 Subject: [PATCH] feat(sandbox): seed payroll, articles and a year of ledger history; calm the connect CTAs (#1437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- DECISIONS.md | 3 + .../payslips/send/__tests__/route.test.ts | 29 + .../salary/runs/[id]/payslips/send/route.ts | 9 + .../sandbox/seed/__tests__/articles.test.ts | 96 +++ .../seed/__tests__/ledger-history.test.ts | 354 ++++++++ .../seed/__tests__/salary-vouchers.test.ts | 121 +++ app/api/sandbox/seed/__tests__/salary.test.ts | 569 +++++++++++++ app/api/sandbox/seed/articles.ts | 124 +++ app/api/sandbox/seed/ledger-history.ts | 455 ++++++++++ app/api/sandbox/seed/route.ts | 348 +++++++- app/api/sandbox/seed/salary-vouchers.ts | 247 ++++++ app/api/sandbox/seed/salary.ts | 799 ++++++++++++++++++ components/agent/AgentTrigger.tsx | 73 +- .../dashboard/SkatteverketPromoCard.tsx | 61 +- components/onboarding/NewUserChecklist.tsx | 190 +++-- .../components/CloudBackupCard.tsx | 443 +++++----- .../components/BankSelector.tsx | 194 +++-- tests/schema/no-phantom-columns.test.ts | 15 +- 18 files changed, 3718 insertions(+), 412 deletions(-) create mode 100644 app/api/sandbox/seed/__tests__/articles.test.ts create mode 100644 app/api/sandbox/seed/__tests__/ledger-history.test.ts create mode 100644 app/api/sandbox/seed/__tests__/salary-vouchers.test.ts create mode 100644 app/api/sandbox/seed/__tests__/salary.test.ts create mode 100644 app/api/sandbox/seed/articles.ts create mode 100644 app/api/sandbox/seed/ledger-history.ts create mode 100644 app/api/sandbox/seed/salary-vouchers.ts create mode 100644 app/api/sandbox/seed/salary.ts diff --git a/DECISIONS.md b/DECISIONS.md index 2325b73f..a006b2dc 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -806,3 +806,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-06] Kontantmetoden year-end cut-off (BFL 5 kap 2 §) books moms to the VILANDE accounts (2618/2628/2638 ut, 2648 in), never 2611/2641: vilande accounts are deliberately absent from ACCOUNT_RUTA/ACCOUNT_TO_BOX, so the moms stays out of the momsdeklaration until payment, which is what bokslutsmetoden requires. 2647 was considered and rejected: it is domestic omvand betalningsskyldighet, unrelated. Cut-off posts as two AGGREGATE verifikat reversed on day 1 of the next period, and deliberately does NOT set invoices.journal_entry_id: the payment flows route on that link, so per-invoice linking would send every new-year payment down the accrual clearing path against a receivable the vandning already removed, booking the settlement twice. [2026-08-06] Prompt clarifications render from a structured summary (lib/agent-context/chat-clarifications.ts), never off the raw channel_context blob. A WhatsApp "nej" stores representation with participants:[] and purpose:null and denied:true, so branching on `!purpose` reads a settled denial as a half answer: the shipped renderer emitted "syfte SAKNAS: fråga bara efter syftet" about a meal the user had just said was not representation. `denied` and the genuine half answer (participants named, purpose missing, which BFL 5 kap 6-7 § does want completed) are now separate states. Also: the photo caption no longer reaches the prompt, for the reason already written down in channel-context-notes.ts (nobody was asked for it, nobody reviewed it), and free text passes through flattenMemoryContent because promptTemplate output is seeded as a user message and wrapToolResult only wraps tool results. [2026-08-06] Unmatched underlag are PROPOSED to the assistant, never auto-linked. WhatsApp intake writes neither invoice_inbox_items.matched_transaction_id nor transactions.document_id, so a chat-captured receipt is invisible to every lookup and #1425's backfill-by-document_id has nothing to backfill. Scoring unmatched items at read time (lib/agent-context/underlag-candidates.ts, reusing core-receipt-matcher) closes that with no migration and no link written by a machine, preserving the human confirm step; setting matched_transaction_id at intake above a confidence bar remains the open alternative and is a founder call. An uncomparable cross-currency amount disqualifies a candidate outright, because the matcher drops the amount signal there and date + merchant alone score 1.0. +[2026-08-06] Sandbox ledger history marked no_doc_required instead of seeding receipt documents: the history represents books kept before the company arrived in Accounted, so its underlag sits in the previous system. Same rationale and same sidecar table the SIE-import opt-in uses. Without it the demo's first screen read "Verifikat utan underlag: 39". +[2026-08-06] Sandbox payroll takes skatteavdrag from FALLBACK_TAX_TABLES_2026 rather than a flat schablon: the draft run ships calculated, so its live "Beräkna om" would have jumped ~4 600 kr away from the sibling booked run, and a wrong skatteavdrag would show unlabelled in the payslip, the 2710 line and the AGI figures. +[2026-08-06] Added guardSandbox to /api/salary/runs/[id]/payslips/send: it was the only send path without one, and seeding a booked salary run put "Skicka lönebesked" one click from an anonymous visitor with live Resend behind it. diff --git a/app/api/salary/runs/[id]/payslips/send/__tests__/route.test.ts b/app/api/salary/runs/[id]/payslips/send/__tests__/route.test.ts index 7c9e0a7a..8cde2895 100644 --- a/app/api/salary/runs/[id]/payslips/send/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/payslips/send/__tests__/route.test.ts @@ -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( diff --git a/app/api/salary/runs/[id]/payslips/send/route.ts b/app/api/salary/runs/[id]/payslips/send/route.ts index c9821fc2..3f804f96 100644 --- a/app/api/salary/runs/[id]/payslips/send/route.ts +++ b/app/api/salary/runs/[id]/payslips/send/route.ts @@ -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 diff --git a/app/api/sandbox/seed/__tests__/articles.test.ts b/app/api/sandbox/seed/__tests__/articles.test.ts new file mode 100644 index 00000000..53813d30 --- /dev/null +++ b/app/api/sandbox/seed/__tests__/articles.test.ts @@ -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)) + }) +}) diff --git a/app/api/sandbox/seed/__tests__/ledger-history.test.ts b/app/api/sandbox/seed/__tests__/ledger-history.test.ts new file mode 100644 index 00000000..bea9657d --- /dev/null +++ b/app/api/sandbox/seed/__tests__/ledger-history.test.ts @@ -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) { + 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() + 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) + }) +}) diff --git a/app/api/sandbox/seed/__tests__/salary-vouchers.test.ts b/app/api/sandbox/seed/__tests__/salary-vouchers.test.ts new file mode 100644 index 00000000..ab39f674 --- /dev/null +++ b/app/api/sandbox/seed/__tests__/salary-vouchers.test.ts @@ -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) + }) +}) diff --git a/app/api/sandbox/seed/__tests__/salary.test.ts b/app/api/sandbox/seed/__tests__/salary.test.ts new file mode 100644 index 00000000..9eaa1bb5 --- /dev/null +++ b/app/api/sandbox/seed/__tests__/salary.test.ts @@ -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>) { + 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/) + }) +}) diff --git a/app/api/sandbox/seed/articles.ts b/app/api/sandbox/seed/articles.ts new file mode 100644 index 00000000..c678c73f --- /dev/null +++ b/app/api/sandbox/seed/articles.ts @@ -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.', + }, + ] +} diff --git a/app/api/sandbox/seed/ledger-history.ts b/app/api/sandbox/seed/ledger-history.ts new file mode 100644 index 00000000..a5e6ca43 --- /dev/null +++ b/app/api/sandbox/seed/ledger-history.ts @@ -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 +} + +/** 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 +} + +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 = [ + 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 +} + +/** 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 { + 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() + + 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 } +} diff --git a/app/api/sandbox/seed/route.ts b/app/api/sandbox/seed/route.ts index 53054fdf..26c470e4 100644 --- a/app/api/sandbox/seed/route.ts +++ b/app/api/sandbox/seed/route.ts @@ -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 = {} + 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, diff --git a/app/api/sandbox/seed/salary-vouchers.ts b/app/api/sandbox/seed/salary-vouchers.ts new file mode 100644 index 00000000..9c222d3e --- /dev/null +++ b/app/api/sandbox/seed/salary-vouchers.ts @@ -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 +} + +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 +} diff --git a/app/api/sandbox/seed/salary.ts b/app/api/sandbox/seed/salary.ts new file mode 100644 index 00000000..8e98d6ec --- /dev/null +++ b/app/api/sandbox/seed/salary.ts @@ -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 + 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, + } + }) +} diff --git a/components/agent/AgentTrigger.tsx b/components/agent/AgentTrigger.tsx index 0f819532..d70513b2 100644 --- a/components/agent/AgentTrigger.tsx +++ b/components/agent/AgentTrigger.tsx @@ -12,6 +12,13 @@ import { CAPABILITY } from '@/lib/entitlements/keys' // Floating trigger sits above the page bottom-right, opens the AgentSheet when // clicked. Hidden when the sheet is already open so the icon doesn't double up. // +// Desktop-only for a FRESH open (founder call 2026-08-06). On mobile the pill +// landed on top of the bottom nav and covered page content, and the bottom nav +// already carries an "Assistent" tab to /chat behind the very same +// identity.isVerified gate used here, so the pill was pure redundancy. A +// COLLAPSED session keeps its handle on every viewport and on every page except +// /chat: see the page-suppression block and the visibility class below. +// // Route-aware: routeToIntent(pathname) picks the right intent + intentArgs so // clicking the FAB on /invoices/abc-123 opens invoice.draft with that invoice // id (rather than the page-agnostic general.help with just the URL). The @@ -44,19 +51,33 @@ export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) { // the session is merely collapsed we KEEP the FAB: it's the handle that // brings the minimized conversation back. if (isOpen && !collapsed) return null - // The page-suppression rules below apply only to a FRESH open. A collapsed - // session always gets its reopen handle, regardless of page: otherwise a - // conversation minimized on /chat or /bookkeeping/[id] could never be - // brought back. + // Page suppression, split by WHY the page suppresses. The split is the point: + // one rule is about redundancy (the page already IS the conversation), the + // other is about crowding (the page is dense and the pill would sit on top of + // it). Only the redundancy rule survives into the collapsed state, because + // only it leaves the user another route back to the conversation. + // + // /chat*: suppressed in BOTH states. Fresh open: the surface IS the chat, so + // a floating "Fråga …" pill is redundant and overlaps the input. Collapsed: + // the /chat layout lists every conversation in its sidebar and renders the + // selected one in the main pane, so navigating here IS the way back; a handle + // that re-expands the panel on top of it would put two chats on one screen. + // Safe because the session lives in AgentSheetProvider in the dashboard + // layout, one level above the page: it stays mounted (messages, streaming, + // pending approval cards intact) the whole time the user is on /chat, and the + // handle reappears the moment they navigate anywhere else, bottom nav + // included. Nothing is discarded, the handle is just hidden while the user is + // standing inside the surface it would lead to. + if (pathname?.startsWith('/chat')) return null + // /bookkeeping/[id]: FRESH open only. The verifikation editor is a dense + // regulatory surface (debits/credits, BAS codes, period locks) and a floating + // "Fråga … om denna verifikation" pill on top of it adds noise without + // earning its place. A COLLAPSED session keeps its handle here, and on every + // other page in the app: this page offers no other route back to a minimized + // conversation, and stranding a half-finished booking is a worse outcome than + // a pill over the editor. (/bookkeeping list, /bookkeeping/new and + // /bookkeeping/year-end are not the editor and keep the fresh-open FAB.) if (!collapsed) { - // The /chat surface IS the chat: a floating "Fråga …" pill on top of it - // is redundant and overlaps the input. Suppress while the user is here. - if (pathname?.startsWith('/chat')) return null - // The verifikation editor is a dense regulatory surface (debits/credits, - // BAS codes, period locks): a floating "Fråga … om denna verifikation" - // pill on top of it adds noise without earning its place. Suppress on - // /bookkeeping/[id] specifically; /bookkeeping (list), /bookkeeping/new, - // and /bookkeeping/year-end still get the FAB. const segs = pathname?.split('/').filter(Boolean) ?? [] if (segs[0] === 'bookkeeping' && segs[1] && segs[1] !== 'year-end' && segs[1] !== 'new') { return null @@ -107,13 +128,33 @@ export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) { }) } + // Mobile visibility, decided per state rather than per viewport alone: + // + // fresh open -> 'hidden md:flex'. `display: none` (not opacity/visibility) + // so the button is genuinely non-interactive on mobile: not + // clickable, not tabbable, out of the accessibility tree. + // Losing it costs nothing, the bottom nav's "Assistent" tab + // reaches the same agent. + // collapsed -> 'flex' on every viewport. Off /chat this is the ONLY route + // back to a minimized conversation: the bottom-nav tab opens + // the /chat surface instead of restoring the in-progress + // sheet, so hiding the handle on mobile would strand a + // session mid-booking with no way to reopen it. On /chat the + // handle is suppressed outright (see above), which is exactly + // the case where the nav tab does lead somewhere useful. + // + // A pure CSS switch on purpose: no useMediaQuery, so no hydration mismatch, + // no resize listener, no layout shift on first paint. + const visibilityClass = collapsed ? 'flex' : 'hidden md:flex' + return ( - - - +
+

+ {t('skv_promo_description')}{' '} + {/* py-3/-my-3: a 44px tap target on a link that still sits inline in + the sentence; the negative margin keeps the line box at 20px so + nothing shifts. */} + {/* eslint-disable-next-line @next/next/no-html-link-for-pages -- /api route, not a Next page; the authorize endpoint 302s to Skatteverket, which the client router cannot follow */} + + {t('skv_promo_cta')} + +

+
) } diff --git a/components/onboarding/NewUserChecklist.tsx b/components/onboarding/NewUserChecklist.tsx index 3017fb98..87d53549 100644 --- a/components/onboarding/NewUserChecklist.tsx +++ b/components/onboarding/NewUserChecklist.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { Check } from 'lucide-react' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { useErrorToast } from '@/lib/hooks/use-error-toast' @@ -23,8 +24,13 @@ interface NewUserChecklistProps { /** * First-run getting-started block on Hem, in the founder-picked stepped - * shape: a numbered three-step thread (get the books in, connect the bank, - * build the assistant) with the partner marks on the steps that have them. + * shape: a numbered thread (get the books in, connect the bank, connect + * Skatteverket, build the assistant) on a hairline spine. + * Only the step you are on argues its case: it carries the description and + * the partner marks next to a filled action. Steps you have not reached yet + * drop the pitch but keep a quiet outline action, so any step stays one + * click away (connect Skatteverket before the bank, if that is your order). + * Done steps collapse to their title. * The persisted state machine is unchanged (path / completedAt / * dismissedAt via /api/onboarding/state); "Starta från början" records the * fresh path and simply checks off step one. @@ -104,11 +110,12 @@ export default function NewUserChecklist({ } const activeStep = !step1Done ? 1 : !step2Done ? 2 : !step3Done ? 3 : 4 + const stepCount = hasSkatteverket ? 4 : 3 return ( -
-
-

{t('title', { count: hasSkatteverket ? 4 : 3 })}

+
+
+

{t('title', { count: stepCount })}

-
+
    -

    - {t('step_books_description')}{' '} - {' '} - {t('step_books_fresh_suffix')} -

    -
    - - - - - - {t('step_books_sie')} - -
    + {t('step_books_action')} + + )} + marks={ + <> + + + + {t('step_books_sie')} + + } + > + {t('step_books_description')}{' '} + {' '} + {t('step_books_fresh_suffix')}
    -

    {t('step_bank_description')}

    -
    + action={(variant) => ( - {hasBanking && ( - - )} -
    + )} + marks={ + hasBanking ? ( + + ) : undefined + } + > + {t('step_bank_description')}
    {hasSkatteverket && ( @@ -187,22 +193,18 @@ export default function NewUserChecklist({ done={step3Done} active={activeStep === 3} title={t('step_skv_title')} - > -

    {t('step_skv_description')}

    -
    - - -
    + )} + marks={} + > + {t('step_skv_description')} )} @@ -213,30 +215,41 @@ export default function NewUserChecklist({ title={t('step_assistant_title')} badge={t('step_assistant_beta')} last - > -

    {t('step_assistant_description')}

    -
    + action={(variant) => ( -
    + )} + > + {t('step_assistant_description')} -
+
) } -/** One numbered step on the thread: dot + hairline down to the next step. */ +/** + * One numbered step on the thread: dot + hairline down to the next step. + * `children` (the pitch) and `marks` (partner logos) render only while this + * is the step the user is on. `action` renders on every step that is not + * done: filled on the active step, outline on the ones further down, so no + * step is ever unreachable just because it is not next in line. + * The title row is a constant 36px so it matches the h-9 action button and + * the dot (mt-1) centres in it; the spine then runs dot-bottom to dot-top + * (top-8 to -bottom-1) without visible breaks. + */ function Step({ number, done, active, title, badge, + action, + marks, last = false, children, }: { @@ -245,43 +258,64 @@ function Step({ active: boolean title: string badge?: string + action?: (variant: 'default' | 'outline') => React.ReactNode + marks?: React.ReactNode last?: boolean children: React.ReactNode }) { + const open = active && !done + return ( -
+
  • {!last && (
  • ) } diff --git a/extensions/general/cloud-backup/components/CloudBackupCard.tsx b/extensions/general/cloud-backup/components/CloudBackupCard.tsx index a2c0dc8c..e42fa5f4 100644 --- a/extensions/general/cloud-backup/components/CloudBackupCard.tsx +++ b/extensions/general/cloud-backup/components/CloudBackupCard.tsx @@ -5,21 +5,19 @@ import { useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' -import { Label } from '@/components/ui/label' +import { AttnLine } from '@/components/ui/attn-line' +import { + SettingsRow, + SettingsRowEnd, + SettingsSelect, +} from '@/components/settings/SettingsRows' import { useToast } from '@/components/ui/use-toast' +import { cn } from '@/lib/utils' import { DestructiveConfirmDialog, useDestructiveConfirm, } from '@/components/ui/destructive-confirm-dialog' -import { - AlertTriangle, - Box, - Cloud, - ExternalLink, - Loader2, - RefreshCw, - Unplug, -} from 'lucide-react' +import { Box, Cloud, ExternalLink, Loader2, RefreshCw, Unplug } from 'lucide-react' import type { CloudBackupStatus, CloudLastSync, @@ -117,54 +115,78 @@ export default function CloudBackupCard() { if (isLoading) { return ( -
    -

    {t('ext_cloud_backup_loading')}

    -
    +

    + {t('ext_cloud_backup_loading')} +

    ) } + const providers = status?.providers ?? [] + // Configured too: reconnecting a destination whose credentials are gone + // would only bounce off `/connect`, so that row keeps its quiet + // "not configured" line instead. + const reauthProviders = providers.filter((p) => p.configured && p.needs_reauth) + + // `/status` returns a row for every provider the build knows about, whether + // or not this deployment has credentials for it, so the note has to filter: + // naming Dropbox where Dropbox is not configured would promise a + // destination that does not exist. Configured (not connected) is the right + // cut, because the note is the compliance framing for the decision to + // connect and has to read before anything is connected. + const destinations = providers + .filter((p) => p.configured) + .map((p) => PROVIDER_META[p.provider]?.label ?? p.provider) + .join(' / ') + return ( -
    - {(status?.providers ?? []).map((providerStatus) => ( - - ))} +
    + {reauthProviders.length > 0 && } + +
    + {providers.map((providerStatus) => ( + + ))} +
    + + {/* + The 7-year BFL note is compliance context, so it stays visible rather + than hiding behind hover: it is shown once for the whole section + instead of once per destination, because the statement is identical + for every provider and repeating it was what made the panel bulky. + */} + {destinations.length > 0 && ( +

    + {t('ext_cloud_backup_legal_note', { provider: destinations })} +

    + )}
    ) } -interface ProviderRowProps { - status: CloudProviderStatus - onChanged: () => Promise | void -} - /** - * One destination: identity on the left, its own connection state, schedule - * and actions on the right. Every request carries `?provider=`, so the two - * rows never touch each other's records. + * Start the OAuth handshake for one destination. Shared by the connect button + * on a row and by the section-level reconnect line, so both take the exact + * same path. */ -function ProviderRow({ status, onChanged }: ProviderRowProps) { +function useConnectProvider(providerId: CloudProviderId, provider: string) { const { toast } = useToast() const t = useTranslations('extensions') - const { dialogProps, confirm } = useDestructiveConfirm() - const [isConnecting, setIsConnecting] = useState(false) - const [isSyncing, setIsSyncing] = useState(false) - const [isDisconnecting, setIsDisconnecting] = useState(false) - const providerId = status.provider - const meta = PROVIDER_META[providerId] - const provider = meta?.label ?? providerId - const Icon = meta?.icon ?? Cloud - const qs = `?provider=${encodeURIComponent(providerId)}` - - const handleConnect = useCallback(async () => { + const connect = useCallback(async () => { + // The reconnect affordance is a plain inline link (no disabled state), so + // the guard lives here instead of on a button. + if (isConnecting) return setIsConnecting(true) try { - const res = await fetch(`${API_BASE}/connect${qs}`, { method: 'POST' }) + const res = await fetch( + `${API_BASE}/connect?provider=${encodeURIComponent(providerId)}`, + { method: 'POST' } + ) if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(body.error || t('ext_cloud_backup_connect_start_failed')) @@ -179,7 +201,67 @@ function ProviderRow({ status, onChanged }: ProviderRowProps) { }) setIsConnecting(false) } - }, [provider, qs, t, toast]) + }, [isConnecting, provider, providerId, t, toast]) + + return { isConnecting, connect } +} + +/** + * Attention is one ochre sentence, max one per section (convention 6), so the + * reconnect notice lives here and not on each row: both destinations can lose + * their token at once, and two stacked banners is exactly the shape the + * convention forbids. The sentence names every affected destination; the + * action reconnects the first, since one redirect can only hand off to one + * provider. Once it is back the line returns for the next one. + */ +function ReauthAttn({ providers }: { providers: CloudProviderStatus[] }) { + const t = useTranslations('extensions') + const labels = providers.map((p) => PROVIDER_META[p.provider]?.label ?? p.provider) + const { isConnecting, connect } = useConnectProvider(providers[0].provider, labels[0]) + + return ( + + {t('ext_cloud_backup_reauth_description', { provider: labels.join(' / ') })} + + ) +} + +interface ProviderRowProps { + status: CloudProviderStatus + onChanged: () => Promise | void +} + +/** + * One destination as a single hairline row: brand mark and name on the left, + * the connect action on the right. Connected destinations unfold their detail + * (last sync, schedule) as indented settings rows below the same header row. + * Every request carries `?provider=`, so the two rows never touch each + * other's records. The reconnect notice is deliberately not here: it belongs + * to the section (see `ReauthAttn`), one ochre sentence for all destinations. + */ +function ProviderRow({ status, onChanged }: ProviderRowProps) { + const { toast } = useToast() + const t = useTranslations('extensions') + const { dialogProps, confirm } = useDestructiveConfirm() + + const [isSyncing, setIsSyncing] = useState(false) + const [isDisconnecting, setIsDisconnecting] = useState(false) + + const providerId = status.provider + const meta = PROVIDER_META[providerId] + const provider = meta?.label ?? providerId + const Icon = meta?.icon ?? Cloud + const qs = `?provider=${encodeURIComponent(providerId)}` + + const { isConnecting, connect: handleConnect } = useConnectProvider(providerId, provider) const handleDisconnect = useCallback(async () => { setIsDisconnecting(true) @@ -296,102 +378,58 @@ function ProviderRow({ status, onChanged }: ProviderRowProps) { if (ok) await syncOnce(true) }, [confirm, syncOnce, t]) + // The scope copy ("only files the app creates" / "its own app folder") is + // what earns the OAuth grant, so it stays visible in the state where the + // user decides: it replaces the tagline, which only paraphrased the row + // title. Connected rows swap it for the account the backup lands in. + const scopeKey = + providerId === 'dropbox' + ? 'ext_cloud_backup_connect_description_dropbox' + : 'ext_cloud_backup_connect_description_google' + + const supportingLine = status.connected + ? status.account_email + : status.configured + ? t(scopeKey) + : null + return ( -
    +
    -
    - {/* Identity */} -
    -
    - -
    -
    -

    {provider}

    -

    - {t('ext_cloud_backup_card_tagline', { provider })} -

    -

    - {t('ext_cloud_backup_legal_note', { provider })} -

    + + {/* Header row: identity left, action right, in every state. */} +
    +
    +
    - {/* Controls */} -
    - {!status.configured ? ( -

    - {t('ext_cloud_backup_not_configured', { provider })} -

    - ) : status.connected ? ( - <> - {status.needs_reauth && ( -
    - -
    -

    - {t('ext_cloud_backup_reauth_title', { provider })} -

    -

    - {t('ext_cloud_backup_reauth_description', { provider })} -

    - -
    -
    - )} -
    -
    -
    - {t('ext_cloud_backup_account_label')} -
    -
    {status.account_email}
    -
    -
    -
    - {t('ext_cloud_backup_last_sync_label')} -
    -
    - {status.last_sync ? ( - - ) : ( - - {t('ext_cloud_backup_never')} - - )} -
    -
    -
    - -
    - -
    - -
    - - -
    - - ) : ( - <> -

    - {t( - providerId === 'dropbox' - ? 'ext_cloud_backup_connect_description_dropbox' - : 'ext_cloud_backup_connect_description_google' + + ) : ( + -

    - - )} -
    + + )} +
    + )}
    + + {/* + Both gates, not just `connected`: a deployment that loses its + credentials while an account is still stored shows the "not + configured" state alone, exactly as the header row does, instead of + offering controls next to a line saying the destination is unavailable. + `role="group"` ties these controls to the destination they belong to: + with two rows open, "Synka nu" is otherwise unattributable. + */} + {status.configured && status.connected && ( +
    + + {status.last_sync ? ( + + ) : ( + {t('ext_cloud_backup_never')} + )} + + + +
    + )}
    ) } @@ -487,7 +538,7 @@ function LastSyncSummary({ const verified = files ? files.every((f) => f.sha256) : Boolean(lastSync.sha256) return ( - <> + ) } @@ -557,8 +608,7 @@ function ScheduleSection({ const [localHour, setLocalHour] = useState(scheduleHour(schedule)) const [isSaving, setIsSaving] = useState(false) - // Each provider renders its own controls, so the ids must not collide. - const toggleId = `auto-sync-toggle-${providerId}` + // Each provider renders its own controls, so the id must not collide. const hourId = `auto-sync-hour-${providerId}` useEffect(() => { @@ -618,48 +668,43 @@ function ScheduleSection({ ) return ( -
    -
    -
    - -

    - {t('ext_cloud_backup_auto_sync_description', { provider })} -

    -
    - -
    + <> + + + + + {enabled && ( -
    - - + {isSaving && } -
    + )} {schedule?.last_auto_sync_at && ( -

    +

    {t('ext_cloud_backup_last_auto_sync')} {formatDateTime(schedule.last_auto_sync_at)}{' '} {schedule.last_auto_sync_status === 'success' ? ( · {t('ext_cloud_backup_auto_sync_success')} @@ -675,7 +720,7 @@ function ScheduleSection({ ) : null}

    )} -
    + ) } diff --git a/extensions/general/enable-banking/components/BankSelector.tsx b/extensions/general/enable-banking/components/BankSelector.tsx index d1782204..eefe8f85 100644 --- a/extensions/general/enable-banking/components/BankSelector.tsx +++ b/extensions/general/enable-banking/components/BankSelector.tsx @@ -1,7 +1,8 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import { Loader2 } from 'lucide-react' +import { ChevronRight, Landmark, Loader2, Search } from 'lucide-react' +import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' export interface Bank { @@ -27,7 +28,52 @@ interface BankSelectorProps { className?: string } -function BankCard({ bank, isConnecting, connectingBankName, onConnect }: { +/** + * Eyebrow above a list section: same micro-label and same px-1 inset as the + * SettingsGroup eyebrow, so the two sit on one left edge. + */ +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

    + {children} +

    + ) +} + +/** + * 32px leading mark for a row. Real bank logos are drawn for a white + * background, so they sit on a white chip (same treatment as LogoMark in + * components/onboarding/NewUserChecklist.tsx); the no-logo fallback uses the + * neutral surface instead, which keeps the icon readable in dark mode. + * While connecting, the mark is replaced by a bare spinner: quiet inline + * state, no tint, no box. + */ +function BankMark({ bank, connecting }: { bank: Bank; connecting: boolean }) { + if (connecting) { + return ( + + + ) + } + + if (bank.logo) { + return ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + + + ) + } + + return ( + + + ) +} + +function BankRow({ bank, isConnecting, connectingBankName, onConnect }: { bank: Bank isConnecting: boolean connectingBankName: string | null @@ -37,54 +83,34 @@ function BankCard({ bank, isConnecting, connectingBankName, onConnect }: { return ( ) } @@ -151,46 +177,42 @@ export function BankSelector({
    {/* Search input */}
    - - - - + setSearchQuery(e.target.value)} - className="w-full pl-10 pr-3 py-3 border border-border rounded-lg bg-background text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-all" + className="pl-10" />
    - {/* Loading state */} + {/* Loading state. The spinner is decorative, so the state needs a text + equivalent: without it a screen-reader user gets silence between + submitting and the list appearing. */} {isLoading && ( -
    - +
    +
    )} - {/* Error state */} + {/* Error state: one quiet line, not a tinted panel. role="alert" so the + failure is announced rather than only redrawn. */} {error && ( -
    -

    {error}

    -
    +

    {error}

    )} - {/* Bank grid */} + {/* Bank list */} {!isLoading && !error && ( <> {filteredBanks.length === 0 ? ( -
    +

    {searchQuery ? `Inga banker matchar "${searchQuery}"` @@ -203,36 +225,46 @@ export function BankSelector({ )}

    ) : ( -
    + // -mx-1 cancels the px-1 the settings panel wraps this in, so the + // hairlines and the hover band run the full width of the settings + // content, level with the SettingsRow hairlines above; the rows' + // own px-1 then puts the bank mark on the same left edge as the + // settings labels. + // max-h-96 is exactly eight 48px rows: a scroll box with a reason. +
    {popularBanks.length > 0 && ( -
    -

    Populära banker

    -
    +
    + Populära banker +
    {popularBanks.map((bank) => ( - + ))}
    -
    +
    )} - {popularBanks.length > 0 && otherBanks.length > 0 && ( -

    Alla banker

    + {otherBanks.length > 0 && ( +
    + {popularBanks.length > 0 && Alla banker} +
    + {otherBanks.map((bank) => ( + + ))} +
    +
    )} -
    - {otherBanks.map((bank) => ( - - ))} -
    )} )} - {/* Connecting overlay */} + {/* The single worded home of the connecting state: one muted line rather + than a tinted bordered panel, and it names the bank so the state is + still readable when the chosen row has scrolled out of view. */} {isConnecting && connectingBankName && ( -
    - - Ansluter till {connectingBankName}... -
    +

    +

    )} ) diff --git a/tests/schema/no-phantom-columns.test.ts b/tests/schema/no-phantom-columns.test.ts index f1affeb3..879e2f11 100644 --- a/tests/schema/no-phantom-columns.test.ts +++ b/tests/schema/no-phantom-columns.test.ts @@ -79,8 +79,21 @@ const KNOWN_STALE_ON_CONFLICT: Record = {} * * Baseline 2026-07-26: 346 (145 dynamic-payload, 116 dynamic-select, * 48 dynamic-logical, 32 spread-payload, 4 dynamic-column, 1 computed key). + * + * Raised 2026-08-06 for the sandbox seed's payroll + ledger-history builders. + * app/api/sandbox/seed/ follows the pure-row-builder pattern the existing + * customers.ts / pending-operations.ts modules established: the builder returns + * complete row objects and route.ts spreads them, adding only the ids it had to + * insert first (voucher_number, journal_entry_id, account_id). The scanner + * cannot see through that spread. Writing the columns out again in route.ts to + * satisfy the scanner would duplicate every builder's shape at the call site, + * which is the thing the builders exist to prevent, and the row shapes are + * covered by their own unit tests instead. + * + * Baseline 2026-08-06: 370 (158 dynamic-payload, 120 dynamic-select, + * 47 dynamic-logical, 38 spread-payload, 5 dynamic-column, 2 computed key). */ -const UNRESOLVED_CEILING = 360 +const UNRESOLVED_CEILING = 375 /** * Floor on statically resolved column references. Guards the guard: if a change