f266c386f3
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
452 lines
18 KiB
TypeScript
452 lines
18 KiB
TypeScript
/**
|
|
* 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'
|
|
import { lastDayOfMonth } from './date-utils'
|
|
|
|
/**
|
|
* Every BAS account this builder can emit. Exported so the seed's
|
|
* chart_of_accounts lookup can be widened in one place instead of drifting out
|
|
* of sync with the entries below.
|
|
*/
|
|
export const SANDBOX_LEDGER_ACCOUNT_NUMBERS: readonly string[] = [
|
|
'1510', // Kundfordringar
|
|
'1930', // Företagskonto / checkkonto
|
|
'2013', // Övriga egna uttag
|
|
'2018', // Övriga egna insättningar
|
|
'2611', // Utgående moms försäljning inom Sverige, 25 %
|
|
'2641', // Debiterad ingående moms
|
|
'2650', // Redovisningskonto för moms
|
|
'3001', // Försäljning inom Sverige, 25 % moms
|
|
'5410', // Förbrukningsinventarier
|
|
'5420', // Programvaror
|
|
'5460', // Förbrukningsmaterial
|
|
'5800', // Resekostnader
|
|
'5910', // Annonsering
|
|
'6110', // Kontorsmateriel
|
|
'6212', // Mobiltelefon
|
|
'6230', // Datakommunikation
|
|
'6530', // Redovisningstjänster
|
|
'6570', // Bankavgifter
|
|
]
|
|
|
|
export interface SandboxLedgerHistoryInput {
|
|
userId: string
|
|
companyId: string
|
|
fiscalPeriodId: string
|
|
/** "Now" as the seed sees it. The history stops at the end of the previous month. */
|
|
today: Date
|
|
/** account_number to chart_of_accounts.id. Missing entries fall back to null. */
|
|
accountMap: Record<string, string | undefined>
|
|
}
|
|
|
|
/** A journal_entries row WITHOUT voucher_number: the caller assigns that. */
|
|
export interface SandboxLedgerEntryRow {
|
|
user_id: string
|
|
company_id: string
|
|
fiscal_period_id: string
|
|
voucher_series: string
|
|
entry_date: string
|
|
description: string
|
|
source_type: string
|
|
source_id: null
|
|
status: string
|
|
committed_at: string
|
|
}
|
|
|
|
/** A journal_entry_lines row WITHOUT journal_entry_id: the caller fills it in. */
|
|
export interface SandboxLedgerLineRow {
|
|
account_number: string
|
|
account_id: string | null
|
|
debit_amount: number
|
|
credit_amount: number
|
|
line_description: string
|
|
sort_order: number
|
|
/**
|
|
* Always set, never omitted. PostgREST normalizes columns across a bulk
|
|
* insert, so a row missing the key while a sibling sets it sends NULL and
|
|
* violates the NOT NULL on journal_entry_lines.dimensions.
|
|
*/
|
|
dimensions: Record<string, string>
|
|
}
|
|
|
|
export interface SandboxLedgerHistory {
|
|
entries: SandboxLedgerEntryRow[]
|
|
/** linesByEntryIndex[i] belongs to entries[i]. */
|
|
linesByEntryIndex: SandboxLedgerLineRow[][]
|
|
}
|
|
|
|
/**
|
|
* 'manual' is the only allowed source_type that dereferences nothing: every
|
|
* other candidate ('invoice_created', 'bank_transaction', ...) makes the voucher
|
|
* detail view look for a source row that this history does not have. The value
|
|
* must come from the journal_entries_source_type_check allowlist (see
|
|
* 20260712100500_journal_source_type_stripe_payout.sql).
|
|
*/
|
|
const SOURCE_TYPE = 'manual'
|
|
const VOUCHER_SERIES = 'A'
|
|
|
|
const MONTH_NAMES_SV = [
|
|
'januari',
|
|
'februari',
|
|
'mars',
|
|
'april',
|
|
'maj',
|
|
'juni',
|
|
'juli',
|
|
'augusti',
|
|
'september',
|
|
'oktober',
|
|
'november',
|
|
'december',
|
|
]
|
|
|
|
/** Billed hours per month. July is semester, autumn is the busy season. */
|
|
const CONSULTING_HOURS = [96, 104, 120, 112, 108, 88, 40, 72, 116, 124, 118, 84]
|
|
const HOURLY_RATE = 950
|
|
|
|
/** Owner's opening capital contribution, January only. */
|
|
const OWNER_CONTRIBUTION = 60000
|
|
|
|
interface PeriodicCost {
|
|
accountNumber: string
|
|
description: string
|
|
/** Amount excluding VAT. */
|
|
net: number
|
|
/** Integer percent. 0 means a VAT-exempt supply: no 2641 line at all. */
|
|
vatRate: number
|
|
}
|
|
|
|
/**
|
|
* The day-12 cost, by month index (0 = January). null means the month has none,
|
|
* which is what makes it read as occasional rather than as a subscription.
|
|
*
|
|
* VAT rates follow the supply, not the account: persontransport inom Sverige is
|
|
* 6 % and hotellrum 12 % (ML 9 kap.), and banktjänster are undantagna från
|
|
* moms (ML 10 kap. 33 §), so the December bank fee books gross with no input
|
|
* VAT. Deductible input VAT always lands on 2641 regardless of the rate.
|
|
*/
|
|
const PERIODIC_COSTS: Array<PeriodicCost | null> = [
|
|
null,
|
|
{ accountNumber: '5910', description: 'Annonsering, kampanj sociala medier', net: 2400, vatRate: 25 },
|
|
{ accountNumber: '6530', description: 'Redovisningskonsult, avstämning inför deklaration', net: 6500, vatRate: 25 },
|
|
{ accountNumber: '6230', description: 'Bredband kontorsplats, första halvåret', net: 1490, vatRate: 25 },
|
|
{ accountNumber: '5800', description: 'Tågbiljetter, kundmöten Göteborg', net: 1480, vatRate: 6 },
|
|
{ accountNumber: '5410', description: 'Extern skärm och dockningsstation', net: 4990, vatRate: 25 },
|
|
null,
|
|
{ accountNumber: '6110', description: 'Kontorsmateriel', net: 780, vatRate: 25 },
|
|
{ accountNumber: '5800', description: 'Hotell, kundprojekt Malmö', net: 3950, vatRate: 12 },
|
|
{ accountNumber: '6230', description: 'Bredband kontorsplats, andra halvåret', net: 1490, vatRate: 25 },
|
|
{ accountNumber: '5460', description: 'Förbrukningsmaterial, kontor', net: 1150, vatRate: 25 },
|
|
{ accountNumber: '6570', description: 'Bankavgifter, årsavgift företagskonto', net: 1200, vatRate: 0 },
|
|
]
|
|
|
|
/** Internal line shape: exactly one of debit/credit is set. */
|
|
interface LineSpec {
|
|
accountNumber: string
|
|
debit?: number
|
|
credit?: number
|
|
lineDescription: string
|
|
dimensions?: Record<string, string>
|
|
}
|
|
|
|
/** yyyy-MM-dd from calendar parts. Avoids toISOString(), which shifts to UTC. */
|
|
function dateStr(year: number, month: number, day: number): string {
|
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
|
}
|
|
|
|
function vatOf(net: number, vatRate: number): number {
|
|
return roundOre((net * vatRate) / 100)
|
|
}
|
|
|
|
/**
|
|
* Deterministic per-month variation. `(month * factor) % cycle` walks the cycle
|
|
* in a non-obvious order, so the amounts look hand-entered without any
|
|
* randomness: the builder must be reproducible.
|
|
*/
|
|
function vary(base: number, month: number, factor: number, cycle: number, step: number): number {
|
|
return roundOre(base + (((month * factor) % cycle) * step))
|
|
}
|
|
|
|
/**
|
|
* Revenue-line dimension bag, by month. Three-month cycle so the dimension P&L
|
|
* report has data on both kostnadsställe/projekt pairs the seed creates AND a
|
|
* meaningful untagged remainder. Every other line in the history is untagged.
|
|
*/
|
|
function revenueDimensions(month: number): Record<string, string> {
|
|
const phase = month % 3
|
|
if (phase === 1) return { '1': 'BUTIK', '6': 'P001' }
|
|
if (phase === 2) return { '1': 'WEBB', '6': 'P002' }
|
|
return {}
|
|
}
|
|
|
|
export function buildSandboxLedgerHistory({
|
|
userId,
|
|
companyId,
|
|
fiscalPeriodId,
|
|
today,
|
|
accountMap,
|
|
}: SandboxLedgerHistoryInput): SandboxLedgerHistory {
|
|
const year = today.getFullYear()
|
|
/** 1-based; the history covers January through the month before this one. */
|
|
const lastMonth = today.getMonth()
|
|
|
|
const entries: SandboxLedgerEntryRow[] = []
|
|
const linesByEntryIndex: SandboxLedgerLineRow[][] = []
|
|
|
|
/**
|
|
* Running VAT, so the quarterly momsredovisning below settles the exact
|
|
* amounts the period's own entries produced rather than a re-derivation.
|
|
*/
|
|
let quarterOutputVat = 0
|
|
let quarterInputVat = 0
|
|
|
|
const accountId = (accountNumber: string): string | null =>
|
|
accountMap[accountNumber] ?? null
|
|
|
|
function addEntry(entryDate: string, description: string, lines: LineSpec[]): void {
|
|
entries.push({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
fiscal_period_id: fiscalPeriodId,
|
|
voucher_series: VOUCHER_SERIES,
|
|
entry_date: entryDate,
|
|
description,
|
|
source_type: SOURCE_TYPE,
|
|
source_id: null,
|
|
status: 'posted',
|
|
committed_at: entryDate,
|
|
})
|
|
linesByEntryIndex.push(
|
|
lines.map((line, index) => ({
|
|
account_number: line.accountNumber,
|
|
account_id: accountId(line.accountNumber),
|
|
debit_amount: roundOre(line.debit ?? 0),
|
|
credit_amount: roundOre(line.credit ?? 0),
|
|
line_description: line.lineDescription,
|
|
sort_order: index,
|
|
dimensions: line.dimensions ?? {},
|
|
})),
|
|
)
|
|
}
|
|
|
|
/** Dr cost (+ Dr 2641), Cr 1930. The bank pays it the day it happens. */
|
|
function addBankPaidCost(entryDate: string, cost: PeriodicCost): void {
|
|
const vat = vatOf(cost.net, cost.vatRate)
|
|
const gross = roundOre(cost.net + vat)
|
|
const lines: LineSpec[] = [
|
|
{ accountNumber: cost.accountNumber, debit: cost.net, lineDescription: cost.description },
|
|
]
|
|
if (vat > 0) {
|
|
quarterInputVat = roundOre(quarterInputVat + vat)
|
|
lines.push({
|
|
accountNumber: '2641',
|
|
debit: vat,
|
|
lineDescription: `Ingående moms ${cost.vatRate} %`,
|
|
})
|
|
}
|
|
lines.push({
|
|
accountNumber: '1930',
|
|
credit: gross,
|
|
lineDescription: 'Betalt från företagskontot',
|
|
})
|
|
addEntry(entryDate, cost.description, lines)
|
|
}
|
|
|
|
/**
|
|
* Close one VAT quarter: clear 2611 and 2641 into 2650, then pay 2650 from
|
|
* the bank on the SFL 26 kap. 26 § deadline.
|
|
*
|
|
* Without this the sandbox reads as a company that has collected VAT all
|
|
* year and never remitted a krona: the moms liability and the bank balance
|
|
* both climb without limit, and every cash KPI derived from them is
|
|
* nonsense. The company files quarterly (company_settings.moms_period =
|
|
* 'quarterly'), so each quarter is declared after it closes and paid on the
|
|
* 12th of the second month after it.
|
|
*/
|
|
/** Payments owed but not yet emitted, keyed by the month they fall due. */
|
|
const vatPaymentsDue = new Map<number, { amount: number; label: string }>()
|
|
|
|
function declareVatQuarter(quarterEndMonth: number, paymentMonth: number, label: string): void {
|
|
const output = quarterOutputVat
|
|
const input = quarterInputVat
|
|
quarterOutputVat = 0
|
|
quarterInputVat = 0
|
|
|
|
const netPayable = roundOre(output - input)
|
|
// A refund quarter would need the opposite sign on 2650 and a bank
|
|
// deposit. This history is comfortably output-heavy every quarter, so
|
|
// rather than emit a voucher whose direction was never exercised, skip.
|
|
if (netPayable <= 0) return
|
|
|
|
// 2611 debit + 2641 credit + 2650 is the shape get_vat_declaration_totals
|
|
// classifies as a momsredovisning and drops from the period's totals, which
|
|
// is exactly right: a settlement must not feed the next declaration.
|
|
addEntry(
|
|
dateStr(year, quarterEndMonth, lastDayOfMonth(year, quarterEndMonth)),
|
|
`Momsredovisning ${label}`,
|
|
[
|
|
{ accountNumber: '2611', debit: output, lineDescription: 'Utgående moms för perioden' },
|
|
{ accountNumber: '2641', credit: input, lineDescription: 'Ingående moms för perioden' },
|
|
{ accountNumber: '2650', credit: netPayable, lineDescription: 'Redovisningskonto för moms' },
|
|
],
|
|
)
|
|
|
|
vatPaymentsDue.set(paymentMonth, { amount: netPayable, label })
|
|
}
|
|
|
|
/**
|
|
* Emitted from inside the payment month's own block, never from the quarter
|
|
* close: a 12 May voucher appended while building March would put the entry
|
|
* list out of date order, and the caller assigns voucher numbers in array
|
|
* order (BFNAR 2013:2 expects the sequence to follow the books).
|
|
*/
|
|
function payVatIfDue(month: number): void {
|
|
const due = vatPaymentsDue.get(month)
|
|
if (!due) return
|
|
vatPaymentsDue.delete(month)
|
|
addEntry(dateStr(year, month, 12), `Betald moms ${due.label}`, [
|
|
{ accountNumber: '2650', debit: due.amount, lineDescription: 'Redovisningskonto för moms' },
|
|
{ accountNumber: '1930', credit: due.amount, lineDescription: 'Betalt till skattekontot' },
|
|
])
|
|
}
|
|
|
|
for (let month = 1; month <= lastMonth; month++) {
|
|
const monthName = MONTH_NAMES_SV[month - 1]
|
|
const hours = CONSULTING_HOURS[month - 1]
|
|
const revenueNet = hours * HOURLY_RATE
|
|
const revenueVat = vatOf(revenueNet, 25)
|
|
const revenueGross = roundOre(revenueNet + revenueVat)
|
|
|
|
// ── day 2, January only: owner funds the business ────────────────────
|
|
// The fiscal year is the company's first period, so there is no ingående
|
|
// balans on 1930. Without this the bank account goes negative in January.
|
|
if (month === 1) {
|
|
addEntry(dateStr(year, month, 2), 'Egen insättning, startkapital', [
|
|
{ accountNumber: '1930', debit: OWNER_CONTRIBUTION, lineDescription: 'Insättning på företagskontot' },
|
|
{ accountNumber: '2018', credit: OWNER_CONTRIBUTION, lineDescription: 'Övriga egna insättningar' },
|
|
])
|
|
}
|
|
|
|
// ── day 3: software subscriptions ────────────────────────────────────
|
|
addBankPaidCost(dateStr(year, month, 3), {
|
|
accountNumber: '5420',
|
|
description: `Programvaror, molntjänster ${monthName}`,
|
|
net: vary(890, month, 7, 5, 60),
|
|
vatRate: 25,
|
|
})
|
|
|
|
// ── day 8: mobile and subscription ───────────────────────────────────
|
|
addBankPaidCost(dateStr(year, month, 8), {
|
|
accountNumber: '6212',
|
|
description: `Mobiltelefon och abonnemang, ${monthName}`,
|
|
net: vary(429, month, 5, 4, 35),
|
|
vatRate: 25,
|
|
})
|
|
|
|
// ── day 12: the rotating larger or periodic cost ─────────────────────
|
|
const periodic = PERIODIC_COSTS[month - 1]
|
|
if (periodic) {
|
|
addBankPaidCost(dateStr(year, month, 12), periodic)
|
|
}
|
|
|
|
// ── day 12: the previous quarter's moms leaves the bank ──────────────
|
|
// SFL 26 kap. 26 §: a quarterly filer declares and pays on the 12th of
|
|
// the second month after the period.
|
|
payVatIfDue(month)
|
|
|
|
// ── day 20: the previous month's faktura is paid ─────────────────────
|
|
// 30 day terms, so January has nothing to collect.
|
|
if (month > 1) {
|
|
const previousMonthName = MONTH_NAMES_SV[month - 2]
|
|
const previousNet = CONSULTING_HOURS[month - 2] * HOURLY_RATE
|
|
const previousGross = roundOre(previousNet + vatOf(previousNet, 25))
|
|
addEntry(
|
|
dateStr(year, month, 20),
|
|
`Kundinbetalning, konsultarvode ${previousMonthName}`,
|
|
[
|
|
{ accountNumber: '1930', debit: previousGross, lineDescription: 'Insättning på företagskontot' },
|
|
{ accountNumber: '1510', credit: previousGross, lineDescription: 'Kvittad kundfordran' },
|
|
],
|
|
)
|
|
}
|
|
|
|
// ── day 25: the month's consulting invoice ───────────────────────────
|
|
addEntry(
|
|
dateStr(year, month, 25),
|
|
`Faktura, konsultarvode ${monthName} (${hours} tim)`,
|
|
[
|
|
{ accountNumber: '1510', debit: revenueGross, lineDescription: 'Kundfordran' },
|
|
{
|
|
accountNumber: '3001',
|
|
credit: revenueNet,
|
|
lineDescription: `Konsultarvode ${hours} tim à ${HOURLY_RATE} kr`,
|
|
dimensions: revenueDimensions(month),
|
|
},
|
|
{ accountNumber: '2611', credit: revenueVat, lineDescription: 'Utgående moms 25 %' },
|
|
],
|
|
)
|
|
quarterOutputVat = roundOre(quarterOutputVat + revenueVat)
|
|
|
|
// ── day 27: eget uttag ───────────────────────────────────────────────
|
|
// Enskild firma: the owner is not an employee, so their compensation is an
|
|
// equity withdrawal on 2013, never a personnel cost. Skipped in January for
|
|
// the same cash reason as the customer payment.
|
|
if (month > 1) {
|
|
const withdrawal = vary(38000, month, 3, 4, 4000)
|
|
addEntry(dateStr(year, month, 27), `Eget uttag, ${monthName}`, [
|
|
{ accountNumber: '2013', debit: withdrawal, lineDescription: 'Övriga egna uttag' },
|
|
{ accountNumber: '1930', credit: withdrawal, lineDescription: 'Uttag från företagskontot' },
|
|
])
|
|
}
|
|
|
|
// ── quarter close: declare and pay the moms the quarter produced ──────
|
|
// Emitted inside the loop rather than appended afterwards so the
|
|
// verifikat stay in date order, which is what the voucher numbering the
|
|
// caller assigns will follow.
|
|
if (month === 3) declareVatQuarter(3, 5, `${year} Q1`)
|
|
if (month === 6) declareVatQuarter(6, 8, `${year} Q2`)
|
|
if (month === 9) declareVatQuarter(9, 11, `${year} Q3`)
|
|
}
|
|
|
|
return { entries, linesByEntryIndex }
|
|
}
|