Files
accounted/app/api/sandbox/seed/salary-vouchers.ts
T
d41ef2a909 feat(sandbox): seed payroll, articles and a year of ledger history; calm the connect CTAs (#1437)
* feat(sandbox): seed payroll, articles and a year of ledger history; calm the connect CTAs

The sandbox showed neither Löner nor a usable set of reports, and the
"connect X" surfaces were oversized boxed cards.

Sandbox seed:
- pays_salaries + employer_registered, so Löner and Anställda appear at all
  (an enskild firma is not an employer by default). Both seeded employees are
  employment_type 'employee': an EF may employ staff, just not its own owner.
- Two employees, one booked and one open lönekörning, and the three verifikat
  the booked run must have posted (7210/2710/1930, 7510/2731, 7290+7519/
  2920+2940). Skatteavdrag comes from the real Skatteverket 2026 tables.
- Year-to-date ledger history, January through last month, with the quarterly
  momsredovisning cleared to 2650 and paid on the SFL deadline. Without the
  settlement the demo collected VAT all year and never remitted it, which left
  an implausible bank balance and 155 813 kr of moms "att betala".
- The history is exempted through journal_entry_no_doc_required, the same way
  the SIE-import opt-in treats imported books: its kvitton live in the previous
  system, and unflagged it put 39 "verifikat utan underlag" on the home screen.
- Artikelregister, and the BAS accounts the K1 chart omits for an enskild firma.
- History is numbered before the invoice and payroll vouchers so the series runs
  forwards through the year, and its writes are batched.

Connect CTAs:
- Bank picker: a two-column grid of 95px bordered logo cards becomes flat
  hairline rows, Lucide icons, and a quiet inline connecting state.
- Cloud backup: each provider collapses to one row; the BFL note is shown once
  for the section and names only configured destinations.
- Hem first-run: only the active step argues its case, but every not-done step
  keeps a reachable action. The Skatteverket nudge becomes one quiet sentence.

Mobile assistant FAB: a fresh open is desktop-only, since the bottom nav already
has an Assistent tab. A collapsed session keeps its handle everywhere except
/chat, which is itself the way back to the conversation.

Also closes a real hole: /api/salary/runs/[id]/payslips/send had no sandbox
guard, and a seeded booked run put "Skicka lönebesked" one click from an
anonymous visitor with live Resend behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sandbox): check the two unchecked Supabase errors and tighten review nits

CodeRabbit review on #1437.

Major: two calls discarded their error and continued with null data. A failed
chart_of_accounts re-select would have written account_id: null onto every
ledger-history and salary voucher line, and a failed next_voucher_number would
have inserted a posted verifikat with no number, which is a hole in the
verifikationsserie (BFNAR 2013:2). Both now throw, and a null voucher number is
rejected explicitly.

Minor: the A-004 note claimed a 10 % markup on numbers that are 11.1 %; the
salary breakdown test's name said the opposite of its assertions after the
switch to the real tax table; the ledger-history doc still said 4 to 6 verifikat
per month before the quarterly momsredovisning added a seventh in March, May and
June.

Bank picker: the spinner is aria-hidden, so loading and connecting had no text
equivalent and a failed bank fetch was never announced. Added role="status" with
an sr-only label, and role="alert" on the error line.

Declined: confirm-before-disconnect on the cloud-backup row. Disconnect was
unconfirmed before this PR too, so adding a dialog is a behaviour change beyond
the redesign rather than a fix to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:30:04 +02:00

248 lines
7.6 KiB
TypeScript

