* feat(mcp): tool titles + Connect-with-Claude docs for Connectors Directory readiness Add a top-level Tool.title (MCP spec 2025-06-18) to all 92 MCP tools and flow it through the tools/list serializer for the Claude Connectors Directory. Trim the ~10 longest tool descriptions toward 180-200 chars (semantics + agent hints preserved) to partly offset the added bytes, and raise the payload-size bench ceiling 32K → 36K with headroom reserved for upcoming Skatteverket tools. Ship a "Connect with Claude" docs page (OAuth 2.1 connector + npx gnubok-mcp stdio bridge, sample sandbox prompts, 10-minute reviewer test, support pointer), wire it into the docs nav, and add a README to the packages/gnubok-mcp npm bridge. Bump one categorized sandbox business expense above 4 000 kr so the VAT close-check demo surfaces a high-value receipt-less expense. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): restore six spaces lost in the description-trimming pass Review finding (Greptile P2): the trim deleted single spaces in six tool descriptions ("Stages foruser", "länkatill", "kundfordran(1510)", …), producing malformed text in tools/list. All six were pure space deletions with no token gain — restored verbatim. Swept all 92 descriptions for residual merge artifacts; the two remaining matches are pre-existing function-call notation from main. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(compliance): correct BFL citation in seed + mark 4000 kr as app heuristic Swedish compliance review findings: - The seed comment cited BFL 5 kap 6§ as requiring verifikat on high-value affärshändelser; 6§ governs verifikationens innehåll. Rephrased: BFL 5 kap 6-7§ require every affärshändelse to be documented — the 4 000 kr cut-off is the tool's own heuristic, not a statutory threshold. - The connect-claude docs read as though 4 000 kr were a legal floor (conflatable with the förenklad faktura ceiling, ML 17 kap 26-28§). Clarified that BFL requires underlag regardless of amount. Dismissed as by-design: the gnubok_sk_test_... README placeholders (standard practice; the prefix schema is already public in llms.txt). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
952 lines
33 KiB
TypeScript
952 lines
33 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'
|
||
|
||
// 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,
|
||
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 { 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 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. 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.
|
||
// 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. Each
|
||
// params blob must be executor-complete — the commit executors in
|
||
// lib/pending-operations/commit.ts validate required fields on "Godkänn",
|
||
// so a display-only preview with a hollow params object fails to save.
|
||
// 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)',
|
||
// Mirrors what gnubok_create_supplier_invoice_from_inbox would stage:
|
||
// every field commitCreateSupplierInvoiceFromInbox requires
|
||
// (inbox_item_id, supplier_id, supplier_invoice_number, invoice_date,
|
||
// finite subtotal/vat_amount/total, and a non-empty items array).
|
||
params: {
|
||
inbox_item_id: inboxRow.id,
|
||
supplier_id: supplierMap['Demokafé AB'],
|
||
document_id: null,
|
||
supplier_invoice_number: 'INKOMMANDE-2026-001',
|
||
invoice_date: toDateStr(fiveDaysAgo),
|
||
due_date: toDateStr(sevenDaysFromNow),
|
||
currency: 'SEK',
|
||
exchange_rate: null,
|
||
vat_treatment: 'reduced_12',
|
||
subtotal: 240,
|
||
vat_amount: 28.80,
|
||
total: 268.80,
|
||
notes: 'Representation – kundmöte (demo)',
|
||
items: [
|
||
{
|
||
line_number: 1,
|
||
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,
|
||
},
|
||
],
|
||
},
|
||
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',
|
||
// commitCategorizeTransaction needs a real uncategorized
|
||
// transaction_id + a category that resolves to an account mapping.
|
||
// income_services → 3001 (Försäljning tjänster 25%), matching the
|
||
// preview's 1930 / 2611 / 3001 split for the 1 200 kr deposit.
|
||
params: {
|
||
transaction_id: txMap['INSÄTTNING BANKGIRO'],
|
||
category: 'income_services',
|
||
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)
|
||
}
|