Files
accounted/app/api/sandbox/seed/route.ts
T
MattssonandClaude Fable 5.1 1c82baf553 feat(invoices): offert (quote) document type with own OF-series, decisions, conversion, MCP and v1 (#2163)
* fix(invoices): reminders, AR ledger, AR reconciliation and deadlines only read fakturor

The overdue-reminder run, the kundreskontra, the 1510 reconciliation and the
deadlines page selected invoices by status alone. A sent proforma past its
due date was chased with a betalningspaminnelse and flipped to 'overdue',
and it appeared as a receivable. All four now filter document_type =
'invoice', which is also the precondition for adding quotes (offert): a
quote carries a date but never a receivable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56

* feat(invoices): offert (quote) document type with its own OF-series, decisions and conversion

Adds document_type 'quote' with valid_until, quote_status (open / accepted /
declined; expired is derived from valid_until, never stored) and
quote_decided_at. Quotes are numbered OF-nnn at insert from
company_settings.next_quote_number via generate_quote_number(), the same
pattern as delivery notes, so a declined quote never leaves a hole in the
F-series the way a proforma does. The column next_quote_number already
existed on prod and staging without a migration; the migration adopts it.

Engine: build-invoice-write writes the quote columns and keeps
remaining_amount at 0; the draft editor refuses accepted or declined
quotes; PATCH refuses changing a quote's or delivery note's document type
since the number belongs to the series; mark-paid refuses quotes.

New POST /api/invoices/[id]/quote-status records the decision and locks
once an invoice exists. Conversion is extracted into
lib/invoices/convert-to-invoice.ts (one implementation for the route and
the MCP staged commit, which had drifted): a converted quote stays and
flips to accepted, the invoice links back via converted_from_id and gets
its due date from the customer's payment terms; a declined or already
invoiced quote is refused. next-number previews the OF-series for quotes.

Migration applied to the staging branch and registered as 20260902140000;
the pg test runs in CI (pg-real).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56

* feat(invoices): quote PDF, email and filename surfaces

The customer-facing surfaces get a quote sibling for every proforma branch:
PDF title OFFERT / QUOTE with Offertdatum and Giltig till instead of the
due date, a notice that the document is not an invoice or a payment
request, and no payment box, OCR, bankgiro, Swish, QR or payment link.
The email says the quote is attached and valid until the expiry, drops
the payment details and pay-online button, and asks about the quote
rather than the invoice. Filenames read "Offert nr OF-001". Seller VAT
number and payment accounts are skipped for quotes as for proformas:
a quote is not a faktura under ML 17 kap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56

* feat(invoices): offert in the editor, list and detail pages

Editor: "Offert" document type with a required "Giltig till" field
(default today + 30 days) in place of the due date; the wire body mirrors
it into due_date so the shared schema is satisfied. Payment link, ROT/RUT,
periodisering and the bank box are already gated on real invoices. The
type cannot be switched on an existing quote (its OF-number belongs to
the series).

List: an Offerter tab beside Proforma, "Ny offert" in the split button,
and a status column that shows the decision or the derived expiry:
Utgången and Avböjd are exception chips, Öppen and Accepterad muted text.

Detail: Acceptera and Skapa faktura in the header, Avböj in the overflow
menu; an expired quote asks before accepting or invoicing (bypassable);
once an invoice exists the page links to it as Fakturerad and hides the
decision actions. Strings in both sv and en.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56

* feat(mcp,v1): expose offert on the MCP tools and the v1 REST surface

MCP: create_invoice takes document_type quote with a required valid_until
and allocates the OF-number at insert; the convert tool keeps its id and
accepts quotes with the registry refusal codes; new set_quote_status;
list_invoices and get_invoice expose valid_until and the effective quote
status, including a derived expired filter. The tools/list payload stays
under its ceiling without a ledger change. The MCP staged convert now
uses the shared converter.

v1: POST /invoices/{id}/quote-status (registered in the endpoint registry,
scope map and route loader), valid_until and quote_status in the list,
create and detail shapes, and a quote_status list filter. Skill atoms
mention offert. Decision log lines for the own number series, derived
expiry, accepted-not-cancelled conversion and the header action layout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56

* test(invoices): pass route params and period id in the new quote tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56

* refactor(invoices): literal update payloads in the converter so the phantom-column guard can read them

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56

* fix(invoices): close the quote review findings in one pass

Skeptics (correctness, compliance, regression) and CodeRabbit on #2163:

- quote_status is no longer a write-builder output, so a v1 PATCH or MCP
  update_invoice can never reset a recorded accept/decline; new quotes are
  opened by the invoices_quote_defaults trigger (20260902141000), which
  also keeps due_date and valid_until equal. v1 PATCH and the MCP update
  executor now use the shared editable-draft predicate.
- One live invoice per converted source, enforced by a partial unique
  index; the converter maps 23505 to INVOICE_QUOTE_ALREADY_INVOICED and
  both quote-status routes compare-and-set on the decision they read.
- MCP-created quotes carry remaining_amount 0; mark-paid, transaction
  match and voucher link refuse non-invoices on the MCP staging tools,
  the executors and the dashboard link route.
- Conversion of a foreign-currency source refetches the rate for the
  conversion day (ML 8 kap 21-23 paragraphs) and fails closed without one;
  0-day payment terms mean due on receipt.
- bulk-create refuses quotes per item; list_invoices rejects a
  quote_status filter combined with another document_type; an omitted
  document_type on PATCH means unchanged.
- attention, push notifications, open-AR count, FX revaluation, year-end
  and accrual auto-detect and bank-match suggestions only read fakturor.
- Quote PDF and email print Summa / Total instead of Att betala.
- Regenerated skills/accounted-api for the new v1 endpoint.

Declined with reasons in DECISIONS.md: NOT VALID + VALIDATE and CONCURRENTLY
on the migrations (repo precedent, 13.8k rows, transactional apply);
re-validating VAT treatment at conversion (the converted invoice is a
draft the user reviews; follow-up).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs

* fix(invoices): second review round: migration versions, order links, batch allocation, races

- Migrations renamed to 20260902220000 / 20260902221000: #2166 shipped its
  own 20260902141000 to prod while this PR was in review and prod's head
  moved past both files; below-head versions are skipped by branching,
  which would have left the quote trigger off prod. Staging rows renamed.
- Quote lines never carry sales_order_item_id (an offer must not count as
  invoiced kundorder quantity); the converter carries a proforma line's
  order link onto the invoice.
- Converter compare-and-sets the source (proforma cancel, quote accept):
  a concurrent cancel, proforma-to-order conversion or decision removes
  the orphan invoice with INVOICE_CONVERT_SOURCE_CHANGED instead of a
  second document for the same sale.
- MCP set_quote_status gets the same compare-and-set as the HTTP routes;
  0-row updates report INVOICE_QUOTE_CHANGED_CONCURRENTLY everywhere.
  quote-status (dashboard, v1, MCP) accepts valid_until so an expired
  sent quote can be reopened, as the docs promised.
- MCP mark-paid refuses only quotes, parity with the dashboard route
  (a sent proforma marked paid is a supported prepayment record).
- Batch allocation (dashboard route and MCP tool) refuses non-invoices
  before the RPC, which gates on status alone.
- Customer AR drill-down, v1 customer open invoices and archive guard,
  and the calendar feed read fakturor only.
- Draft quote PDF says "UTKAST" instead of "not a valid invoice"; the
  editor locks the document type on existing quotes and delivery notes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs

* chore(invoices): use roundOre in the quote MCP summaries and FX test after main tightened the guard baseline

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs

* fix(invoices): third review round: atomic decision lock, viewer gate, lookup errors, quote payment terms

- 20260902222000: BEFORE UPDATE trigger locks an accepted quote while a
  live converted invoice exists (the compare-and-set in the three decision
  writers could still be beaten by a conversion landing in between); the
  routes and the MCP tool map the raise to 409 INVOICE_QUOTE_ALREADY_INVOICED.
  generate_quote_number now also requires a non-viewer membership so a
  viewer's session token cannot burn OF-numbers through PostgREST.
- Converter checks quote eligibility before the Riksbanken call and treats
  a failed company_settings read as a failure instead of a 30-day default.
- Re-sending the same decision keeps quote_decided_at (idempotent).
- gnubok_find_voucher_candidates_for_invoice refuses non-invoices like its
  write sibling; the dashboard link route surfaces a failed lookup.
- Late-fee and credit-term texts never print on a quote.
Applied and registered on staging; pg tests added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs

* fix(invoices): review nits: fail-closed batch lookup, dry-run expiry, quote heading, quote-date CHECK

- match-batch surfaces a failed document lookup instead of allocating.
- v1 quote-status dry-run preview carries the new valid_until.
- Quote PDF heading reads Offertinformation / Quote information.
- 20260902222000 also pins the date invariants the trigger maintains as a
  CHECK: a quote always has valid_until = due_date, nothing else has one.
  Applied on staging.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:40:25 +02:00

1291 lines
48 KiB
TypeScript

import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { requireAuth } from '@/lib/auth/require-auth'
import { NextResponse } from 'next/server'
import { getActiveCompanyId } from '@/lib/company/context'
import { createLogger } from '@/lib/logger'
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
import { truncateIp } from '@/lib/api/v1/with-api-v1'
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import { encryptPersonnummer } from '@/lib/salary/personnummer'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
import { buildSandboxCustomers } from './customers'
import { buildSandboxPendingOperations } from './pending-operations'
import { buildSandboxArticles } from './articles'
import { buildSandboxVatDeadline } from './vat-deadline'
import {
buildSandboxLedgerHistory,
SANDBOX_LEDGER_ACCOUNT_NUMBERS,
} from './ledger-history'
import {
buildSandboxEmployees,
buildSandboxSalaryLineItems,
buildSandboxSalaryRunEmployees,
buildSandboxSalaryRuns,
mapSandboxEmployeeIds,
resolveSandboxSalaryPeriods,
SANDBOX_RUN_TOTALS,
SANDBOX_TOTAL_VACATION_ACCRUAL_AVGIFTER,
} from './salary'
import {
buildSandboxSalaryVouchers,
SANDBOX_SALARY_ACCOUNT_NUMBERS,
} from './salary-vouchers'
// Anonymous sign-in is enabled in all environments so visitors can try the
// product; a per-/24 cap on the seed endpoint keeps a single network from
// spinning up arbitrary sandbox companies. Idempotent for legit users, so 5/h
// covers retries; an attacker has to rotate /24s to scale abuse.
const RATE_LIMIT = { maxRequests: 5, windowMs: 60 * 60 * 1000 }
/**
* POST /api/sandbox/seed
* Seeds demo data for an anonymous sandbox user.
* Only callable by anonymous users (is_anonymous === true).
*/
export async function POST(request: Request) {
// Per-request logger so seed-failure entries are correlatable in the SIEM.
// Cannot reuse withRouteContext here: it requires an active company, but
// the sandbox seed runs *before* a company exists for the user.
const requestId = `req_${crypto.randomUUID()}`
const log = createLogger('sandbox:seed', { requestId })
const fwd = request.headers.get('x-forwarded-for')
const rawIp = fwd ? fwd.split(',')[0]?.trim() : request.headers.get('x-real-ip') ?? undefined
// Fall back to a shared 'unknown' bucket when the proxy doesn't surface a
// client IP: keeps the limit enforced under a misconfigured deploy rather
// than failing open. Truncated /24 elsewhere is the normal path.
const ipIdentifier = truncateIp(rawIp || undefined) ?? 'unknown'
if (rawIp && ipIdentifier === 'unknown') {
log.warn('unparseable forwarded-for header on sandbox seed', { headerLength: rawIp.length })
}
const rl = await checkRateLimit({
prefix: 'sandbox:seed',
identifier: ipIdentifier,
...RATE_LIMIT,
})
if (!rl.ok) return rl.response!
// Can't use withRouteContext (see above, no company yet), so call requireAuth
// directly: the documented stopgap that still enforces MFA. A no-op for the
// anonymous users this route serves (they have no second factor), but keeps
// the route on the same auth path as the rest of the API.
//
// GDPR Art.32 compensating controls for this anonymous, low-auth write path:
// (1) anonymous-only: authenticated users are rejected below (403); (2) the
// /24 rate limit above (5/h); (3) all seeded data is synthetic demo content
// (fabricated names, example.com emails, documentation-reserved org numbers),
// not real personal data; (4) writes are scoped to the caller's own freshly
// created sandbox company, RLS-isolated from every other tenant.
const auth = await requireAuth()
if (auth.error) return auth.error
const { user, supabase } = auth
if (!user.is_anonymous) {
return NextResponse.json(
{ error: 'Sandbox is only available for anonymous users', requestId },
{ status: 403 },
)
}
// Anonymous users start with no company. Create one before seeding.
// If a previous seed attempt already created a company for this user, reuse it
// (idempotency).
let companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) {
const { data: newCompanyId, error: companyError } = await supabase.rpc(
'create_company_with_owner',
{
p_name: 'Sandlådan Konsult',
p_entity_type: 'enskild_firma',
}
)
if (companyError || !newCompanyId) {
log.error('failed to create sandbox company', { error: companyError, userId: user.id })
return NextResponse.json(
{ error: 'Failed to create sandbox company', requestId },
{ status: 500 }
)
}
companyId = newCompanyId as string
}
// Idempotency: if the core seed already ran (company_settings exists), skip
// the bulk insert path. We still TOP UP the newer surfaces (agent_profile,
// suppliers, asset, pending operations) afterwards so an old sandbox session
// (created before those were added to the seed) picks them up on the next
// call instead of being stuck without a verified assistant.
const { data: existing } = await supabase
.from('company_settings')
.select('id')
.eq('company_id', companyId)
.maybeSingle()
if (existing) {
try {
await topUpSandboxAdditions(supabase, companyId)
return NextResponse.json({ seeded: false, topped_up: true })
} catch (err) {
log.error('failed to top up sandbox additions', { error: err, userId: user.id, companyId })
return NextResponse.json({ seeded: false, topped_up: false })
}
}
try {
const userId = user.id
// 1. Update profile (auto-created by auth trigger)
await supabase
.from('profiles')
.update({ full_name: 'Demo Användare' })
.eq('id', userId)
// 2. Create company settings
const { error: settingsError } = await supabase
.from('company_settings')
.insert({
user_id: userId,
company_id: companyId,
entity_type: 'enskild_firma',
company_name: 'Sandlådan Konsult',
org_number: '199001011234',
address_line1: 'Demovägen 1',
postal_code: '111 22',
city: 'Stockholm',
country: 'SE',
f_skatt: true,
vat_registered: true,
vat_number: 'SE199001011234',
moms_period: 'quarterly',
fiscal_year_start_month: 1,
accounting_method: 'accrual',
invoice_prefix: 'F',
next_invoice_number: 5,
next_delivery_note_number: 1,
next_quote_number: 1,
invoice_default_days: 30,
// Sender bank details: the pain.001 debtor for the betalfil demo.
// Example IBAN from the Swedish IBAN documentation range; BIC derives
// from it being an SEB-style example. Demo-only values.
iban: 'SE3550000000054910000003',
bic: 'ESSESESS',
bankgiro: '991-2346',
onboarding_step: 6,
onboarding_complete: true,
initial_setup_path: 'fresh',
initial_setup_completed_at: new Date().toISOString(),
initial_setup_dismissed_at: new Date().toISOString(),
is_sandbox: true,
// Dimensions demo: the register/pickers render out of the box.
dimensions_enabled: true,
// Payroll demo. `pays_salaries` is the UI gate DashboardNav reads to
// decide whether Löner and Anställda appear at all (an enskild firma
// is not an employer by default), and `employer_registered` is the
// AGI gate. An EF may absolutely employ staff; what it may not do is
// put its OWNER on payroll, which is why both seeded employees carry
// employment_type 'employee' rather than 'company_owner'.
pays_salaries: true,
employer_registered: true,
})
if (settingsError) throw settingsError
// 3. Seed chart of accounts via RPC
const { error: coaError } = await supabase.rpc('seed_chart_of_accounts', {
p_company_id: companyId,
p_entity_type: 'enskild_firma',
})
if (coaError) throw coaError
// 3b. Seed demo dimensions (kostnadsställe/projekt). ensure_company_dimensions
// lazily creates the system dims 1/6; two values per dim give the register,
// pickers, and the dimension-tagged journal lines below something to show.
const { error: dimsRpcError } = await supabase.rpc('ensure_company_dimensions', {
p_company_id: companyId,
})
if (dimsRpcError) throw dimsRpcError
const { data: demoDims, error: demoDimsError } = await supabase
.from('dimensions')
.select('id, sie_dim_no')
.eq('company_id', companyId)
.in('sie_dim_no', [1, 6])
if (demoDimsError) throw demoDimsError
const dimIdByNo = Object.fromEntries(
(demoDims ?? []).map(d => [d.sie_dim_no as number, d.id as string])
) as Record<number, string>
if (dimIdByNo[1] && dimIdByNo[6]) {
const seededDimensionCodes = ['BUTIK', 'WEBB', 'P001', 'P002']
const { error: dimValuesError } = await supabase
.from('dimension_values')
.insert([
{ company_id: companyId, dimension_id: dimIdByNo[1], code: 'BUTIK', name: 'Butiken' },
{ company_id: companyId, dimension_id: dimIdByNo[1], code: 'WEBB', name: 'Webbshoppen' },
{ company_id: companyId, dimension_id: dimIdByNo[6], code: 'P001', name: 'Projekt Björk' },
{ company_id: companyId, dimension_id: dimIdByNo[6], code: 'P002', name: 'Projekt Alm' },
])
if (dimValuesError) throw dimValuesError
log.info('seeded sandbox dimension values', { companyId, codes: seededDimensionCodes })
}
// 4. Create fiscal period (current year)
const currentYear = new Date().getFullYear()
const { data: fiscalPeriod, error: fpError } = await supabase
.from('fiscal_periods')
.insert({
user_id: userId,
company_id: companyId,
name: `Räkenskapsår ${currentYear}`,
period_start: `${currentYear}-01-01`,
period_end: `${currentYear}-12-31`,
})
.select('id')
.single()
if (fpError) throw fpError
// 5. Create customers
const { data: customers, error: custError } = await supabase
.from('customers')
.insert(buildSandboxCustomers(userId, companyId))
.select('id, name')
if (custError) throw custError
const customerMap = Object.fromEntries(customers.map(c => [c.name, c.id]))
// 6. Create invoices
const today = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const toDateStr = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const thirtyDaysAgo = new Date(today)
thirtyDaysAgo.setDate(today.getDate() - 30)
const fifteenDaysAgo = new Date(today)
fifteenDaysAgo.setDate(today.getDate() - 15)
const thirtyDaysFromNow = new Date(today)
thirtyDaysFromNow.setDate(today.getDate() + 30)
const fiveDaysAgo = new Date(today)
fiveDaysAgo.setDate(today.getDate() - 5)
const { data: invoices, error: invError } = await supabase
.from('invoices')
.insert([
{
user_id: userId,
company_id: companyId,
customer_id: customerMap['Björk & Partner AB'],
invoice_number: 'F-2026001',
invoice_date: toDateStr(thirtyDaysAgo),
due_date: toDateStr(today),
status: 'paid',
subtotal: 15000,
vat_amount: 3750,
total: 18750,
vat_treatment: 'standard_25',
vat_rate: 25,
moms_ruta: '10',
document_type: 'invoice',
paid_at: toDateStr(fifteenDaysAgo),
paid_amount: 18750,
remaining_amount: 0,
},
{
user_id: userId,
company_id: companyId,
customer_id: customerMap['Schmidt GmbH'],
invoice_number: 'F-2026002',
invoice_date: toDateStr(fifteenDaysAgo),
due_date: toDateStr(thirtyDaysFromNow),
status: 'sent',
subtotal: 20000,
vat_amount: 0,
total: 20000,
remaining_amount: 20000,
paid_amount: 0,
vat_treatment: 'reverse_charge',
vat_rate: 0,
reverse_charge_text: 'Reverse charge: buyer is liable for VAT',
document_type: 'invoice',
},
{
user_id: userId,
company_id: companyId,
customer_id: customerMap['Anna Lindström'],
invoice_number: 'F-2026003',
invoice_date: toDateStr(thirtyDaysAgo),
due_date: toDateStr(fiveDaysAgo),
status: 'overdue',
subtotal: 5000,
vat_amount: 1250,
total: 6250,
remaining_amount: 6250,
paid_amount: 0,
vat_treatment: 'standard_25',
vat_rate: 25,
moms_ruta: '10',
document_type: 'invoice',
},
{
user_id: userId,
company_id: companyId,
customer_id: customerMap['Björk & Partner AB'],
invoice_number: 'F-2026004',
invoice_date: toDateStr(today),
due_date: toDateStr(thirtyDaysFromNow),
status: 'draft',
subtotal: 8000,
vat_amount: 2000,
total: 10000,
// PostgREST normalises a bulk insert to the union of keys: every row in
// this batch carries both columns so none arrives as NULL.
remaining_amount: 10000,
paid_amount: 0,
vat_treatment: 'standard_25',
vat_rate: 25,
moms_ruta: '10',
document_type: 'invoice',
},
])
.select('id, invoice_number')
if (invError) throw invError
const invoiceMap = Object.fromEntries(invoices.map(i => [i.invoice_number, i.id]))
// 7. Create invoice items
const { error: itemsError } = await supabase
.from('invoice_items')
.insert([
{
invoice_id: invoiceMap['F-2026001'],
description: 'Webbutveckling, mars 2026',
quantity: 30,
unit: 'tim',
unit_price: 500,
line_total: 15000,
vat_rate: 25,
},
{
invoice_id: invoiceMap['F-2026002'],
description: 'IT-konsulting, internationellt projekt',
quantity: 40,
unit: 'tim',
unit_price: 500,
line_total: 20000,
vat_rate: 0,
},
{
invoice_id: invoiceMap['F-2026003'],
description: 'Hemsida & grafisk profil',
quantity: 1,
unit: 'st',
unit_price: 5000,
line_total: 5000,
vat_rate: 25,
},
{
invoice_id: invoiceMap['F-2026004'],
description: 'Systemunderhåll april 2026',
quantity: 16,
unit: 'tim',
unit_price: 500,
line_total: 8000,
vat_rate: 25,
},
])
if (itemsError) throw itemsError
// 8. Resolve account IDs for journal entries.
//
// seed_chart_of_accounts lays down the K1 subset, and its 7xxx personnel
// block is gated on p_entity_type = 'aktiebolag'. The sandbox company is an
// enskild firma, so none of the payroll accounts exist yet, and neither do
// the semesterlöneskuld pair. Create the missing ones from the BAS 2026
// reference first, exactly as ensureSalaryAccountsExist does before the
// real booking path posts a salary run.
const neededAccounts = [
...new Set([
...SANDBOX_LEDGER_ACCOUNT_NUMBERS,
...SANDBOX_SALARY_ACCOUNT_NUMBERS,
'1510',
'1930',
'2611',
'3001',
// The 6 % article (a printed book) derives 3003/2631 at invoice-line
// time; neither is in the K1 chart, so invoicing it would post to an
// account the company does not have.
'2631',
'3003',
]),
]
const { data: existingAccounts, error: existingAccountsError } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.in('account_number', neededAccounts)
if (existingAccountsError) throw existingAccountsError
const existingAccountNumbers = new Set(
(existingAccounts ?? []).map(a => a.account_number as string)
)
const missingAccounts = neededAccounts
.filter(n => !existingAccountNumbers.has(n))
.map(accountNumber => {
const ref = getBASReference(accountNumber)
// An account with no BAS 2026 reference would have to be invented here.
// Better to fail the seed than to write a chart row with guessed
// class/type/normal_balance that every report would then trust.
if (!ref) {
throw new Error(`Sandbox seed: no BAS reference for account ${accountNumber}`)
}
return {
user_id: userId,
company_id: companyId,
account_number: accountNumber,
account_name: ref.account_name,
account_class: ref.account_class,
account_group: ref.account_group,
account_type: ref.account_type,
normal_balance: ref.normal_balance,
sru_code: ref.sru_code,
k2_excluded: ref.k2_excluded,
plan_type: 'full_bas',
is_active: true,
is_system_account: false,
}
})
if (missingAccounts.length > 0) {
const { error: missingAccountsError } = await supabase
.from('chart_of_accounts')
.insert(missingAccounts)
if (missingAccountsError) throw missingAccountsError
}
// Errors are fatal here, not tolerable: a failed read would leave
// accountMap empty and silently write account_id: null onto every
// ledger-history and salary voucher line, producing a sandbox whose
// vouchers reference no account at all.
const { data: accounts, error: accountsError } = await supabase
.from('chart_of_accounts')
.select('id, account_number')
.eq('company_id', companyId)
.in('account_number', neededAccounts)
if (accountsError) throw accountsError
const accountMap = Object.fromEntries(
(accounts ?? []).map(a => [a.account_number, a.id])
)
// 9. Year-to-date ledger history (January through last month).
//
// Without it the company has two verifikat and every report is a flat
// line: Resultatrapport, Balansrapport, Nyckeltal and Momsrapport all read
// as broken rather than empty.
//
// Seeded BEFORE the invoice and payroll vouchers below on purpose.
// next_voucher_number hands out numbers in call order, so seeding January
// last would have produced A-1 dated in July followed by A-3 dated in
// January: a gap-free sequence that runs backwards through the year, which
// is not what BFNAR 2013:2 means by a chronological verifikationsserie.
const ledgerHistory = buildSandboxLedgerHistory({
userId,
companyId,
fiscalPeriodId: fiscalPeriod.id,
today,
accountMap,
})
// One RPC per voucher: next_voucher_number is a counter table with a row
// lock (not MAX+1), so sequential calls are safe and gap-free. The two
// writes are batched rather than run per entry, which is what turns ~130
// round trips into ~45 for a seed that runs on every sandbox visit.
const historyVoucherNumbers: number[] = []
for (const historyEntry of ledgerHistory.entries) {
const { data: historyVoucherNumber, error: historyVoucherError } = await supabase.rpc(
'next_voucher_number',
{
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriod.id,
p_series: historyEntry.voucher_series,
},
)
if (historyVoucherError) throw historyVoucherError
historyVoucherNumbers.push(historyVoucherNumber as number)
}
// Inserted as draft and posted after the lines land: PostgREST autocommits
// each request, and check_balance_on_posted_insert (migration
// 20260806130000) rejects a posted header whose transaction carries no
// lines. The draft-to-posted UPDATE below fires check_balance_on_post
// against the finished verifikat instead.
//
// committed_at note: this route runs under the requester's authenticated
// client, and set_committed_at() (migration 20260806160000) preserves a
// preset committed_at only for trusted roles, so any backdated
// committed_at supplied here is overwritten with now() at posting. That
// is deliberate: an end-user role must never control the audit timestamp,
// and sandbox companies are disposable.
const { data: insertedHistoryEntries, error: historyEntryError } = await supabase
.from('journal_entries')
.insert(
ledgerHistory.entries.map((historyEntry, index) => ({
...historyEntry,
voucher_number: historyVoucherNumbers[index],
status: 'draft',
})),
)
.select('id, voucher_number')
if (historyEntryError) throw historyEntryError
// Match on voucher_number, not on array position: PostgREST does not
// promise the returned rows come back in insertion order, and
// (company_id, fiscal_period_id, voucher_series, voucher_number) is unique.
const historyIdByVoucher = new Map(
(insertedHistoryEntries ?? []).map(row => [row.voucher_number as number, row.id as string]),
)
const historyEntryIds = historyVoucherNumbers.map(voucherNumber => {
const entryId = historyIdByVoucher.get(voucherNumber)
if (!entryId) {
throw new Error(`Sandbox seed: ledger history voucher ${voucherNumber} was not inserted`)
}
return entryId
})
const { error: historyLinesError } = await supabase
.from('journal_entry_lines')
.insert(
ledgerHistory.linesByEntryIndex.flatMap((lines, index) =>
lines.map(line => ({ ...line, journal_entry_id: historyEntryIds[index] })),
),
)
if (historyLinesError) throw historyLinesError
const { error: historyPostError } = await supabase
.from('journal_entries')
.update({ status: 'posted' })
.in('id', historyEntryIds)
.eq('company_id', companyId)
if (historyPostError) throw historyPostError
// The history is the company's books from before it arrived in Accounted:
// its kvitton live in the previous system's binder, not here. Left
// unflagged, every one of these vouchers would land on Hem as "Verifikat
// utan underlag" and the demo's first screen would read as a compliance
// mess. Marking them exempt is the same move the SIE-import opt-in makes
// for exactly the same reason, through the same sanctioned sidecar table
// (journal_entry_no_doc_required), so the verifikat themselves stay
// immutable per BFL.
await markEntriesNoDocRequired(
supabase,
companyId,
userId,
historyEntryIds,
'Historisk bokföring: underlag arkiverade i det tidigare systemet.',
)
// 10. Invoice vouchers (inserted directly, not via engine, to avoid event emission)
const { data: voucherNum1 } = await supabase.rpc('next_voucher_number', {
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriod.id,
p_series: 'A',
})
const { data: je1, error: je1Error } = await supabase
.from('journal_entries')
.insert({
user_id: userId,
company_id: companyId,
fiscal_period_id: fiscalPeriod.id,
voucher_number: voucherNum1 ?? 1,
voucher_series: 'A',
entry_date: toDateStr(thirtyDaysAgo),
description: 'Faktura F-2026001, Björk & Partner AB',
source_type: 'invoice_created',
source_id: invoiceMap['F-2026001'],
// Draft until the lines exist; see the ledger-history comment above.
status: 'draft',
committed_at: toDateStr(thirtyDaysAgo),
})
.select('id')
.single()
if (je1Error) throw je1Error
const { data: voucherNum2 } = await supabase.rpc('next_voucher_number', {
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriod.id,
p_series: 'A',
})
const { data: je2, error: je2Error } = await supabase
.from('journal_entries')
.insert({
user_id: userId,
company_id: companyId,
fiscal_period_id: fiscalPeriod.id,
voucher_number: voucherNum2 ?? 2,
voucher_series: 'A',
entry_date: toDateStr(fifteenDaysAgo),
description: 'Betalning faktura F-2026001, Björk & Partner AB',
source_type: 'invoice_paid',
source_id: invoiceMap['F-2026001'],
// Draft until the lines exist; see the ledger-history comment above.
status: 'draft',
committed_at: toDateStr(fifteenDaysAgo),
})
.select('id')
.single()
if (je2Error) throw je2Error
// 10. Create journal entry lines. The P&L line carries demo dimensions
// ({"1":"BUTIK","6":"P001"}) so the register's "antal taggade rader",
// voucher-detail badges, and the dimension P&L report light up in the
// sandbox. cost_center/project are GENERATED from the bag since the PR9
// cutover: writing them explicitly would error.
const revenueDims = { '1': 'BUTIK', '6': 'P001' }
const { error: jelError } = await supabase
.from('journal_entry_lines')
.insert([
// JE1: Invoice creation, Debit AR, Credit Revenue + VAT
// NB: `dimensions` must be set explicitly on EVERY row: same PostgREST
// bulk-insert normalization as paid_amount below: omitting it on some
// rows while one row sets it sends null (violating NOT NULL) instead
// of falling through to the schema default '{}'.
{
journal_entry_id: je1.id,
account_number: '1510',
account_id: accountMap['1510'] ?? null,
debit_amount: 18750,
credit_amount: 0,
sort_order: 0,
dimensions: {},
},
{
journal_entry_id: je1.id,
account_number: '3001',
account_id: accountMap['3001'] ?? null,
debit_amount: 0,
credit_amount: 15000,
sort_order: 1,
dimensions: revenueDims,
},
{
journal_entry_id: je1.id,
account_number: '2611',
account_id: accountMap['2611'] ?? null,
debit_amount: 0,
credit_amount: 3750,
sort_order: 2,
dimensions: {},
},
// JE2: Invoice payment, Debit Bank, Credit AR
{
journal_entry_id: je2.id,
account_number: '1930',
account_id: accountMap['1930'] ?? null,
debit_amount: 18750,
credit_amount: 0,
sort_order: 0,
dimensions: {},
},
{
journal_entry_id: je2.id,
account_number: '1510',
account_id: accountMap['1510'] ?? null,
debit_amount: 0,
credit_amount: 18750,
sort_order: 1,
dimensions: {},
},
])
if (jelError) throw jelError
const { error: invoicePostError } = await supabase
.from('journal_entries')
.update({ status: 'posted' })
.in('id', [je1.id, je2.id])
.eq('company_id', companyId)
if (invoicePostError) throw invoicePostError
// 11. Create transactions
const { data: txRows, error: txError } = await supabase
.from('transactions')
.insert([
// Categorized expenses
{
user_id: userId,
company_id: companyId,
date: toDateStr(thirtyDaysAgo),
description: 'CLAS OHLSON STOCKHOLM',
amount: -450,
category: 'expense_office',
is_business: true,
merchant_name: 'Clas Ohlson',
},
{
user_id: userId,
company_id: companyId,
date: toDateStr(fifteenDaysAgo),
description: 'GITHUB INC',
amount: -999,
category: 'expense_software',
is_business: true,
merchant_name: 'GitHub',
},
{
user_id: userId,
company_id: companyId,
date: toDateStr(fiveDaysAgo),
description: 'SJ BILJETT',
// > 4 000 kr categorized business expense with no attached underlag,
// so gnubok_vat_close_check surfaces a non-empty blocker list.
// (BFL 5 kap 6-7§ require every affärshändelse to be documented with
// underlag; the 4 000 kr cut-off is the tool's own high-value
// heuristic, not a statutory threshold.)
amount: -4500,
category: 'expense_travel',
is_business: true,
merchant_name: 'SJ',
},
// Income matched to paid invoice
{
user_id: userId,
company_id: companyId,
date: toDateStr(fifteenDaysAgo),
description: 'BJÖRK & PARTNER AB BETALNING F-2026001',
amount: 18750,
category: 'income_services',
is_business: true,
invoice_id: invoiceMap['F-2026001'],
journal_entry_id: je2.id,
merchant_name: 'Björk & Partner AB',
},
// Private transaction
{
user_id: userId,
company_id: companyId,
date: toDateStr(fiveDaysAgo),
description: 'PRIVAT INSÄTTNING',
amount: 5000,
category: 'private',
is_business: false,
},
// Uncategorized transactions
{
user_id: userId,
company_id: companyId,
date: toDateStr(fiveDaysAgo),
description: 'SWISH BETALNING 0701234567',
amount: -350,
category: 'uncategorized',
is_business: null,
},
{
user_id: userId,
company_id: companyId,
date: toDateStr(today),
description: 'INSÄTTNING BANKGIRO',
amount: 1200,
category: 'uncategorized',
is_business: null,
},
{
user_id: userId,
company_id: companyId,
date: toDateStr(today),
description: 'KORTBETALNING RESTAURANG',
amount: -680,
category: 'uncategorized',
is_business: null,
},
])
.select('id, description')
if (txError) throw txError
// Lookup so the pre-staged categorize_transaction operation below can
// reference a real, uncategorized transaction by id (descriptions are
// unique in this seed set).
const txMap = Object.fromEntries(
(txRows ?? []).map(t => [t.description as string, t.id as string])
)
// 12. Create deadlines
const momsDeadline = buildSandboxVatDeadline(today)
const { error: dlError } = await supabase
.from('deadlines')
.insert([
{
user_id: userId,
company_id: companyId,
title: momsDeadline.title,
due_date: momsDeadline.dueDate,
deadline_type: 'tax',
priority: 'important',
// Current generator types: the bare 'moms'/'inkomstdeklaration'
// types were retired and seeding them recreates legacy rows the
// cleanup migration removed.
tax_deadline_type: 'moms_quarterly',
tax_period: momsDeadline.period,
source: 'system',
status: 'upcoming',
linked_report_type: 'vat',
},
{
user_id: userId,
company_id: companyId,
title: `Inkomstdeklaration ${currentYear - 1}`,
due_date: `${currentYear}-05-02`,
deadline_type: 'tax',
priority: 'critical',
// Sandbox companies are enskild firma (see p_entity_type above).
tax_deadline_type: 'inkomstdeklaration_ef',
tax_period: `${currentYear - 1}`,
source: 'system',
status: 'upcoming',
},
])
if (dlError) throw dlError
// 13. Seed suppliers + one registered supplier invoice + one paid one.
// Supplier invoices are arguably the second-most-used surface after
// bank transactions; without them the /suppliers and /supplier-invoices
// pages render the empty state and the demo loses a big chunk of the
// accounts-payable story.
// Supplier names use the "Demo" prefix and the documentation-reserved
// 5559... org-number range so the seeded rows cannot be confused with
// production data should they ever leak into a real environment.
const { data: suppliers, error: supError } = await supabase
.from('suppliers')
.insert([
{
user_id: userId,
company_id: companyId,
name: 'Demo Telekom AB',
supplier_type: 'swedish_business',
org_number: '5559000001',
vat_number: 'SE555900000101',
email: 'demo+telekom@example.com',
// Luhn-valid (Bankgirot check digit): the betalfil flow validates
// payee numbers, so demo suppliers must carry numbers that pass.
bankgiro: '5559-0004',
address_line1: 'Demovägen 10',
postal_code: '111 22',
city: 'Stockholm',
country: 'SE',
default_payment_terms: 30,
},
{
user_id: userId,
company_id: companyId,
name: 'Demokafé AB',
supplier_type: 'swedish_business',
org_number: '5559000002',
vat_number: 'SE555900000201',
bankgiro: '5559-0012',
address_line1: 'Demovägen 11',
postal_code: '111 22',
city: 'Stockholm',
country: 'SE',
default_payment_terms: 15,
},
])
.select('id, name')
if (supError) throw supError
const supplierMap = Object.fromEntries(suppliers.map(s => [s.name, s.id]))
// Supplier invoice #1, Telia, paid 15 days ago (mobile + bredband, 25% VAT).
const sevenDaysFromNow = new Date(today)
sevenDaysFromNow.setDate(today.getDate() + 7)
// Hardcode 1 and 2: get_next_arrival_number is MAX+1 against the same
// table we're about to insert into, so calling it twice before the first
// insert lands gives the same value for both rows and violates the
// (company_id, arrival_number) unique index. The company is brand new
// here, so 1 and 2 are guaranteed to be free.
const { data: supInvoices, error: supInvError } = await supabase
.from('supplier_invoices')
.insert([
{
user_id: userId,
company_id: companyId,
supplier_id: supplierMap['Demo Telekom AB'],
arrival_number: 1,
supplier_invoice_number: '4711-2026-03',
invoice_date: toDateStr(thirtyDaysAgo),
due_date: toDateStr(today),
received_date: toDateStr(thirtyDaysAgo),
status: 'paid',
currency: 'SEK',
subtotal: 480,
vat_amount: 120,
total: 600,
payment_reference: '47112026031',
paid_at: toDateStr(fifteenDaysAgo),
paid_amount: 600,
// Same normalization rule as paid_amount below: the other row in
// this bulk insert sets remaining_amount, so this one must too.
remaining_amount: 0,
},
{
user_id: userId,
company_id: companyId,
supplier_id: supplierMap['Demokafé AB'],
arrival_number: 2,
supplier_invoice_number: '88245',
invoice_date: toDateStr(fiveDaysAgo),
due_date: toDateStr(sevenDaysFromNow),
received_date: toDateStr(fiveDaysAgo),
status: 'registered',
currency: 'SEK',
subtotal: 240,
vat_amount: 28.80,
total: 268.80,
// Luhn-valid OCR so the betalfil preview demos the structured
// reference path instead of the invoice-number fallback.
payment_reference: '882456',
// Must be set explicitly: PostgREST normalizes columns across
// rows in a bulk insert, so omitting paid_amount here while the
// first row sets it sends null instead of falling through to the
// schema default (0), violating the NOT NULL constraint.
paid_amount: 0,
// No trigger derives this; without it the unpaid demo invoice
// shows "0 kr kvar att betala" and cannot join a betalfil.
remaining_amount: 268.80,
},
])
.select('id, supplier_invoice_number')
if (supInvError) throw supInvError
const supInvoiceMap = Object.fromEntries(
supInvoices.map(s => [s.supplier_invoice_number, s.id])
)
// Supplier invoice line items. Note: supplier_invoice_items.vat_rate is
// stored as a decimal (0.25 = 25%); invoice_items.vat_rate above uses
// integer percent (25). Two different conventions inherited from earlier
// migrations: don't try to "fix" it here.
const { error: supItemsError } = await supabase
.from('supplier_invoice_items')
.insert([
{
supplier_invoice_id: supInvoiceMap['4711-2026-03'],
description: 'Mobil + bredband, mars',
quantity: 1,
unit_price: 480,
line_total: 480,
vat_rate: 0.25,
vat_amount: 120,
account_number: '6212',
},
{
supplier_invoice_id: supInvoiceMap['88245'],
description: 'Kundmöte Demokafé (representation)',
quantity: 1,
unit_price: 240,
line_total: 240,
vat_rate: 0.12,
vat_amount: 28.80,
account_number: '5810',
},
])
if (supItemsError) throw supItemsError
// 14. Add one fully-depreciable asset (laptop) so /assets shows
// something other than a Package empty state. Acquired 18 months ago,
// 60-month linear depreciation. Cost set above the 2026
// förbrukningsinventarier threshold (half prisbasbelopp ≈ 29 600 SEK)
// so the demo unambiguously illustrates capitalization rather than
// direct expensing.
const eighteenMonthsAgo = new Date(today)
eighteenMonthsAgo.setMonth(today.getMonth() - 18)
const { error: assetError } = await supabase
.from('assets')
.insert({
user_id: userId,
company_id: companyId,
name: 'Demo-laptop',
category: 'computer',
acquisition_date: toDateStr(eighteenMonthsAgo),
acquisition_cost: 35000,
salvage_value: 0,
useful_life_months: 60,
depreciation_method: 'linear',
bas_asset_account: '1250',
bas_accumulated_account: '1259',
bas_expense_account: '7831',
notes: 'Demo-tillgång: visar planenlig avskrivning över 5 år.',
})
if (assetError) throw assetError
// 15. Pre-built, verified agent_profile so the assistant chrome (FAB,
// /chat surface, agent identity in nav) renders without firing a
// composer run. The chat itself is server-gated by guardSandbox().
// Delegated to ensureSandboxAgentProfile so the persona lives in one
// place (this seed, the dashboard/chat layout backfill, and the seed
// top-up path all use the same helper).
await ensureSandboxAgentProfile(supabase, companyId)
// 16. Inbox item backing the pre-staged supplier-invoice approval below.
// commitCreateSupplierInvoiceFromInbox does an idempotency + FK lookup
// against invoice_inbox_items by inbox_item_id before it creates anything,
// so the "Godkänn" path can only succeed if a real inbox row exists.
// status is constrained to 'received' | 'processing' | 'error' (migration
// 20260813213000); seeded rows are always 'received'.
const { data: inboxRow, error: inboxError } = await supabase
.from('invoice_inbox_items')
.insert({
user_id: userId,
company_id: companyId,
status: 'received',
source: 'upload',
matched_supplier_id: supplierMap['Demokafé AB'],
extracted_data: {
supplier: { name: 'Demokafé AB' },
invoice: {
invoiceNumber: 'INKOMMANDE-2026-001',
invoiceDate: toDateStr(fiveDaysAgo),
dueDate: toDateStr(sevenDaysFromNow),
currency: 'SEK',
vatTreatment: 'reduced_12',
},
totals: { subtotal: 240, vat: 28.80, total: 268.80 },
lineItems: [
{
description: 'Kundmöte Demokafé (representation)',
quantity: 1,
unit: 'st',
unit_price: 240,
line_total: 240,
account_number: '5810',
vat_rate: 12,
vat_amount: 28.80,
},
],
},
})
.select('id')
.single()
if (inboxError) throw inboxError
// 17. Pre-staged pending_operations so /pending isn't empty. Both the
// executor-complete params and the per-type preview_data shapes live in
// ./pending-operations, where they are unit-testable.
const { error: pendOpsError } = await supabase
.from('pending_operations')
.insert(
buildSandboxPendingOperations({
userId,
companyId,
inboxItemId: inboxRow.id,
supplierId: supplierMap['Demokafé AB'],
invoiceDate: toDateStr(fiveDaysAgo),
dueDate: toDateStr(sevenDaysFromNow),
transactionId: txMap['INSÄTTNING BANKGIRO'],
}),
)
if (pendOpsError) throw pendOpsError
// 18. Artikelregister, so /articles shows reusable invoice-line presets
// instead of the Package empty state.
const { error: articlesError } = await supabase
.from('articles')
.insert(buildSandboxArticles({ userId, companyId }))
if (articlesError) throw articlesError
// 20. Payroll. An enskild firma may employ staff (it just may not put its
// own owner on payroll), so the demo runs two employees through one booked
// and one open lönekörning.
const { data: employeeRows, error: employeesError } = await supabase
.from('employees')
.insert(
buildSandboxEmployees({
userId,
companyId,
today,
// employees.personnummer stores AES-256-GCM ciphertext; the builder
// stays pure by taking the cipher as an argument.
encrypt: encryptPersonnummer,
}),
)
.select('id, last_name')
if (employeesError) throw employeesError
const { annaEmployeeId, erikEmployeeId } = mapSandboxEmployeeIds(employeeRows)
const { data: salaryRunRows, error: salaryRunsError } = await supabase
.from('salary_runs')
.insert(buildSandboxSalaryRuns({ userId, companyId, today }))
.select('id, status')
if (salaryRunsError) throw salaryRunsError
const bookedRun = salaryRunRows.find(r => r.status === 'booked')
const draftRun = salaryRunRows.find(r => r.status === 'draft')
if (!bookedRun || !draftRun) {
throw new Error('Sandbox seed: expected one booked and one draft salary run')
}
const { data: runEmployeeRows, error: runEmployeesError } = await supabase
.from('salary_run_employees')
.insert(
buildSandboxSalaryRunEmployees({
companyId,
today,
bookedRunId: bookedRun.id,
draftRunId: draftRun.id,
annaEmployeeId,
erikEmployeeId,
}),
)
.select('id, employee_id')
if (runEmployeesError) throw runEmployeesError
const { error: salaryLineItemsError } = await supabase
.from('salary_line_items')
.insert(
buildSandboxSalaryLineItems({
companyId,
annaEmployeeId,
erikEmployeeId,
runEmployees: runEmployeeRows,
}),
)
if (salaryLineItemsError) throw salaryLineItemsError
// 21. Verifikat for the BOOKED run. A run in status 'booked' that posted
// nothing would be a lie: the real path (bookPaidSalaryRun) always writes
// these through the engine before advancing the status. The seed inserts
// journal rows directly to avoid event emission, so ./salary-vouchers
// mirrors the engine's account structure instead.
const bookedPeriod = resolveSandboxSalaryPeriods(today).booked
const salaryVouchers = buildSandboxSalaryVouchers({
userId,
companyId,
fiscalPeriodId: fiscalPeriod.id,
salaryRunId: bookedRun.id,
paymentDate: bookedPeriod.paymentDate,
periodYear: bookedPeriod.year,
periodMonth: bookedPeriod.month,
totalGross: SANDBOX_RUN_TOTALS.total_gross,
totalTax: SANDBOX_RUN_TOTALS.total_tax,
totalNet: SANDBOX_RUN_TOTALS.total_net,
totalAvgifter: SANDBOX_RUN_TOTALS.total_avgifter,
totalVacationAccrual: SANDBOX_RUN_TOTALS.total_vacation_accrual,
// salary_runs has no column for avgifter on the vacation accrual (it is
// a per-employee figure), so ./salary exports the sum of the same
// figures the salary_run_employees rows were written from.
totalVacationAvgifter: SANDBOX_TOTAL_VACATION_ACCRUAL_AVGIFTER,
})
const runEntryLinks: Record<string, string> = {}
for (const voucher of salaryVouchers) {
const { data: salaryVoucherNumber, error: salaryVoucherError } = await supabase.rpc(
'next_voucher_number',
{
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriod.id,
p_series: voucher.entry.voucher_series,
},
)
// A posted verifikat with no voucher number is a hole in the
// verifikationsserie (BFNAR 2013:2), so a failed counter read has to stop
// the seed rather than insert one.
if (salaryVoucherError) throw salaryVoucherError
if (salaryVoucherNumber == null) {
throw new Error('Sandbox seed: next_voucher_number returned no number for a salary voucher')
}
const { data: insertedSalaryEntry, error: salaryEntryError } = await supabase
.from('journal_entries')
// Draft until the lines exist; see the ledger-history comment above.
.insert({ ...voucher.entry, voucher_number: salaryVoucherNumber, status: 'draft' })
.select('id')
.single()
if (salaryEntryError) throw salaryEntryError
const { error: salaryEntryLinesError } = await supabase
.from('journal_entry_lines')
.insert(
voucher.lines.map(line => ({
...line,
account_id: accountMap[line.account_number] ?? null,
journal_entry_id: insertedSalaryEntry.id,
})),
)
if (salaryEntryLinesError) throw salaryEntryLinesError
runEntryLinks[voucher.runColumn] = insertedSalaryEntry.id
}
const { error: salaryPostError } = await supabase
.from('journal_entries')
.update({ status: 'posted' })
.in('id', Object.values(runEntryLinks))
.eq('company_id', companyId)
if (salaryPostError) throw salaryPostError
const { error: linkRunError } = await supabase
.from('salary_runs')
.update(runEntryLinks)
.eq('id', bookedRun.id)
.eq('company_id', companyId)
if (linkRunError) throw linkRunError
return NextResponse.json({ seeded: true })
} catch (err) {
log.error('failed to seed sandbox data', { error: err, userId: user.id, companyId })
return NextResponse.json(
{ error: 'Failed to seed sandbox data', requestId },
{ status: 500 }
)
}
}
/**
* Idempotent top-up for sandboxes that pre-date the agent_profile addition
* to the seed. Re-running the seed on those older sandboxes short-circuits
* at the company_settings idempotency check above, so they never get the
* agent_profile without this hook. Delegates to ensureSandboxAgentProfile
* so the profile data stays in exactly one place.
*
* Deliberately NOT extended to the payroll and ledger-history additions: those
* are one correlated dataset (a chart of accounts, a year of vouchers, a
* roster, two runs and their verifikat) that cannot be half-applied coherently,
* and a partial top-up would produce a booked lönekörning whose verifikat
* numbering interleaves with vouchers that already exist. Sandboxes are deleted
* after 24 hours, so the window where this matters closes on its own; a visitor
* who wants the payroll demo starts a new sandbox.
*/
async function topUpSandboxAdditions(
supabase: SupabaseClient,
companyId: string,
): Promise<void> {
await ensureSandboxAgentProfile(supabase, companyId)
}