* fix(reconciliation): surface bank transactions via robust cash_account_id scoping The per-account reconciliation scoping silently returned zero transactions for companies whose rows were NULL or mis-assigned mid-backfill (e.g. Arcim: 138 transactions, 101 unbooked, yet Bankavstämning showed "0 kr" while the 1930 GL movement and a large difference still displayed). Two causes, both fixed: - scopeTransactionsToAccount used a fragile nested or(...,and(is.null,...)) PostgREST filter. Replace it with a flat, reliable `currency = X AND (cash_account_id = id OR cash_account_id IS NULL)` and share the one implementation with /api/transactions so the status card and the lists can never drift. - The original best-effort backfill only touched NULL rows and an earlier revision mis-assigned cash_account_id (the since-fixed min(uuid) bug), which migrations cannot self-correct. Add an idempotent repair migration that re-seeds the default 1930 account and re-derives cash_account_id (correcting non-NULL mis-assignments) for booked rows and single-account companies. Also localise manualLink's user-facing errors to Swedish. Adds unit coverage for the new filter shape and pg-real coverage for the repair (incl. the Arcim single-account reproduction). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(transactions): match a bank transaction to an existing verifikat Adds a "Matcha mot befintlig verifikation" action to the Transactions inbox so a bank line that is already booked elsewhere (a salary run, a Fortnox/manual voucher, an invoice paid from the invoice page) can be linked to that existing verifikat with no new bokföring — the capability previously lived only in Reports → Bankavstämning. - Extract the searchable MatchVerifikationPicker into a shared client component. - New MatchVoucherDialog: resolves the tx's cash account, fetches candidates ranked server-side by reconciliation confidence, links via /api/reconciliation/bank/link (so the link is undoable in Bankavstämning). - unmatched-entries route gains an optional transaction_id that ranks candidates (ranking stays server-side; the recon lib is not client-safe). - Inbox row's overflow (⋯) menu gains the new action. Also fixes the Bankavstämning view: editing the date no longer auto-reloads (applies on Filtrera / account change only) and Datum till defaults to today. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(transactions): actionable Swedish errors when a bank tx cannot be deleted The delete route returned a hardcoded English 409 that getErrorMessage mapped to the misleading generic "En konflikt uppstod. Ladda om sidan...". Return structured bilingual envelopes instead: - TRANSACTION_DELETE_BOOKED (409) for a booked/linked row — steers the user to unlink in Bankavstämning or storna the voucher. - TRANSACTION_DELETE_HAS_AUDIT_TRAIL (409) for the real, common case where an unbooked row carries payment_match_log rows: the cascade hits the audit-immutability trigger (P0001), previously surfaced as a bare 500. Steers the user to match-to-voucher or ignore instead. Updates the DELETE test suite and adds the P0001 case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(settings): surface DPA and privacy policy links The /dpa page (personuppgiftsbiträdesavtal, GDPR Art. 28) was complete but linked from nowhere. Add a "Sekretess och avtal" card on Inställningar → Konto linking to /privacy and /dpa, and a reciprocal link to the DPA from the privacy policy's sub-processor section. (The DPA already links back to /privacy.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(review): address PR #623 feedback - unmatched-entries: when transaction_id is supplied but resolves to no row in the caller's company, return an empty candidate list instead of silently falling back to the full unranked list (Compliance Swarm V8.2.1, high). - MatchVoucherDialog: preserve a manually-picked voucher when the candidate list reloads (e.g. "Visa alla datum") instead of discarding it (Greptile P2). - DELETE /api/transactions/[id]: return the 404 as the structured { error: { code, message, message_en } } envelope like the handler's other errors, for a uniform contract (Greptile P2). Test updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): allow editing notes on a committed journal entry Saving a note on a posted verifikation failed with "Committed entries are immutable": enforce_journal_entry_immutability() had no posted→posted path, so a notes-only UPDATE fell through to the final RAISE. `notes` is internal annotation metadata (not verifikation content under BFL 5 kap. / BFNAR 2013:2), so add a narrow carve-out that permits a notes-only change on a committed entry — verified with a whole-row to_jsonb() diff so any other field change still raises, and only when status is unchanged. Period-lock enforcement is unaffected. CREATE OR REPLACE in a new migration (same pattern as 20260428160000_fix_journal_entry_immutability_delete_bypass); the migration-017 protections are extended, never weakened. Covered by a pg-real test asserting a notes edit succeeds while amount/description/account edits still fail. (Already applied to production; committing the file + test for repo consistency.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sandbox): make pre-staged pending operations executor-complete The seeded pending_operations existed only as display previews — approving them failed because commit executors in lib/pending-operations/commit.ts validate required fields on "Godkänn". Seed a backing invoice_inbox_items row and fill the supplier-invoice and categorize params with every field the executors require (inbox_item_id, full items array; real uncategorized transaction_id + category), so the sandbox approval queue is actually approvable end to end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
940 lines
32 KiB
TypeScript
940 lines
32 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 { 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',
|
||
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,
|
||
},
|
||
])
|
||
.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',
|
||
document_type: 'supplier_invoice',
|
||
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)
|
||
}
|