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 { buildSandboxCustomers } from './customers' import { buildSandboxPendingOperations } from './pending-operations' // 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, }) 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 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 const { data: accounts } = await supabase .from('chart_of_accounts') .select('id, account_number') .eq('company_id', companyId) .in('account_number', ['1510', '1930', '2611', '3001']) const accountMap = Object.fromEntries( (accounts ?? []).map(a => [a.account_number, a.id]) ) // 9. Create journal entries (inserted directly, not via engine, to avoid event emission) const { data: voucherNum1 } = await supabase.rpc('next_voucher_number', { p_company_id: companyId, p_fiscal_period_id: fiscalPeriod.id, p_series: 'A', }) const { data: je1, error: je1Error } = await supabase .from('journal_entries') .insert({ user_id: userId, company_id: companyId, fiscal_period_id: fiscalPeriod.id, voucher_number: voucherNum1 ?? 1, voucher_series: 'A', entry_date: toDateStr(thirtyDaysAgo), description: 'Faktura F-2026001, Björk & Partner AB', source_type: 'invoice_created', source_id: invoiceMap['F-2026001'], status: 'posted', committed_at: toDateStr(thirtyDaysAgo), }) .select('id') .single() if (je1Error) throw je1Error const { data: voucherNum2 } = await supabase.rpc('next_voucher_number', { p_company_id: companyId, p_fiscal_period_id: fiscalPeriod.id, p_series: 'A', }) const { data: je2, error: je2Error } = await supabase .from('journal_entries') .insert({ user_id: userId, company_id: companyId, fiscal_period_id: fiscalPeriod.id, voucher_number: voucherNum2 ?? 2, voucher_series: 'A', entry_date: toDateStr(fifteenDaysAgo), description: 'Betalning faktura F-2026001, Björk & Partner AB', source_type: 'invoice_paid', source_id: invoiceMap['F-2026001'], status: 'posted', committed_at: toDateStr(fifteenDaysAgo), }) .select('id') .single() if (je2Error) throw je2Error // 10. Create journal entry lines. The P&L line carries demo dimensions // ({"1":"BUTIK","6":"P001"}) so the register's "antal taggade rader", // voucher-detail badges, and the dimension P&L report light up in the // sandbox. cost_center/project are GENERATED from the bag since the PR9 // cutover: writing them explicitly would error. const revenueDims = { '1': 'BUTIK', '6': 'P001' } const { error: jelError } = await supabase .from('journal_entry_lines') .insert([ // JE1: Invoice creation, Debit AR, Credit Revenue + VAT // NB: `dimensions` must be set explicitly on EVERY row: same PostgREST // bulk-insert normalization as paid_amount below: omitting it on some // rows while one row sets it sends null (violating NOT NULL) instead // of falling through to the schema default '{}'. { journal_entry_id: je1.id, account_number: '1510', account_id: accountMap['1510'] ?? null, debit_amount: 18750, credit_amount: 0, sort_order: 0, dimensions: {}, }, { journal_entry_id: je1.id, account_number: '3001', account_id: accountMap['3001'] ?? null, debit_amount: 0, credit_amount: 15000, sort_order: 1, dimensions: revenueDims, }, { journal_entry_id: je1.id, account_number: '2611', account_id: accountMap['2611'] ?? null, debit_amount: 0, credit_amount: 3750, sort_order: 2, dimensions: {}, }, // JE2: Invoice payment, Debit Bank, Credit AR { journal_entry_id: je2.id, account_number: '1930', account_id: accountMap['1930'] ?? null, debit_amount: 18750, credit_amount: 0, sort_order: 0, dimensions: {}, }, { journal_entry_id: je2.id, account_number: '1510', account_id: accountMap['1510'] ?? null, debit_amount: 0, credit_amount: 18750, sort_order: 1, dimensions: {}, }, ]) if (jelError) throw jelError // 11. Create transactions const { data: txRows, error: txError } = await supabase .from('transactions') .insert([ // Categorized expenses { user_id: userId, company_id: companyId, date: toDateStr(thirtyDaysAgo), description: 'CLAS OHLSON STOCKHOLM', amount: -450, category: 'expense_office', is_business: true, merchant_name: 'Clas Ohlson', }, { user_id: userId, company_id: companyId, date: toDateStr(fifteenDaysAgo), description: 'GITHUB INC', amount: -999, category: 'expense_software', is_business: true, merchant_name: 'GitHub', }, { user_id: userId, company_id: companyId, date: toDateStr(fiveDaysAgo), description: 'SJ BILJETT', // > 4 000 kr categorized business expense with no attached underlag, // so gnubok_vat_close_check surfaces a non-empty blocker list. // (BFL 5 kap 6-7§ require every affärshändelse to be documented with // underlag; the 4 000 kr cut-off is the tool's own high-value // heuristic, not a statutory threshold.) amount: -4500, category: 'expense_travel', is_business: true, merchant_name: 'SJ', }, // Income matched to paid invoice { user_id: userId, company_id: companyId, date: toDateStr(fifteenDaysAgo), description: 'BJÖRK & PARTNER AB BETALNING F-2026001', amount: 18750, category: 'income_services', is_business: true, invoice_id: invoiceMap['F-2026001'], journal_entry_id: je2.id, merchant_name: 'Björk & Partner AB', }, // Private transaction { user_id: userId, company_id: companyId, date: toDateStr(fiveDaysAgo), description: 'PRIVAT INSÄTTNING', amount: 5000, category: 'private', is_business: false, }, // Uncategorized transactions { user_id: userId, company_id: companyId, date: toDateStr(fiveDaysAgo), description: 'SWISH BETALNING 0701234567', amount: -350, category: 'uncategorized', is_business: null, }, { user_id: userId, company_id: companyId, date: toDateStr(today), description: 'INSÄTTNING BANKGIRO', amount: 1200, category: 'uncategorized', is_business: null, }, { user_id: userId, company_id: companyId, date: toDateStr(today), description: 'KORTBETALNING RESTAURANG', amount: -680, category: 'uncategorized', is_business: null, }, ]) .select('id, description') if (txError) throw txError // Lookup so the pre-staged categorize_transaction operation below can // reference a real, uncategorized transaction by id (descriptions are // unique in this seed set). const txMap = Object.fromEntries( (txRows ?? []).map(t => [t.description as string, t.id as string]) ) // 12. Create deadlines const momsDeadline = new Date(today) momsDeadline.setMonth(momsDeadline.getMonth() + 2) momsDeadline.setDate(12) const { error: dlError } = await supabase .from('deadlines') .insert([ { user_id: userId, company_id: companyId, title: `Momsdeklaration Q1 ${currentYear}`, due_date: toDateStr(momsDeadline), deadline_type: 'tax', priority: 'important', // Current generator types: the bare 'moms'/'inkomstdeklaration' // types were retired and seeding them recreates legacy rows the // cleanup migration removed. tax_deadline_type: 'moms_quarterly', tax_period: `${currentYear}-Q1`, source: 'system', status: 'upcoming', linked_report_type: 'vat', }, { user_id: userId, company_id: companyId, title: `Inkomstdeklaration ${currentYear - 1}`, due_date: `${currentYear}-05-02`, deadline_type: 'tax', priority: 'critical', // Sandbox companies are enskild firma (see p_entity_type above). tax_deadline_type: 'inkomstdeklaration_ef', tax_period: `${currentYear - 1}`, source: 'system', status: 'upcoming', }, ]) if (dlError) throw dlError // 13. Seed suppliers + one registered supplier invoice + one paid one. // Supplier invoices are arguably the second-most-used surface after // bank transactions; without them the /suppliers and /supplier-invoices // pages render the empty state and the demo loses a big chunk of the // accounts-payable story. // Supplier names use the "Demo" prefix and the documentation-reserved // 5559... org-number range so the seeded rows cannot be confused with // production data should they ever leak into a real environment. const { data: suppliers, error: supError } = await supabase .from('suppliers') .insert([ { user_id: userId, company_id: companyId, name: 'Demo Telekom AB', supplier_type: 'swedish_business', org_number: '5559000001', vat_number: 'SE555900000101', email: 'demo+telekom@example.com', bankgiro: '5559-0001', address_line1: 'Demovägen 10', postal_code: '111 22', city: 'Stockholm', country: 'SE', default_payment_terms: 30, }, { user_id: userId, company_id: companyId, name: 'Demokafé AB', supplier_type: 'swedish_business', org_number: '5559000002', vat_number: 'SE555900000201', bankgiro: '5559-0002', address_line1: 'Demovägen 11', postal_code: '111 22', city: 'Stockholm', country: 'SE', default_payment_terms: 15, }, ]) .select('id, name') if (supError) throw supError const supplierMap = Object.fromEntries(suppliers.map(s => [s.name, s.id])) // Supplier invoice #1, Telia, paid 15 days ago (mobile + bredband, 25% VAT). const sevenDaysFromNow = new Date(today) sevenDaysFromNow.setDate(today.getDate() + 7) // Hardcode 1 and 2: get_next_arrival_number is MAX+1 against the same // table we're about to insert into, so calling it twice before the first // insert lands gives the same value for both rows and violates the // (company_id, arrival_number) unique index. The company is brand new // here, so 1 and 2 are guaranteed to be free. const { data: supInvoices, error: supInvError } = await supabase .from('supplier_invoices') .insert([ { user_id: userId, company_id: companyId, supplier_id: supplierMap['Demo Telekom AB'], arrival_number: 1, supplier_invoice_number: '4711-2026-03', invoice_date: toDateStr(thirtyDaysAgo), due_date: toDateStr(today), received_date: toDateStr(thirtyDaysAgo), status: 'paid', currency: 'SEK', subtotal: 480, vat_amount: 120, total: 600, payment_reference: '47112026031', paid_at: toDateStr(fifteenDaysAgo), paid_amount: 600, }, { user_id: userId, company_id: companyId, supplier_id: supplierMap['Demokafé AB'], arrival_number: 2, supplier_invoice_number: '88245', invoice_date: toDateStr(fiveDaysAgo), due_date: toDateStr(sevenDaysFromNow), received_date: toDateStr(fiveDaysAgo), status: 'registered', currency: 'SEK', subtotal: 240, vat_amount: 28.80, total: 268.80, // Must be set explicitly: PostgREST normalizes columns across // rows in a bulk insert, so omitting paid_amount here while the // first row sets it sends null instead of falling through to the // schema default (0), violating the NOT NULL constraint. paid_amount: 0, }, ]) .select('id, supplier_invoice_number') if (supInvError) throw supInvError const supInvoiceMap = Object.fromEntries( supInvoices.map(s => [s.supplier_invoice_number, s.id]) ) // Supplier invoice line items. Note: supplier_invoice_items.vat_rate is // stored as a decimal (0.25 = 25%); invoice_items.vat_rate above uses // integer percent (25). Two different conventions inherited from earlier // migrations: don't try to "fix" it here. const { error: supItemsError } = await supabase .from('supplier_invoice_items') .insert([ { supplier_invoice_id: supInvoiceMap['4711-2026-03'], description: 'Mobil + bredband, mars', quantity: 1, unit_price: 480, line_total: 480, vat_rate: 0.25, vat_amount: 120, account_number: '6212', }, { supplier_invoice_id: supInvoiceMap['88245'], description: 'Kundmöte Demokafé (representation)', quantity: 1, unit_price: 240, line_total: 240, vat_rate: 0.12, vat_amount: 28.80, account_number: '5810', }, ]) if (supItemsError) throw supItemsError // 14. Add one fully-depreciable asset (laptop) so /assets shows // something other than a Package empty state. Acquired 18 months ago, // 60-month linear depreciation. Cost set above the 2026 // förbrukningsinventarier threshold (half prisbasbelopp ≈ 29 600 SEK) // so the demo unambiguously illustrates capitalization rather than // direct expensing. const eighteenMonthsAgo = new Date(today) eighteenMonthsAgo.setMonth(today.getMonth() - 18) const { error: assetError } = await supabase .from('assets') .insert({ user_id: userId, company_id: companyId, name: 'Demo-laptop', category: 'computer', acquisition_date: toDateStr(eighteenMonthsAgo), acquisition_cost: 35000, salvage_value: 0, useful_life_months: 60, depreciation_method: 'linear', bas_asset_account: '1250', bas_accumulated_account: '1259', bas_expense_account: '7831', notes: 'Demo-tillgång: visar planenlig avskrivning över 5 år.', }) if (assetError) throw assetError // 15. Pre-built, verified agent_profile so the assistant chrome (FAB, // /chat surface, agent identity in nav) renders without firing a // composer run. The chat itself is server-gated by guardSandbox(). // Delegated to ensureSandboxAgentProfile so the persona lives in one // place (this seed, the dashboard/chat layout backfill, and the seed // top-up path all use the same helper). await ensureSandboxAgentProfile(supabase, companyId) // 16. Inbox item backing the pre-staged supplier-invoice approval below. // commitCreateSupplierInvoiceFromInbox does an idempotency + FK lookup // against invoice_inbox_items by inbox_item_id before it creates anything, // so the "Godkänn" path can only succeed if a real inbox row exists. // status is constrained to 'received' | 'error' (migration 20260504180000). const { data: inboxRow, error: inboxError } = await supabase .from('invoice_inbox_items') .insert({ user_id: userId, company_id: companyId, status: 'received', source: 'upload', matched_supplier_id: supplierMap['Demokafé AB'], extracted_data: { supplier: { name: 'Demokafé AB' }, invoice: { invoiceNumber: 'INKOMMANDE-2026-001', invoiceDate: toDateStr(fiveDaysAgo), dueDate: toDateStr(sevenDaysFromNow), currency: 'SEK', vatTreatment: 'reduced_12', }, totals: { subtotal: 240, vat: 28.80, total: 268.80 }, lineItems: [ { description: 'Kundmöte Demokafé (representation)', quantity: 1, unit: 'st', unit_price: 240, line_total: 240, account_number: '5810', vat_rate: 12, vat_amount: 28.80, }, ], }, }) .select('id') .single() if (inboxError) throw inboxError // 17. Pre-staged pending_operations so /pending isn't empty. Both the // executor-complete params and the per-type preview_data shapes live in // ./pending-operations, where they are unit-testable. const { error: pendOpsError } = await supabase .from('pending_operations') .insert( buildSandboxPendingOperations({ userId, companyId, inboxItemId: inboxRow.id, supplierId: supplierMap['Demokafé AB'], invoiceDate: toDateStr(fiveDaysAgo), dueDate: toDateStr(sevenDaysFromNow), transactionId: txMap['INSÄTTNING BANKGIRO'], }), ) if (pendOpsError) throw pendOpsError 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. */ async function topUpSandboxAdditions( supabase: SupabaseClient, companyId: string, ): Promise { await ensureSandboxAgentProfile(supabase, companyId) }