/**
* Journal entries for the sandbox's BOOKED salary run.
*
* A run in status 'booked' that produced no verifikat would be a lie: in the
* real product `bookPaidSalaryRun` posts 2-4 entries through the bookkeeping
* engine before it advances the status. The sandbox seed cannot call that path
* (it goes through the engine, which emits events, and the seed deliberately
* inserts journal rows directly), so this module mirrors the account structure
* of `createSalaryRunEntries` in lib/salary/salary-entries.ts instead.
*
* Accounts come from SALARY_ACCOUNTS (lib/salary/account-mapping.ts), not from
* literals here, so a future BAS remap moves the seed with the engine.
*
* Pure builders, in the same style as ./customers.ts and ./pending-operations.ts:
* the caller assigns voucher numbers and journal_entry_id foreign keys.
*/
import { SALARY_ACCOUNTS } from '@/lib/salary/account-mapping'
/** Money rounding, per the project rule: never toFixed(). */
const ore = (n: number) => Math.round(n * 100) / 100
/**
* Every account these vouchers can touch, for the seed's chart-of-accounts
* check. seed_chart_of_accounts only lays down its 7xxx personnel block for
* aktiebolag, and the sandbox company is an enskild firma, so all of these are
* missing until the seed creates them.
*
* Derived from SALARY_ACCOUNTS rather than listed, so it cannot fall behind the
* account map. SICK_PAY and VACATION_PAY are in the map but unused by this
* seed; including them is harmless (the account simply exists in the chart) and
* keeps the list free of a hand-maintained subset.
*/
export const SANDBOX_SALARY_ACCOUNT_NUMBERS: readonly string[] =
Object.values(SALARY_ACCOUNTS)
export interface SalaryVoucherTotals {
/** Sum of gross_salary across the run's employees. */
totalGross: number
/** Sum of tax_withheld. */
totalTax: number
/** Sum of net_salary. */
totalNet: number
/** Sum of avgifter_amount (arbetsgivaravgifter on the payroll). */
totalAvgifter: number
/** Sum of vacation_accrual (semesterlöneskuld change). */
totalVacationAccrual: number
/** Sum of vacation_accrual_avgifter (avgifter on the accrued vacation). */
totalVacationAvgifter: number
}
export interface SalaryVoucherInput extends SalaryVoucherTotals {
userId: string
companyId: string
fiscalPeriodId: string
salaryRunId: string
/** ISO date the run pays out on; the entry date for all three vouchers. */
paymentDate: string
periodYear: number
periodMonth: number
}
export interface SeedJournalLine {
account_number: string
debit_amount: number
credit_amount: number
sort_order: number
/** Always set explicitly: PostgREST normalizes columns across a bulk insert,
* so omitting it on some rows while another row sets it sends null and
* violates NOT NULL instead of falling through to the '{}' default. */
dimensions: Record<string, string>
}
export interface SeedJournalEntry {
user_id: string
company_id: string
fiscal_period_id: string
entry_date: string
description: string
source_type: 'salary_payment'
source_id: string
status: 'posted'
committed_at: string
voucher_series: string
}
export interface SalaryVoucher {
entry: SeedJournalEntry
lines: SeedJournalLine[]
/** Which salary_runs column should point at this entry once it is inserted. */
runColumn: 'salary_entry_id' | 'avgifter_entry_id' | 'vacation_entry_id'
}
/**
* Build the vouchers for one booked salary run.
*
* Entry 1 (lön): D 7210 brutto / C 2710 personalskatt + C 1930 netto
* Entry 2 (avgifter): D 7510 sociala avgifter / C 2731 avräkning sociala avgifter
* Entry 3 (semester): D 7290 + D 7519 / C 2920 + C 2940
*
* Entry 3 is omitted when the run accrued no vacation, mirroring the engine
* (a zero-amount voucher would violate the balance trigger's "total > 0").
*
* Throws when gross != tax + net. That identity holds for the seeded run
* (no benefits, no gross or net deductions), and an unbalanced verifikat must
* fail loudly rather than reach the ledger.
*/
export function buildSandboxSalaryVouchers(input: SalaryVoucherInput): SalaryVoucher[] {
const {
userId,
companyId,
fiscalPeriodId,
salaryRunId,
paymentDate,
periodYear,
periodMonth,
totalGross,
totalTax,
totalNet,
totalAvgifter,
totalVacationAccrual,
totalVacationAvgifter,
} = input
const gross = ore(totalGross)
const tax = ore(totalTax)
const net = ore(totalNet)
const avgifter = ore(totalAvgifter)
const vacation = ore(totalVacationAccrual)
const vacationAvgifter = ore(totalVacationAvgifter)
if (ore(tax + net) !== gross) {
throw new Error(
`Sandbox salary voucher would not balance: gross ${gross} != tax ${tax} + net ${net}`,
)
}
const periodLabel = `${periodYear}-${String(periodMonth).padStart(2, '0')}`
const base = {
user_id: userId,
company_id: companyId,
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
source_type: 'salary_payment' as const,
source_id: salaryRunId,
status: 'posted' as const,
committed_at: paymentDate,
voucher_series: 'A',
}
const vouchers: SalaryVoucher[] = [
{
runColumn: 'salary_entry_id',
entry: { ...base, description: `Lön ${periodLabel}` },
lines: [
{
account_number: SALARY_ACCOUNTS.SALARY_EMPLOYEE,
debit_amount: gross,
credit_amount: 0,
sort_order: 0,
dimensions: {},
},
{
account_number: SALARY_ACCOUNTS.TAX_WITHHELD,
debit_amount: 0,
credit_amount: tax,
sort_order: 1,
dimensions: {},
},
{
account_number: SALARY_ACCOUNTS.BANK,
debit_amount: 0,
credit_amount: net,
sort_order: 2,
dimensions: {},
},
],
},
{
runColumn: 'avgifter_entry_id',
entry: { ...base, description: `Lön ${periodLabel}: arbetsgivaravgifter` },
lines: [
{
account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE,
debit_amount: avgifter,
credit_amount: 0,
sort_order: 0,
dimensions: {},
},
{
account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: avgifter,
sort_order: 1,
dimensions: {},
},
],
},
]
if (vacation > 0 || vacationAvgifter > 0) {
const lines: SeedJournalLine[] = []
if (vacation > 0) {
lines.push(
{
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_EXPENSE,
debit_amount: vacation,
credit_amount: 0,
sort_order: lines.length,
dimensions: {},
},
{
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_LIABILITY,
debit_amount: 0,
credit_amount: vacation,
sort_order: lines.length + 1,
dimensions: {},
},
)
}
if (vacationAvgifter > 0) {
lines.push(
{
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_EXPENSE,
debit_amount: vacationAvgifter,
credit_amount: 0,
sort_order: lines.length,
dimensions: {},
},
{
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: vacationAvgifter,
sort_order: lines.length + 1,
dimensions: {},
},
)
}
vouchers.push({
runColumn: 'vacation_entry_id',
entry: { ...base, description: `Lön ${periodLabel}: semesterlöneskuld` },
lines,
})
}
return vouchers
}