* fix(db): enforce balance check on directly inserted posted journal entries check_balance_on_post only fires on the draft-to-posted UPDATE transition, so any code path that INSERTs a row with status 'posted' directly skipped balance validation entirely. The invariant sum(debit) = sum(credit) on every posted entry was DB-enforced only for the engine's commit lifecycle. Add check_balance_on_posted_insert, a deferred constraint trigger on AFTER INSERT WHEN (NEW.status = 'posted') reusing the existing check_journal_entry_balance() function, which already handles the journal_entries INSERT context via NEW.id/NEW.status. Deferred semantics let an atomic transaction insert header and lines together; zero-line and unbalanced posted inserts are rejected at constraint evaluation. All existing checks stay intact; this only adds coverage. The one first-party posted-INSERT path outside an RPC, the sandbox seed, now books through the bookkeeping engine (createJournalEntry) instead of raw inserts. SIE import already inserts header and lines in a single transaction via its structured RPC and passes unchanged. pg tests cover the new path (zero-line rejected, unbalanced rejected at SET CONSTRAINTS IMMEDIATE, balanced same-transaction insert accepted) and existing posted-entry fixtures move to a transactional insertPostedJournalEntry helper so they stay valid setup. Fixes #327 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tests): insert list-filters pg fixtures in one transaction The list-filters suite (landed via a sibling merge) inserted posted headers with getPool().query, where each query autocommits: the deferred check_balance_on_posted_insert constraint fired at the header's own commit with zero lines and correctly rejected the fixture. Header and balanced lines now share one BEGIN/COMMIT so the constraint evaluates the complete entry, mirroring the insertPostedJournalEntry helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(seed): insert journal headers as drafts, post after lines land check_balance_on_posted_insert (renamed to apply-time version 20260806130000) rejects a posted header whose transaction has no lines. PostgREST autocommits each request, so every seed path that inserted posted headers first would die with "has zero total": the sandbox seed (ledger history, invoice vouchers, salary vouchers), seed-demo-account and seed-export-data. All now insert draft headers, insert lines, then flip to posted so check_balance_on_post validates the finished verifikat. The sandbox seed keeps its documented no-events design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): preserve a preset committed_at on draft-to-posted transition set_committed_at() stamped now() unconditionally, so the seed flows that post backdated drafts lost their historical booking timestamps and every demo verifikat read as booked today (CodeRabbit finding on PR 1439). Stamp only when committed_at is NULL: the engine path (drafts carry no committed_at) behaves exactly as before and a posted entry still always has a committed_at; an explicitly supplied value now survives posting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): preserve preset committed_at only for trusted roles The IS NULL guard alone (20260806150000, never shipped; replaced by 20260806160000) let any RLS-permitted member backdate committed_at through PostgREST by presetting it on a draft and posting, which the Swedish accounting review flagged: committed_at is what the BFL 5 kap timeliness checks and behandlingshistorik treat as the genuine transition time. Preset values now survive posting only for service_role/postgres/supabase_admin; authenticated and anon writers always get the now() stamp. Consequence: the sandbox seed (runs as the requesting user) gets committed_at = posting time, accepted and documented in the route; the demo scripts run as service_role and keep their backdated history. pg tests cover all four paths, with the upper timestamp bound CodeRabbit asked for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): restore superseded migration so the preview tracker stays consistent The preview branch had already applied 20260806150000 when the previous commit deleted the file, orphaning the preview's migration tracker ("Remote migration versions not found in local migrations directory"). Restored with a header explaining it is superseded in the same deploy by 20260806160000, so the unguarded semantics are never live on their own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): decide committed_at trust by JWT claims, not current_user The Swedish review found the current_user guard bypassable: commit_journal_entry is SECURITY DEFINER and granted to authenticated, so inside it current_user is the function owner and a member could preset a backdated committed_at on a direct-inserted draft and launder it through the RPC. The guard now reads the JWT claims role (same primitive as the RPC's own tenant guard): preset values survive only for service_role or claim-less backend connections; authenticated and anon callers are always stamped now(), on both the direct UPDATE and the RPC path (new pg test). Both migration files now carry the identical final body so no unguarded intermediate exists as a standalone applyable unit. Behandlingshistorik logging of trusted overrides is follow-up #1444. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1264 lines
46 KiB
TypeScript
1264 lines
46 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)
|
|
}
|
|
|
|
// 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 = 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')
|
|
// 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)
|
|
}
|