* 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>
1227 lines
44 KiB
TypeScript
1227 lines
44 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 {
|
|
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,
|
|
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,
|
|
},
|
|
{
|
|
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,
|
|
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,
|
|
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,
|
|
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)
|
|
}
|
|
|
|
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,
|
|
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'],
|
|
status: 'posted',
|
|
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'],
|
|
status: 'posted',
|
|
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
|
|
|
|
// 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 = new Date(today)
|
|
momsDeadline.setMonth(momsDeadline.getMonth() + 2)
|
|
momsDeadline.setDate(12)
|
|
|
|
const { error: dlError } = await supabase
|
|
.from('deadlines')
|
|
.insert([
|
|
{
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
title: `Momsdeklaration Q1 ${currentYear}`,
|
|
due_date: toDateStr(momsDeadline),
|
|
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: `${currentYear}-Q1`,
|
|
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',
|
|
bankgiro: '5559-0001',
|
|
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-0002',
|
|
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,
|
|
},
|
|
{
|
|
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,
|
|
// 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,
|
|
},
|
|
])
|
|
.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' | 'error' (migration 20260504180000).
|
|
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')
|
|
.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 })
|
|
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)
|
|
}
|