* feat(sandbox,branding): prod-parity demo with AI gating + accounted rebrand Sandbox now ships with seeded suppliers, supplier invoices, an asset, a verified agent_profile, and pending operations so the demo company exercises every prod surface. Server-side `guardSandbox()` short- circuits any AI or paid-external API call (Bedrock chat/composer, Resend invoice send, Riksbanken FX, VIES, etc.) and the AgentSheet swaps in a SandboxAgentPreview that explains what's gated and offers a register CTA. DashboardContent no longer mounts the NewUserChecklist when the agent is already built, fixing the path that let sandbox users still trigger /onboarding/agent. Visible branding flips from Gnubok to Accounted: new BrandWordmark component (Hedvig Letters Serif 700), new app/icon.png + PWA icons generated from the accounted icon, default appName updated. URLs, header names, API key prefixes, hostnames, and event/cookie/ localStorage keys keep `gnubok` — the rebrand is visual only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sandbox): hardcode supplier-invoice arrival numbers in seed get_next_arrival_number is MAX(arrival_number) + 1 against the same table we're about to insert into. Calling it twice before either row lands made both calls return 1, which then violated the (company_id, arrival_number) unique index — POST /api/sandbox/seed 500'd on first sandbox start. The seeded company is brand new in this branch so 1 and 2 are guaranteed unused; hardcoding side-steps the race entirely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sandbox): set paid_amount=0 on unpaid supplier invoice PostgREST normalizes the column set across rows in a bulk insert, so the second supplier invoice (Espresso House, status=registered) was being sent with paid_amount=null because the first row (Telia, paid) set it. supplier_invoices.paid_amount is NOT NULL DEFAULT 0; the default only kicks in when the column is *absent* from the payload, not when it's explicitly null. Set it inline to side-step the normalization. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sandbox): set actor_type=agent_chat on seeded pending_operations pending_operations only allows user-scoped INSERTs via the `pending_operations_chat_insert` policy, which requires actor_type='agent_chat' alongside auth.uid()=user_id + company membership. The seed was inserting with the default actor_type='user', tripping the RLS check. Also lift risk_level from preview_data (where it was unused) onto the row itself, matching the column added in 20260430120000_pending_operations_actor_and_risk. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr-review): address PR #585 review feedback Fixes called out by the core-only CI check, Greptile, and the compliance + Swedish-accounting bots: - AGI Programnamn pinned back to 'gnubok' (CI blocker). The XML Skatteverket receives must keep the stable software identifier regardless of the visual rebrand — same rule as the v1 health endpoint's `service: 'gnubok'` literal. - handleCreateAccount in SandboxAgentPreview + ChatEmptyState now wraps signOut() in try/catch so a transient Supabase failure doesn't strand the user on a dead button (greptile P2 × 2). - /api/currency/rate hard-fails on missing companyId instead of conditionally skipping the sandbox guard (greptile P2 / compliance V8.2.1). - topUpSandboxAdditions now delegates to ensureSandboxAgentProfile; the assistant persona lives in exactly one place across the seed, layout backfills, and top-up path (greptile P2 outside-diff / compliance SOC2 CC6.1). - ensureSandboxAgentProfile drops the userId param and sets verified_by_user_id to NULL — synthetic seed data should not attribute verification to a real user (compliance V8.2.1 / GDPR Art. 25(2)). Errors now logged via the structured logger instead of being silently swallowed (V16). - Sandbox seed swaps real-world company names (Telia, Espresso House) for clearly-synthetic Demo-prefixed brands using the 5559... documentation org-number range (compliance A.8.33). Asset cost bumped 24 000 → 35 000 SEK so the demo clears the förbrukningsinventarier threshold and illustrates capitalization unambiguously (swedish-asset-accounting). - Representation pending-operation preview corrected: VAT label fixed from 6% → 12%, and input VAT split between the avdragsgill (2641) and ej-avdragsgill (5811) portions to match swedish-vat / ML 8 kap rules (swedish-vat). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr-review): seed preview consistency + AGI Programnamn constant Two last review-bot items before merge: - Sandbox seed: the representation pending-operation preview was splitting the 240 SEK café meal 60/180 between 5810 and 5811, which is wrong for a single attendee under the 300 SEK / person avdragsgill cap (ML 8 kap) — the entire amount is fully avdragsgill in that case. Collapse the preview to a single 5810 + 2641 + 2440 entry so it matches the supplier_invoice_items row 1:1 and stops teaching demo users an incorrect bookkeeping pattern. - Hoist the AGI Programnamn 'gnubok' literal into a named constant with a comment pointing to potential future Skatteverket vendor registration (per the swedish-compliance bot's nit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sandbox): avoid BFL duplicate-verification on pending op + fix VAT cap comment Swedish-compliance bot caught two final nits: - The pending operation for the Demokafé representation was using the same supplier_invoice_number as the already-seeded supplier_invoices row (88245). If the sandbox user approved the staged operation, the insert would have created (or attempted) a duplicate verification — BFL 5 kap. requires each affärshändelse be recorded exactly once. Swap the staged operation's invoice number to a distinct value (INKOMMANDE-2026-001) so approval cleanly creates a new row. - The preview comment described the 300 SEK threshold as an "avdragsgill cap". The actual rule (ML 8 kap. 9 §) caps the deductible VAT at 25 % × 300 SEK × antal_personer = 75 SEK per person — the 300 SEK is the tax base, not the total. Math here is correct either way, but the comment now states the correct formula so future seed edits don't propagate the wrong understanding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
859 lines
29 KiB
TypeScript
859 lines
29 KiB
TypeScript
import crypto from 'crypto'
|
||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||
import { createClient } from '@/lib/supabase/server'
|
||
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'
|
||
|
||
// 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!
|
||
|
||
const supabase = await createClient()
|
||
const { data: { user } } = await supabase.auth.getUser()
|
||
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized', requestId }, { status: 401 })
|
||
}
|
||
|
||
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,
|
||
is_sandbox: 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
|
||
|
||
// 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([
|
||
{
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
name: 'Björk & Partner AB',
|
||
customer_type: 'swedish_business',
|
||
email: 'faktura@bjorkpartner.se',
|
||
org_number: '5566778899',
|
||
vat_number: 'SE556677889901',
|
||
vat_number_validated: true,
|
||
address_line1: 'Storgatan 10',
|
||
postal_code: '111 44',
|
||
city: 'Stockholm',
|
||
country: 'SE',
|
||
default_payment_terms: 30,
|
||
},
|
||
{
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
name: 'Schmidt GmbH',
|
||
customer_type: 'eu_business',
|
||
email: 'billing@schmidt.de',
|
||
org_number: 'HRB 12345',
|
||
vat_number: 'DE123456789',
|
||
vat_number_validated: true,
|
||
address_line1: 'Hauptstraße 5',
|
||
postal_code: '10115',
|
||
city: 'Berlin',
|
||
country: 'DE',
|
||
default_payment_terms: 30,
|
||
},
|
||
{
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
name: 'Anna Lindström',
|
||
customer_type: 'individual',
|
||
email: 'anna.lindstrom@example.com',
|
||
address_line1: 'Lillgatan 3',
|
||
postal_code: '222 33',
|
||
city: 'Malmö',
|
||
country: 'SE',
|
||
default_payment_terms: 30,
|
||
},
|
||
])
|
||
.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
|
||
const { error: jelError } = await supabase
|
||
.from('journal_entry_lines')
|
||
.insert([
|
||
// JE1: Invoice creation — Debit AR, Credit Revenue + VAT
|
||
{
|
||
journal_entry_id: je1.id,
|
||
account_number: '1510',
|
||
account_id: accountMap['1510'] ?? null,
|
||
debit_amount: 18750,
|
||
credit_amount: 0,
|
||
sort_order: 0,
|
||
},
|
||
{
|
||
journal_entry_id: je1.id,
|
||
account_number: '3001',
|
||
account_id: accountMap['3001'] ?? null,
|
||
debit_amount: 0,
|
||
credit_amount: 15000,
|
||
sort_order: 1,
|
||
},
|
||
{
|
||
journal_entry_id: je1.id,
|
||
account_number: '2611',
|
||
account_id: accountMap['2611'] ?? null,
|
||
debit_amount: 0,
|
||
credit_amount: 3750,
|
||
sort_order: 2,
|
||
},
|
||
// 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,
|
||
},
|
||
{
|
||
journal_entry_id: je2.id,
|
||
account_number: '1510',
|
||
account_id: accountMap['1510'] ?? null,
|
||
debit_amount: 0,
|
||
credit_amount: 18750,
|
||
sort_order: 1,
|
||
},
|
||
])
|
||
|
||
if (jelError) throw jelError
|
||
|
||
// 11. Create transactions
|
||
const { 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',
|
||
amount: -2500,
|
||
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,
|
||
},
|
||
])
|
||
|
||
if (txError) throw txError
|
||
|
||
// 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 2026',
|
||
due_date: toDateStr(momsDeadline),
|
||
deadline_type: 'tax',
|
||
priority: 'important',
|
||
tax_deadline_type: 'moms',
|
||
tax_period: `${currentYear}-Q1`,
|
||
source: 'system',
|
||
status: 'upcoming',
|
||
linked_report_type: 'vat',
|
||
},
|
||
{
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
title: 'Inkomstdeklaration 2025',
|
||
due_date: `${currentYear}-05-02`,
|
||
deadline_type: 'tax',
|
||
priority: 'critical',
|
||
tax_deadline_type: 'inkomstdeklaration',
|
||
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. Pre-staged pending_operations so /pending isn't empty.
|
||
// These are the kind of operation the AI agent would stage; pre-seeded
|
||
// here so the user can see the approval queue UI (preview, period
|
||
// status, risk level) without having to invoke the disabled AI.
|
||
// actor_type='agent_chat' + risk_level on the row itself is required by
|
||
// pending_operations_chat_insert (the only RLS policy that lets a
|
||
// user-scoped client INSERT into this table).
|
||
const { error: pendOpsError } = await supabase
|
||
.from('pending_operations')
|
||
.insert([
|
||
{
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
operation_type: 'create_supplier_invoice_from_inbox',
|
||
status: 'pending',
|
||
actor_type: 'agent_chat',
|
||
risk_level: 'low',
|
||
// Uses a distinct supplier_invoice_number so approving this
|
||
// pending operation creates a NEW supplier_invoices row instead
|
||
// of colliding with the Demokafé '88245' already booked above
|
||
// (BFL 5 kap — each affärshändelse must be recorded exactly once).
|
||
title: 'Registrera leverantörsfaktura — Demokafé (representation, nytt underlag)',
|
||
params: {
|
||
supplier_id: supplierMap['Demokafé AB'],
|
||
supplier_invoice_number: 'INKOMMANDE-2026-001',
|
||
invoice_date: toDateStr(fiveDaysAgo),
|
||
due_date: toDateStr(sevenDaysFromNow),
|
||
total: 268.80,
|
||
vat_amount: 28.80,
|
||
account_number: '5810',
|
||
},
|
||
preview_data: {
|
||
// Representation @ 12% VAT (café meal), 240 SEK excl. VAT for
|
||
// a single attendee. The avdragsrätt cap is 25% × 300 SEK ×
|
||
// antal_personer = 75 SEK / person (ML 8 kap. 9 §); since the
|
||
// VAT here is 28.80 SEK the full amount is deductible and the
|
||
// cost lands in 5810 — no split needed.
|
||
preview_lines: [
|
||
{ account: '5810', description: 'Representation (12% moms, ≤ 75 SEK moms/pers)', debit: 240, credit: 0 },
|
||
{ account: '2641', description: 'Ingående moms', debit: 28.80, credit: 0 },
|
||
{ account: '2440', description: 'Leverantörsskulder', debit: 0, credit: 268.80 },
|
||
],
|
||
},
|
||
},
|
||
{
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
operation_type: 'categorize_transaction',
|
||
status: 'pending',
|
||
actor_type: 'agent_chat',
|
||
risk_level: 'low',
|
||
title: 'Bokför insättning — bankgiro',
|
||
params: {
|
||
account_number: '3001',
|
||
is_business: true,
|
||
vat_treatment: 'standard_25',
|
||
},
|
||
preview_data: {
|
||
preview_lines: [
|
||
{ account: '1930', description: 'Företagskonto', debit: 1200, credit: 0 },
|
||
{ account: '2611', description: 'Utgående moms 25%', debit: 0, credit: 240 },
|
||
{ account: '3001', description: 'Försäljning 25% moms', debit: 0, credit: 960 },
|
||
],
|
||
},
|
||
},
|
||
])
|
||
|
||
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<void> {
|
||
await ensureSandboxAgentProfile(supabase, companyId)
|
||
}
|