Files
accounted/app/api/sandbox/seed/salary-vouchers.ts
T
Mattsson 7c36d471b5 fix(sandbox): use posting engine and recover failed seeds (#2297)
* fix(sandbox): seed through posting engine and recover failed attempts

* test(sandbox): align CI auth schema for anonymous users
2026-09-05 10:42:14 +02:00

242 lines
7.3 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. 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 posts through the engine and links the returned journal entry IDs.
*/
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
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,
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
}