Files
accounted/app/api/sandbox/seed/route.ts
T
Mattsson 7d56e0ec01 fix(mcp): use canonical VAT deadlines (#1679)
* fix(mcp): use canonical VAT deadlines

* fix(mcp): handle incomplete VAT settings

* fix(mcp): block unknown VAT deadlines

* fix(mcp): tighten VAT tool description

* fix(mcp): fail closed on missing entity type

* ci: retry timed-out preview

* fix(vat): scope annual filing method requirement
2026-08-18 17:22:52 +02:00

1290 lines
48 KiB
TypeScript

import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { requireAuth } from '@/lib/auth/require-auth'
import { NextResponse } from 'next/server'
import { getActiveCompanyId } from '@/lib/company/context'
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 { buildSandboxVatDeadline } from './vat-deadline'
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
// spinning up arbitrary sandbox companies. Idempotent for legit users, so 5/h
// covers retries; an attacker has to rotate /24s to scale abuse.
const RATE_LIMIT = { maxRequests: 5, windowMs: 60 * 60 * 1000 }
/**
* POST /api/sandbox/seed
* Seeds demo data for an anonymous sandbox user.
* Only callable by anonymous users (is_anonymous === true).
*/
export async function POST(request: Request) {
// Per-request logger so seed-failure entries are correlatable in the SIEM.
// Cannot reuse withRouteContext here: it requires an active company, but
// the sandbox seed runs *before* a company exists for the user.
const requestId = `req_${crypto.randomUUID()}`
const log = createLogger('sandbox:seed', { requestId })
const fwd = request.headers.get('x-forwarded-for')
const rawIp = fwd ? fwd.split(',')[0]?.trim() : request.headers.get('x-real-ip') ?? undefined
// Fall back to a shared 'unknown' bucket when the proxy doesn't surface a
// client IP: keeps the limit enforced under a misconfigured deploy rather
// than failing open. Truncated /24 elsewhere is the normal path.
const ipIdentifier = truncateIp(rawIp || undefined) ?? 'unknown'
if (rawIp && ipIdentifier === 'unknown') {
log.warn('unparseable forwarded-for header on sandbox seed', { headerLength: rawIp.length })
}
const rl = await checkRateLimit({
prefix: 'sandbox:seed',
identifier: ipIdentifier,
...RATE_LIMIT,
})
if (!rl.ok) return rl.response!
// Can't use withRouteContext (see above, no company yet), so call requireAuth
// directly: the documented stopgap that still enforces MFA. A no-op for the
// anonymous users this route serves (they have no second factor), but keeps
// the route on the same auth path as the rest of the API.
//
// GDPR Art.32 compensating controls for this anonymous, low-auth write path:
// (1) anonymous-only: authenticated users are rejected below (403); (2) the
// /24 rate limit above (5/h); (3) all seeded data is synthetic demo content
// (fabricated names, example.com emails, documentation-reserved org numbers),
// not real personal data; (4) writes are scoped to the caller's own freshly
// created sandbox company, RLS-isolated from every other tenant.
const auth = await requireAuth()
if (auth.error) return auth.error
const { user, supabase } = auth
if (!user.is_anonymous) {
return NextResponse.json(
{ error: 'Sandbox is only available for anonymous users', requestId },
{ status: 403 },
)
}
// Anonymous users start with no company. Create one before seeding.
// If a previous seed attempt already created a company for this user, reuse it
// (idempotency).
let companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) {
const { data: newCompanyId, error: companyError } = await supabase.rpc(
'create_company_with_owner',
{
p_name: 'Sandlådan Konsult',
p_entity_type: 'enskild_firma',
}
)
if (companyError || !newCompanyId) {
log.error('failed to create sandbox company', { error: companyError, userId: user.id })
return NextResponse.json(
{ error: 'Failed to create sandbox company', requestId },
{ status: 500 }
)
}
companyId = newCompanyId as string
}
// Idempotency: if the core seed already ran (company_settings exists), skip
// the bulk insert path. We still TOP UP the newer surfaces (agent_profile,
// suppliers, asset, pending operations) afterwards so an old sandbox session
// (created before those were added to the seed) picks them up on the next
// call instead of being stuck without a verified assistant.
const { data: existing } = await supabase
.from('company_settings')
.select('id')
.eq('company_id', companyId)
.maybeSingle()
if (existing) {
try {
await topUpSandboxAdditions(supabase, companyId)
return NextResponse.json({ seeded: false, topped_up: true })
} catch (err) {
log.error('failed to top up sandbox additions', { error: err, userId: user.id, companyId })
return NextResponse.json({ seeded: false, topped_up: false })
}
}
try {
const userId = user.id
// 1. Update profile (auto-created by auth trigger)
await supabase
.from('profiles')
.update({ full_name: 'Demo Användare' })
.eq('id', userId)
// 2. Create company settings
const { error: settingsError } = await supabase
.from('company_settings')
.insert({
user_id: userId,
company_id: companyId,
entity_type: 'enskild_firma',
company_name: 'Sandlådan Konsult',
org_number: '199001011234',
address_line1: 'Demovägen 1',
postal_code: '111 22',
city: 'Stockholm',
country: 'SE',
f_skatt: true,
vat_registered: true,
vat_number: 'SE199001011234',
moms_period: 'quarterly',
fiscal_year_start_month: 1,
accounting_method: 'accrual',
invoice_prefix: 'F',
next_invoice_number: 5,
next_delivery_note_number: 1,
invoice_default_days: 30,
// Sender bank details: the pain.001 debtor for the betalfil demo.
// Example IBAN from the Swedish IBAN documentation range; BIC derives
// from it being an SEB-style example. Demo-only values.
iban: 'SE3550000000054910000003',
bic: 'ESSESESS',
bankgiro: '991-2346',
onboarding_step: 6,
onboarding_complete: true,
initial_setup_path: 'fresh',
initial_setup_completed_at: new Date().toISOString(),
initial_setup_dismissed_at: new Date().toISOString(),
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
// 3. Seed chart of accounts via RPC
const { error: coaError } = await supabase.rpc('seed_chart_of_accounts', {
p_company_id: companyId,
p_entity_type: 'enskild_firma',
})
if (coaError) throw coaError
// 3b. Seed demo dimensions (kostnadsställe/projekt). ensure_company_dimensions
// lazily creates the system dims 1/6; two values per dim give the register,
// pickers, and the dimension-tagged journal lines below something to show.
const { error: dimsRpcError } = await supabase.rpc('ensure_company_dimensions', {
p_company_id: companyId,
})
if (dimsRpcError) throw dimsRpcError
const { data: demoDims, error: demoDimsError } = await supabase
.from('dimensions')
.select('id, sie_dim_no')
.eq('company_id', companyId)
.in('sie_dim_no', [1, 6])
if (demoDimsError) throw demoDimsError
const dimIdByNo = Object.fromEntries(
(demoDims ?? []).map(d => [d.sie_dim_no as number, d.id as string])
) as Record<number, string>
if (dimIdByNo[1] && dimIdByNo[6]) {
const seededDimensionCodes = ['BUTIK', 'WEBB', 'P001', 'P002']
const { error: dimValuesError } = await supabase
.from('dimension_values')
.insert([
{ company_id: companyId, dimension_id: dimIdByNo[1], code: 'BUTIK', name: 'Butiken' },
{ company_id: companyId, dimension_id: dimIdByNo[1], code: 'WEBB', name: 'Webbshoppen' },
{ company_id: companyId, dimension_id: dimIdByNo[6], code: 'P001', name: 'Projekt Björk' },
{ company_id: companyId, dimension_id: dimIdByNo[6], code: 'P002', name: 'Projekt Alm' },
])
if (dimValuesError) throw dimValuesError
log.info('seeded sandbox dimension values', { companyId, codes: seededDimensionCodes })
}
// 4. Create fiscal period (current year)
const currentYear = new Date().getFullYear()
const { data: fiscalPeriod, error: fpError } = await supabase
.from('fiscal_periods')
.insert({
user_id: userId,
company_id: companyId,
name: `Räkenskapsår ${currentYear}`,
period_start: `${currentYear}-01-01`,
period_end: `${currentYear}-12-31`,
})
.select('id')
.single()
if (fpError) throw fpError
// 5. Create customers
const { data: customers, error: custError } = await supabase
.from('customers')
.insert(buildSandboxCustomers(userId, companyId))
.select('id, name')
if (custError) throw custError
const customerMap = Object.fromEntries(customers.map(c => [c.name, c.id]))
// 6. Create invoices
const today = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const toDateStr = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const thirtyDaysAgo = new Date(today)
thirtyDaysAgo.setDate(today.getDate() - 30)
const fifteenDaysAgo = new Date(today)
fifteenDaysAgo.setDate(today.getDate() - 15)
const thirtyDaysFromNow = new Date(today)
thirtyDaysFromNow.setDate(today.getDate() + 30)
const fiveDaysAgo = new Date(today)
fiveDaysAgo.setDate(today.getDate() - 5)
const { data: invoices, error: invError } = await supabase
.from('invoices')
.insert([
{
user_id: userId,
company_id: companyId,
customer_id: customerMap['Björk & Partner AB'],
invoice_number: 'F-2026001',
invoice_date: toDateStr(thirtyDaysAgo),
due_date: toDateStr(today),
status: 'paid',
subtotal: 15000,
vat_amount: 3750,
total: 18750,
vat_treatment: 'standard_25',
vat_rate: 25,
moms_ruta: '10',
document_type: 'invoice',
paid_at: toDateStr(fifteenDaysAgo),
paid_amount: 18750,
remaining_amount: 0,
},
{
user_id: userId,
company_id: companyId,
customer_id: customerMap['Schmidt GmbH'],
invoice_number: 'F-2026002',
invoice_date: toDateStr(fifteenDaysAgo),
due_date: toDateStr(thirtyDaysFromNow),
status: 'sent',
subtotal: 20000,
vat_amount: 0,
total: 20000,
remaining_amount: 20000,
paid_amount: 0,
vat_treatment: 'reverse_charge',
vat_rate: 0,
reverse_charge_text: 'Reverse charge: buyer is liable for VAT',
document_type: 'invoice',
},
{
user_id: userId,
company_id: companyId,
customer_id: customerMap['Anna Lindström'],
invoice_number: 'F-2026003',
invoice_date: toDateStr(thirtyDaysAgo),
due_date: toDateStr(fiveDaysAgo),
status: 'overdue',
subtotal: 5000,
vat_amount: 1250,
total: 6250,
remaining_amount: 6250,
paid_amount: 0,
vat_treatment: 'standard_25',
vat_rate: 25,
moms_ruta: '10',
document_type: 'invoice',
},
{
user_id: userId,
company_id: companyId,
customer_id: customerMap['Björk & Partner AB'],
invoice_number: 'F-2026004',
invoice_date: toDateStr(today),
due_date: toDateStr(thirtyDaysFromNow),
status: 'draft',
subtotal: 8000,
vat_amount: 2000,
total: 10000,
// PostgREST normalises a bulk insert to the union of keys: every row in
// this batch carries both columns so none arrives as NULL.
remaining_amount: 10000,
paid_amount: 0,
vat_treatment: 'standard_25',
vat_rate: 25,
moms_ruta: '10',
document_type: 'invoice',
},
])
.select('id, invoice_number')
if (invError) throw invError
const invoiceMap = Object.fromEntries(invoices.map(i => [i.invoice_number, i.id]))
// 7. Create invoice items
const { error: itemsError } = await supabase
.from('invoice_items')
.insert([
{
invoice_id: invoiceMap['F-2026001'],
description: 'Webbutveckling, mars 2026',
quantity: 30,
unit: 'tim',
unit_price: 500,
line_total: 15000,
vat_rate: 25,
},
{
invoice_id: invoiceMap['F-2026002'],
description: 'IT-konsulting, internationellt projekt',
quantity: 40,
unit: 'tim',
unit_price: 500,
line_total: 20000,
vat_rate: 0,
},
{
invoice_id: invoiceMap['F-2026003'],
description: 'Hemsida & grafisk profil',
quantity: 1,
unit: 'st',
unit_price: 5000,
line_total: 5000,
vat_rate: 25,
},
{
invoice_id: invoiceMap['F-2026004'],
description: 'Systemunderhåll april 2026',
quantity: 16,
unit: 'tim',
unit_price: 500,
line_total: 8000,
vat_rate: 25,
},
])
if (itemsError) throw itemsError
// 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', neededAccounts)
if (accountsError) throw accountsError
const accountMap = Object.fromEntries(
(accounts ?? []).map(a => [a.account_number, a.id])
)
// 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)
}
// Inserted as draft and posted after the lines land: PostgREST autocommits
// each request, and check_balance_on_posted_insert (migration
// 20260806130000) rejects a posted header whose transaction carries no
// lines. The draft-to-posted UPDATE below fires check_balance_on_post
// against the finished verifikat instead.
//
// committed_at note: this route runs under the requester's authenticated
// client, and set_committed_at() (migration 20260806160000) preserves a
// preset committed_at only for trusted roles, so any backdated
// committed_at supplied here is overwritten with now() at posting. That
// is deliberate: an end-user role must never control the audit timestamp,
// and sandbox companies are disposable.
const { data: insertedHistoryEntries, error: historyEntryError } = await supabase
.from('journal_entries')
.insert(
ledgerHistory.entries.map((historyEntry, index) => ({
...historyEntry,
voucher_number: historyVoucherNumbers[index],
status: 'draft',
})),
)
.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
const { error: historyPostError } = await supabase
.from('journal_entries')
.update({ status: 'posted' })
.in('id', historyEntryIds)
.eq('company_id', companyId)
if (historyPostError) throw historyPostError
// 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,
p_series: 'A',
})
const { data: je1, error: je1Error } = await supabase
.from('journal_entries')
.insert({
user_id: userId,
company_id: companyId,
fiscal_period_id: fiscalPeriod.id,
voucher_number: voucherNum1 ?? 1,
voucher_series: 'A',
entry_date: toDateStr(thirtyDaysAgo),
description: 'Faktura F-2026001, Björk & Partner AB',
source_type: 'invoice_created',
source_id: invoiceMap['F-2026001'],
// Draft until the lines exist; see the ledger-history comment above.
status: 'draft',
committed_at: toDateStr(thirtyDaysAgo),
})
.select('id')
.single()
if (je1Error) throw je1Error
const { data: voucherNum2 } = await supabase.rpc('next_voucher_number', {
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriod.id,
p_series: 'A',
})
const { data: je2, error: je2Error } = await supabase
.from('journal_entries')
.insert({
user_id: userId,
company_id: companyId,
fiscal_period_id: fiscalPeriod.id,
voucher_number: voucherNum2 ?? 2,
voucher_series: 'A',
entry_date: toDateStr(fifteenDaysAgo),
description: 'Betalning faktura F-2026001, Björk & Partner AB',
source_type: 'invoice_paid',
source_id: invoiceMap['F-2026001'],
// Draft until the lines exist; see the ledger-history comment above.
status: 'draft',
committed_at: toDateStr(fifteenDaysAgo),
})
.select('id')
.single()
if (je2Error) throw je2Error
// 10. Create journal entry lines. The P&L line carries demo dimensions
// ({"1":"BUTIK","6":"P001"}) so the register's "antal taggade rader",
// voucher-detail badges, and the dimension P&L report light up in the
// sandbox. cost_center/project are GENERATED from the bag since the PR9
// cutover: writing them explicitly would error.
const revenueDims = { '1': 'BUTIK', '6': 'P001' }
const { error: jelError } = await supabase
.from('journal_entry_lines')
.insert([
// JE1: Invoice creation, Debit AR, Credit Revenue + VAT
// NB: `dimensions` must be set explicitly on EVERY row: same PostgREST
// bulk-insert normalization as paid_amount below: omitting it on some
// rows while one row sets it sends null (violating NOT NULL) instead
// of falling through to the schema default '{}'.
{
journal_entry_id: je1.id,
account_number: '1510',
account_id: accountMap['1510'] ?? null,
debit_amount: 18750,
credit_amount: 0,
sort_order: 0,
dimensions: {},
},
{
journal_entry_id: je1.id,
account_number: '3001',
account_id: accountMap['3001'] ?? null,
debit_amount: 0,
credit_amount: 15000,
sort_order: 1,
dimensions: revenueDims,
},
{
journal_entry_id: je1.id,
account_number: '2611',
account_id: accountMap['2611'] ?? null,
debit_amount: 0,
credit_amount: 3750,
sort_order: 2,
dimensions: {},
},
// JE2: Invoice payment, Debit Bank, Credit AR
{
journal_entry_id: je2.id,
account_number: '1930',
account_id: accountMap['1930'] ?? null,
debit_amount: 18750,
credit_amount: 0,
sort_order: 0,
dimensions: {},
},
{
journal_entry_id: je2.id,
account_number: '1510',
account_id: accountMap['1510'] ?? null,
debit_amount: 0,
credit_amount: 18750,
sort_order: 1,
dimensions: {},
},
])
if (jelError) throw jelError
const { error: invoicePostError } = await supabase
.from('journal_entries')
.update({ status: 'posted' })
.in('id', [je1.id, je2.id])
.eq('company_id', companyId)
if (invoicePostError) throw invoicePostError
// 11. Create transactions
const { data: txRows, error: txError } = await supabase
.from('transactions')
.insert([
// Categorized expenses
{
user_id: userId,
company_id: companyId,
date: toDateStr(thirtyDaysAgo),
description: 'CLAS OHLSON STOCKHOLM',
amount: -450,
category: 'expense_office',
is_business: true,
merchant_name: 'Clas Ohlson',
},
{
user_id: userId,
company_id: companyId,
date: toDateStr(fifteenDaysAgo),
description: 'GITHUB INC',
amount: -999,
category: 'expense_software',
is_business: true,
merchant_name: 'GitHub',
},
{
user_id: userId,
company_id: companyId,
date: toDateStr(fiveDaysAgo),
description: 'SJ BILJETT',
// > 4 000 kr categorized business expense with no attached underlag,
// so gnubok_vat_close_check surfaces a non-empty blocker list.
// (BFL 5 kap 6-7§ require every affärshändelse to be documented with
// underlag; the 4 000 kr cut-off is the tool's own high-value
// heuristic, not a statutory threshold.)
amount: -4500,
category: 'expense_travel',
is_business: true,
merchant_name: 'SJ',
},
// Income matched to paid invoice
{
user_id: userId,
company_id: companyId,
date: toDateStr(fifteenDaysAgo),
description: 'BJÖRK & PARTNER AB BETALNING F-2026001',
amount: 18750,
category: 'income_services',
is_business: true,
invoice_id: invoiceMap['F-2026001'],
journal_entry_id: je2.id,
merchant_name: 'Björk & Partner AB',
},
// Private transaction
{
user_id: userId,
company_id: companyId,
date: toDateStr(fiveDaysAgo),
description: 'PRIVAT INSÄTTNING',
amount: 5000,
category: 'private',
is_business: false,
},
// Uncategorized transactions
{
user_id: userId,
company_id: companyId,
date: toDateStr(fiveDaysAgo),
description: 'SWISH BETALNING 0701234567',
amount: -350,
category: 'uncategorized',
is_business: null,
},
{
user_id: userId,
company_id: companyId,
date: toDateStr(today),
description: 'INSÄTTNING BANKGIRO',
amount: 1200,
category: 'uncategorized',
is_business: null,
},
{
user_id: userId,
company_id: companyId,
date: toDateStr(today),
description: 'KORTBETALNING RESTAURANG',
amount: -680,
category: 'uncategorized',
is_business: null,
},
])
.select('id, description')
if (txError) throw txError
// Lookup so the pre-staged categorize_transaction operation below can
// reference a real, uncategorized transaction by id (descriptions are
// unique in this seed set).
const txMap = Object.fromEntries(
(txRows ?? []).map(t => [t.description as string, t.id as string])
)
// 12. Create deadlines
const momsDeadline = buildSandboxVatDeadline(today)
const { error: dlError } = await supabase
.from('deadlines')
.insert([
{
user_id: userId,
company_id: companyId,
title: momsDeadline.title,
due_date: momsDeadline.dueDate,
deadline_type: 'tax',
priority: 'important',
// Current generator types: the bare 'moms'/'inkomstdeklaration'
// types were retired and seeding them recreates legacy rows the
// cleanup migration removed.
tax_deadline_type: 'moms_quarterly',
tax_period: momsDeadline.period,
source: 'system',
status: 'upcoming',
linked_report_type: 'vat',
},
{
user_id: userId,
company_id: companyId,
title: `Inkomstdeklaration ${currentYear - 1}`,
due_date: `${currentYear}-05-02`,
deadline_type: 'tax',
priority: 'critical',
// Sandbox companies are enskild firma (see p_entity_type above).
tax_deadline_type: 'inkomstdeklaration_ef',
tax_period: `${currentYear - 1}`,
source: 'system',
status: 'upcoming',
},
])
if (dlError) throw dlError
// 13. Seed suppliers + one registered supplier invoice + one paid one.
// Supplier invoices are arguably the second-most-used surface after
// bank transactions; without them the /suppliers and /supplier-invoices
// pages render the empty state and the demo loses a big chunk of the
// accounts-payable story.
// Supplier names use the "Demo" prefix and the documentation-reserved
// 5559... org-number range so the seeded rows cannot be confused with
// production data should they ever leak into a real environment.
const { data: suppliers, error: supError } = await supabase
.from('suppliers')
.insert([
{
user_id: userId,
company_id: companyId,
name: 'Demo Telekom AB',
supplier_type: 'swedish_business',
org_number: '5559000001',
vat_number: 'SE555900000101',
email: 'demo+telekom@example.com',
// Luhn-valid (Bankgirot check digit): the betalfil flow validates
// payee numbers, so demo suppliers must carry numbers that pass.
bankgiro: '5559-0004',
address_line1: 'Demovägen 10',
postal_code: '111 22',
city: 'Stockholm',
country: 'SE',
default_payment_terms: 30,
},
{
user_id: userId,
company_id: companyId,
name: 'Demokafé AB',
supplier_type: 'swedish_business',
org_number: '5559000002',
vat_number: 'SE555900000201',
bankgiro: '5559-0012',
address_line1: 'Demovägen 11',
postal_code: '111 22',
city: 'Stockholm',
country: 'SE',
default_payment_terms: 15,
},
])
.select('id, name')
if (supError) throw supError
const supplierMap = Object.fromEntries(suppliers.map(s => [s.name, s.id]))
// Supplier invoice #1, Telia, paid 15 days ago (mobile + bredband, 25% VAT).
const sevenDaysFromNow = new Date(today)
sevenDaysFromNow.setDate(today.getDate() + 7)
// Hardcode 1 and 2: get_next_arrival_number is MAX+1 against the same
// table we're about to insert into, so calling it twice before the first
// insert lands gives the same value for both rows and violates the
// (company_id, arrival_number) unique index. The company is brand new
// here, so 1 and 2 are guaranteed to be free.
const { data: supInvoices, error: supInvError } = await supabase
.from('supplier_invoices')
.insert([
{
user_id: userId,
company_id: companyId,
supplier_id: supplierMap['Demo Telekom AB'],
arrival_number: 1,
supplier_invoice_number: '4711-2026-03',
invoice_date: toDateStr(thirtyDaysAgo),
due_date: toDateStr(today),
received_date: toDateStr(thirtyDaysAgo),
status: 'paid',
currency: 'SEK',
subtotal: 480,
vat_amount: 120,
total: 600,
payment_reference: '47112026031',
paid_at: toDateStr(fifteenDaysAgo),
paid_amount: 600,
// Same normalization rule as paid_amount below: the other row in
// this bulk insert sets remaining_amount, so this one must too.
remaining_amount: 0,
},
{
user_id: userId,
company_id: companyId,
supplier_id: supplierMap['Demokafé AB'],
arrival_number: 2,
supplier_invoice_number: '88245',
invoice_date: toDateStr(fiveDaysAgo),
due_date: toDateStr(sevenDaysFromNow),
received_date: toDateStr(fiveDaysAgo),
status: 'registered',
currency: 'SEK',
subtotal: 240,
vat_amount: 28.80,
total: 268.80,
// Luhn-valid OCR so the betalfil preview demos the structured
// reference path instead of the invoice-number fallback.
payment_reference: '882456',
// Must be set explicitly: PostgREST normalizes columns across
// rows in a bulk insert, so omitting paid_amount here while the
// first row sets it sends null instead of falling through to the
// schema default (0), violating the NOT NULL constraint.
paid_amount: 0,
// No trigger derives this; without it the unpaid demo invoice
// shows "0 kr kvar att betala" and cannot join a betalfil.
remaining_amount: 268.80,
},
])
.select('id, supplier_invoice_number')
if (supInvError) throw supInvError
const supInvoiceMap = Object.fromEntries(
supInvoices.map(s => [s.supplier_invoice_number, s.id])
)
// Supplier invoice line items. Note: supplier_invoice_items.vat_rate is
// stored as a decimal (0.25 = 25%); invoice_items.vat_rate above uses
// integer percent (25). Two different conventions inherited from earlier
// migrations: don't try to "fix" it here.
const { error: supItemsError } = await supabase
.from('supplier_invoice_items')
.insert([
{
supplier_invoice_id: supInvoiceMap['4711-2026-03'],
description: 'Mobil + bredband, mars',
quantity: 1,
unit_price: 480,
line_total: 480,
vat_rate: 0.25,
vat_amount: 120,
account_number: '6212',
},
{
supplier_invoice_id: supInvoiceMap['88245'],
description: 'Kundmöte Demokafé (representation)',
quantity: 1,
unit_price: 240,
line_total: 240,
vat_rate: 0.12,
vat_amount: 28.80,
account_number: '5810',
},
])
if (supItemsError) throw supItemsError
// 14. Add one fully-depreciable asset (laptop) so /assets shows
// something other than a Package empty state. Acquired 18 months ago,
// 60-month linear depreciation. Cost set above the 2026
// förbrukningsinventarier threshold (half prisbasbelopp ≈ 29 600 SEK)
// so the demo unambiguously illustrates capitalization rather than
// direct expensing.
const eighteenMonthsAgo = new Date(today)
eighteenMonthsAgo.setMonth(today.getMonth() - 18)
const { error: assetError } = await supabase
.from('assets')
.insert({
user_id: userId,
company_id: companyId,
name: 'Demo-laptop',
category: 'computer',
acquisition_date: toDateStr(eighteenMonthsAgo),
acquisition_cost: 35000,
salvage_value: 0,
useful_life_months: 60,
depreciation_method: 'linear',
bas_asset_account: '1250',
bas_accumulated_account: '1259',
bas_expense_account: '7831',
notes: 'Demo-tillgång: visar planenlig avskrivning över 5 år.',
})
if (assetError) throw assetError
// 15. Pre-built, verified agent_profile so the assistant chrome (FAB,
// /chat surface, agent identity in nav) renders without firing a
// composer run. The chat itself is server-gated by guardSandbox().
// Delegated to ensureSandboxAgentProfile so the persona lives in one
// place (this seed, the dashboard/chat layout backfill, and the seed
// top-up path all use the same helper).
await ensureSandboxAgentProfile(supabase, companyId)
// 16. Inbox item backing the pre-staged supplier-invoice approval below.
// commitCreateSupplierInvoiceFromInbox does an idempotency + FK lookup
// against invoice_inbox_items by inbox_item_id before it creates anything,
// so the "Godkänn" path can only succeed if a real inbox row exists.
// status is constrained to 'received' | 'processing' | 'error' (migration
// 20260813213000); seeded rows are always 'received'.
const { data: inboxRow, error: inboxError } = await supabase
.from('invoice_inbox_items')
.insert({
user_id: userId,
company_id: companyId,
status: 'received',
source: 'upload',
matched_supplier_id: supplierMap['Demokafé AB'],
extracted_data: {
supplier: { name: 'Demokafé AB' },
invoice: {
invoiceNumber: 'INKOMMANDE-2026-001',
invoiceDate: toDateStr(fiveDaysAgo),
dueDate: toDateStr(sevenDaysFromNow),
currency: 'SEK',
vatTreatment: 'reduced_12',
},
totals: { subtotal: 240, vat: 28.80, total: 268.80 },
lineItems: [
{
description: 'Kundmöte Demokafé (representation)',
quantity: 1,
unit: 'st',
unit_price: 240,
line_total: 240,
account_number: '5810',
vat_rate: 12,
vat_amount: 28.80,
},
],
},
})
.select('id')
.single()
if (inboxError) throw inboxError
// 17. Pre-staged pending_operations so /pending isn't empty. Both the
// executor-complete params and the per-type preview_data shapes live in
// ./pending-operations, where they are unit-testable.
const { error: pendOpsError } = await supabase
.from('pending_operations')
.insert(
buildSandboxPendingOperations({
userId,
companyId,
inboxItemId: inboxRow.id,
supplierId: supplierMap['Demokafé AB'],
invoiceDate: toDateStr(fiveDaysAgo),
dueDate: toDateStr(sevenDaysFromNow),
transactionId: txMap['INSÄTTNING BANKGIRO'],
}),
)
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<string, string> = {}
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')
// Draft until the lines exist; see the ledger-history comment above.
.insert({ ...voucher.entry, voucher_number: salaryVoucherNumber, status: 'draft' })
.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: salaryPostError } = await supabase
.from('journal_entries')
.update({ status: 'posted' })
.in('id', Object.values(runEntryLinks))
.eq('company_id', companyId)
if (salaryPostError) throw salaryPostError
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 })
return NextResponse.json(
{ error: 'Failed to seed sandbox data', requestId },
{ status: 500 }
)
}
}
/**
* Idempotent top-up for sandboxes that pre-date the agent_profile addition
* to the seed. Re-running the seed on those older sandboxes short-circuits
* 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,
companyId: string,
): Promise<void> {
await ensureSandboxAgentProfile(supabase, companyId)
}