ec27228a8e
Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
555 lines
18 KiB
JavaScript
555 lines
18 KiB
JavaScript
/**
|
|
* Seed script: populate data for export extensions
|
|
*
|
|
* Creates EU customers, foreign-currency invoices, and journal entries
|
|
* so that all 4 export extensions (EU Sales List, VAT Monitor, Intrastat,
|
|
* Currency Receivables) have data to display.
|
|
*
|
|
* Usage: node scripts/seed-export-data.mjs
|
|
*/
|
|
|
|
import { createClient } from '@supabase/supabase-js'
|
|
import 'dotenv/config'
|
|
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
|
|
if (!supabaseUrl || !serviceRoleKey) {
|
|
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env')
|
|
process.exit(1)
|
|
}
|
|
|
|
const supabase = createClient(supabaseUrl, serviceRoleKey)
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────
|
|
|
|
function round2(n) {
|
|
return Math.round(n * 100) / 100
|
|
}
|
|
|
|
function randomId() {
|
|
return crypto.randomUUID()
|
|
}
|
|
|
|
function today() {
|
|
return new Date().toISOString().split('T')[0]
|
|
}
|
|
|
|
function daysAgo(n) {
|
|
const d = new Date()
|
|
d.setDate(d.getDate() - n)
|
|
return d.toISOString().split('T')[0]
|
|
}
|
|
|
|
function daysFromNow(n) {
|
|
const d = new Date()
|
|
d.setDate(d.getDate() + n)
|
|
return d.toISOString().split('T')[0]
|
|
}
|
|
|
|
// ── Main ────────────────────────────────────────────────────
|
|
|
|
async function main() {
|
|
// 1. Find the user
|
|
const { data: { users }, error: usersError } = await supabase.auth.admin.listUsers()
|
|
if (usersError) {
|
|
console.error('Failed to list users:', usersError.message)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (users.length === 0) {
|
|
console.error('No users found. Please sign up first.')
|
|
process.exit(1)
|
|
}
|
|
|
|
const user = users[0]
|
|
const userId = user.id
|
|
console.log(`Using user: ${user.email} (${userId})`)
|
|
|
|
// 2. Ensure company settings exist
|
|
const { data: company, error: companyError } = await supabase
|
|
.from('company_settings')
|
|
.select('*')
|
|
.eq('user_id', userId)
|
|
.single()
|
|
|
|
if (companyError || !company) {
|
|
console.error('No company_settings found. Complete onboarding first.')
|
|
process.exit(1)
|
|
}
|
|
|
|
console.log(`Company: ${company.company_name || '(unnamed)'}`)
|
|
|
|
// 3. Ensure fiscal period exists for current year
|
|
const year = new Date().getFullYear()
|
|
const periodStart = `${year}-01-01`
|
|
const periodEnd = `${year}-12-31`
|
|
|
|
let { data: fiscalPeriod } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('*')
|
|
.eq('user_id', userId)
|
|
.lte('period_start', today())
|
|
.gte('period_end', today())
|
|
.limit(1)
|
|
.single()
|
|
|
|
if (!fiscalPeriod) {
|
|
console.log(`Creating fiscal period for ${year}...`)
|
|
const { data: newPeriod, error: periodError } = await supabase
|
|
.from('fiscal_periods')
|
|
.insert({
|
|
id: randomId(),
|
|
user_id: userId,
|
|
name: `Räkenskapsår ${year}`,
|
|
period_start: periodStart,
|
|
period_end: periodEnd,
|
|
is_closed: false,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (periodError) {
|
|
console.error('Failed to create fiscal period:', periodError.message)
|
|
process.exit(1)
|
|
}
|
|
fiscalPeriod = newPeriod
|
|
}
|
|
|
|
console.log(`Fiscal period: ${fiscalPeriod.name} (${fiscalPeriod.period_start} to ${fiscalPeriod.period_end})`)
|
|
|
|
// 4. Create EU customers
|
|
const customers = [
|
|
{
|
|
id: randomId(),
|
|
user_id: userId,
|
|
name: 'TechHaus GmbH',
|
|
customer_type: 'eu_business',
|
|
email: 'billing@techhaus.de',
|
|
country: 'Germany',
|
|
org_number: 'HRB 12345',
|
|
vat_number: 'DE123456789',
|
|
vat_number_validated: true,
|
|
default_payment_terms: 30,
|
|
address_line1: 'Friedrichstraße 42',
|
|
postal_code: '10117',
|
|
city: 'Berlin',
|
|
},
|
|
{
|
|
id: randomId(),
|
|
user_id: userId,
|
|
name: 'Suomen Softworks Oy',
|
|
customer_type: 'eu_business',
|
|
email: 'invoices@suomensoftworks.fi',
|
|
country: 'Finland',
|
|
org_number: '1234567-8',
|
|
vat_number: 'FI12345678',
|
|
vat_number_validated: true,
|
|
default_payment_terms: 30,
|
|
address_line1: 'Mannerheimintie 10',
|
|
postal_code: '00100',
|
|
city: 'Helsinki',
|
|
},
|
|
{
|
|
id: randomId(),
|
|
user_id: userId,
|
|
name: 'Oranje Logistics B.V.',
|
|
customer_type: 'eu_business',
|
|
email: 'finance@oranjelogistics.nl',
|
|
country: 'Netherlands',
|
|
org_number: 'KvK 87654321',
|
|
vat_number: 'NL123456789B01',
|
|
vat_number_validated: true,
|
|
default_payment_terms: 14,
|
|
address_line1: 'Keizersgracht 120',
|
|
postal_code: '1015 AA',
|
|
city: 'Amsterdam',
|
|
},
|
|
]
|
|
|
|
console.log('\nCreating 3 EU customers...')
|
|
const { error: custError } = await supabase.from('customers').insert(customers)
|
|
if (custError) {
|
|
console.error('Failed to create customers:', custError.message)
|
|
process.exit(1)
|
|
}
|
|
for (const c of customers) {
|
|
console.log(` ✓ ${c.name} (${c.vat_number})`)
|
|
}
|
|
|
|
// 5. Create invoices
|
|
// Mix of: SEK reverse_charge, EUR reverse_charge, USD
|
|
const nextNum = company.next_invoice_number || 1
|
|
|
|
const invoices = [
|
|
// Invoice 1: SEK reverse_charge to German customer (for EU Sales List: goods)
|
|
{
|
|
id: randomId(),
|
|
user_id: userId,
|
|
customer_id: customers[0].id,
|
|
invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum).padStart(4, '0')}`,
|
|
invoice_date: daysAgo(20),
|
|
due_date: daysFromNow(10),
|
|
status: 'sent',
|
|
currency: 'SEK',
|
|
document_type: 'invoice',
|
|
vat_treatment: 'reverse_charge',
|
|
vat_rate: 0,
|
|
subtotal: 85000,
|
|
subtotal_sek: 85000,
|
|
vat_amount: 0,
|
|
vat_amount_sek: 0,
|
|
total: 85000,
|
|
total_sek: 85000,
|
|
exchange_rate: null,
|
|
moms_ruta: '35',
|
|
reverse_charge_text: 'Reverse charge: VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC',
|
|
},
|
|
// Invoice 2: EUR reverse_charge to Finnish customer (for EU Sales List: services + Currency Receivables)
|
|
{
|
|
id: randomId(),
|
|
user_id: userId,
|
|
customer_id: customers[1].id,
|
|
invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum + 1).padStart(4, '0')}`,
|
|
invoice_date: daysAgo(15),
|
|
due_date: daysFromNow(15),
|
|
status: 'sent',
|
|
currency: 'EUR',
|
|
document_type: 'invoice',
|
|
vat_treatment: 'reverse_charge',
|
|
vat_rate: 0,
|
|
subtotal: 5000,
|
|
subtotal_sek: 57500,
|
|
vat_amount: 0,
|
|
vat_amount_sek: 0,
|
|
total: 5000,
|
|
total_sek: 57500,
|
|
exchange_rate: 11.50,
|
|
exchange_rate_date: daysAgo(15),
|
|
moms_ruta: '39',
|
|
reverse_charge_text: 'Reverse charge: VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC',
|
|
},
|
|
// Invoice 3: EUR reverse_charge to Dutch customer: goods (for EU Sales List + Currency Receivables)
|
|
{
|
|
id: randomId(),
|
|
user_id: userId,
|
|
customer_id: customers[2].id,
|
|
invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum + 2).padStart(4, '0')}`,
|
|
invoice_date: daysAgo(10),
|
|
due_date: daysFromNow(20),
|
|
status: 'sent',
|
|
currency: 'EUR',
|
|
document_type: 'invoice',
|
|
vat_treatment: 'reverse_charge',
|
|
vat_rate: 0,
|
|
subtotal: 12000,
|
|
subtotal_sek: 138000,
|
|
vat_amount: 0,
|
|
vat_amount_sek: 0,
|
|
total: 12000,
|
|
total_sek: 138000,
|
|
exchange_rate: 11.50,
|
|
exchange_rate_date: daysAgo(10),
|
|
moms_ruta: '35',
|
|
reverse_charge_text: 'Reverse charge: VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC',
|
|
},
|
|
// Invoice 4: USD to Dutch customer: overdue (for Currency Receivables)
|
|
{
|
|
id: randomId(),
|
|
user_id: userId,
|
|
customer_id: customers[2].id,
|
|
invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum + 3).padStart(4, '0')}`,
|
|
invoice_date: daysAgo(45),
|
|
due_date: daysAgo(15),
|
|
status: 'overdue',
|
|
currency: 'USD',
|
|
document_type: 'invoice',
|
|
vat_treatment: 'reverse_charge',
|
|
vat_rate: 0,
|
|
subtotal: 8500,
|
|
subtotal_sek: 91800,
|
|
vat_amount: 0,
|
|
vat_amount_sek: 0,
|
|
total: 8500,
|
|
total_sek: 91800,
|
|
exchange_rate: 10.80,
|
|
exchange_rate_date: daysAgo(45),
|
|
moms_ruta: '35',
|
|
reverse_charge_text: 'Reverse charge: VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC',
|
|
},
|
|
// Invoice 5: Paid SEK reverse_charge to Finnish customer (for EU Sales List history)
|
|
{
|
|
id: randomId(),
|
|
user_id: userId,
|
|
customer_id: customers[1].id,
|
|
invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum + 4).padStart(4, '0')}`,
|
|
invoice_date: daysAgo(60),
|
|
due_date: daysAgo(30),
|
|
status: 'paid',
|
|
currency: 'SEK',
|
|
document_type: 'invoice',
|
|
vat_treatment: 'reverse_charge',
|
|
vat_rate: 0,
|
|
subtotal: 42000,
|
|
subtotal_sek: 42000,
|
|
vat_amount: 0,
|
|
vat_amount_sek: 0,
|
|
total: 42000,
|
|
total_sek: 42000,
|
|
exchange_rate: null,
|
|
moms_ruta: '39',
|
|
reverse_charge_text: 'Reverse charge: VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC',
|
|
},
|
|
]
|
|
|
|
console.log('\nCreating 5 invoices...')
|
|
const { error: invError } = await supabase.from('invoices').insert(invoices)
|
|
if (invError) {
|
|
console.error('Failed to create invoices:', invError.message)
|
|
process.exit(1)
|
|
}
|
|
|
|
for (const inv of invoices) {
|
|
const cust = customers.find(c => c.id === inv.customer_id)
|
|
console.log(` ✓ ${inv.invoice_number}, ${cust.name}, ${inv.currency} ${inv.total} (${inv.status})`)
|
|
}
|
|
|
|
// 6. Create invoice items
|
|
const invoiceItems = [
|
|
// Invoice 1 items (SEK goods to Germany)
|
|
{ id: randomId(), invoice_id: invoices[0].id, description: 'Industrial sensors batch', quantity: 50, unit: 'st', unit_price: 1200, line_total: 60000, sort_order: 1 },
|
|
{ id: randomId(), invoice_id: invoices[0].id, description: 'Installation & calibration', quantity: 10, unit: 'tim', unit_price: 2500, line_total: 25000, sort_order: 2 },
|
|
// Invoice 2 items (EUR services to Finland)
|
|
{ id: randomId(), invoice_id: invoices[1].id, description: 'Software consulting', quantity: 40, unit: 'tim', unit_price: 125, line_total: 5000, sort_order: 1 },
|
|
// Invoice 3 items (EUR goods to Netherlands)
|
|
{ id: randomId(), invoice_id: invoices[2].id, description: 'Steel components CN:72163100', quantity: 200, unit: 'st', unit_price: 45, line_total: 9000, sort_order: 1 },
|
|
{ id: randomId(), invoice_id: invoices[2].id, description: 'Aluminium fittings CN:76169990', quantity: 100, unit: 'st', unit_price: 30, line_total: 3000, sort_order: 2 },
|
|
// Invoice 4 items (USD goods to Netherlands)
|
|
{ id: randomId(), invoice_id: invoices[3].id, description: 'Custom machine parts', quantity: 25, unit: 'st', unit_price: 340, line_total: 8500, sort_order: 1 },
|
|
// Invoice 5 items (SEK services to Finland)
|
|
{ id: randomId(), invoice_id: invoices[4].id, description: 'IT architecture review', quantity: 24, unit: 'tim', unit_price: 1750, line_total: 42000, sort_order: 1 },
|
|
]
|
|
|
|
const { error: itemsError } = await supabase.from('invoice_items').insert(invoiceItems)
|
|
if (itemsError) {
|
|
console.error('Failed to create invoice items:', itemsError.message)
|
|
process.exit(1)
|
|
}
|
|
console.log(` ✓ ${invoiceItems.length} invoice items created`)
|
|
|
|
// Update next_invoice_number
|
|
await supabase
|
|
.from('company_settings')
|
|
.update({ next_invoice_number: nextNum + 5 })
|
|
.eq('user_id', userId)
|
|
|
|
// 7. Create journal entries for the invoices (reverse charge: debit 1510, credit 3305/3308)
|
|
// These are needed for VAT Monitor and EU Sales List cross-check
|
|
const journalEntries = []
|
|
const journalLines = []
|
|
|
|
// Get current max voucher number
|
|
const { data: maxVoucher } = await supabase
|
|
.from('journal_entries')
|
|
.select('voucher_number')
|
|
.eq('user_id', userId)
|
|
.order('voucher_number', { ascending: false })
|
|
.limit(1)
|
|
.single()
|
|
|
|
let voucherNum = (maxVoucher?.voucher_number || 0) + 1
|
|
|
|
for (const inv of invoices) {
|
|
const entryId = randomId()
|
|
const totalSEK = inv.total_sek
|
|
|
|
// Determine revenue account: goods = 3305, services = 3308
|
|
// moms_ruta 35 = goods, 39 = services
|
|
const revenueAccount = inv.moms_ruta === '35' ? '3305' : '3308'
|
|
|
|
journalEntries.push({
|
|
id: entryId,
|
|
user_id: userId,
|
|
fiscal_period_id: fiscalPeriod.id,
|
|
voucher_number: voucherNum++,
|
|
voucher_series: 'A',
|
|
entry_date: inv.invoice_date,
|
|
description: `Faktura ${inv.invoice_number}: ${customers.find(c => c.id === inv.customer_id).name}`,
|
|
source_type: 'invoice_created',
|
|
source_id: inv.id,
|
|
status: 'posted',
|
|
committed_at: new Date().toISOString(),
|
|
})
|
|
|
|
// Debit 1510 (accounts receivable)
|
|
journalLines.push({
|
|
id: randomId(),
|
|
journal_entry_id: entryId,
|
|
account_number: '1510',
|
|
debit_amount: round2(totalSEK),
|
|
credit_amount: 0,
|
|
currency: inv.currency,
|
|
amount_in_currency: inv.currency !== 'SEK' ? inv.total : null,
|
|
exchange_rate: inv.exchange_rate,
|
|
line_description: `Kundfordran ${inv.invoice_number}`,
|
|
sort_order: 1,
|
|
})
|
|
|
|
// Credit revenue account (3305 export goods or 3308 EU services)
|
|
journalLines.push({
|
|
id: randomId(),
|
|
journal_entry_id: entryId,
|
|
account_number: revenueAccount,
|
|
debit_amount: 0,
|
|
credit_amount: round2(totalSEK),
|
|
currency: 'SEK',
|
|
line_description: `Intäkt ${inv.invoice_number}`,
|
|
sort_order: 2,
|
|
})
|
|
}
|
|
|
|
// Add a payment entry for invoice 5 (paid): debit 1930, credit 1510
|
|
const paymentEntryId = randomId()
|
|
journalEntries.push({
|
|
id: paymentEntryId,
|
|
user_id: userId,
|
|
fiscal_period_id: fiscalPeriod.id,
|
|
voucher_number: voucherNum++,
|
|
voucher_series: 'A',
|
|
entry_date: daysAgo(25),
|
|
description: `Betalning ${invoices[4].invoice_number}: Suomen Softworks Oy`,
|
|
source_type: 'invoice_paid',
|
|
source_id: invoices[4].id,
|
|
status: 'posted',
|
|
committed_at: new Date().toISOString(),
|
|
})
|
|
|
|
journalLines.push({
|
|
id: randomId(),
|
|
journal_entry_id: paymentEntryId,
|
|
account_number: '1930',
|
|
debit_amount: 42000,
|
|
credit_amount: 0,
|
|
currency: 'SEK',
|
|
line_description: `Inbetalning ${invoices[4].invoice_number}`,
|
|
sort_order: 1,
|
|
})
|
|
|
|
journalLines.push({
|
|
id: randomId(),
|
|
journal_entry_id: paymentEntryId,
|
|
account_number: '1510',
|
|
debit_amount: 0,
|
|
credit_amount: 42000,
|
|
currency: 'SEK',
|
|
line_description: `Reglering ${invoices[4].invoice_number}`,
|
|
sort_order: 2,
|
|
})
|
|
|
|
// Add a small FX gain entry (for Currency Receivables realized FX)
|
|
const fxEntryId = randomId()
|
|
journalEntries.push({
|
|
id: fxEntryId,
|
|
user_id: userId,
|
|
fiscal_period_id: fiscalPeriod.id,
|
|
voucher_number: voucherNum++,
|
|
voucher_series: 'A',
|
|
entry_date: daysAgo(25),
|
|
description: 'Kursdifferens vid betalning',
|
|
source_type: 'invoice_paid',
|
|
status: 'posted',
|
|
committed_at: new Date().toISOString(),
|
|
})
|
|
|
|
journalLines.push({
|
|
id: randomId(),
|
|
journal_entry_id: fxEntryId,
|
|
account_number: '1930',
|
|
debit_amount: 450,
|
|
credit_amount: 0,
|
|
currency: 'SEK',
|
|
line_description: 'Valutavinst',
|
|
sort_order: 1,
|
|
})
|
|
|
|
journalLines.push({
|
|
id: randomId(),
|
|
journal_entry_id: fxEntryId,
|
|
account_number: '3960',
|
|
debit_amount: 0,
|
|
credit_amount: 450,
|
|
currency: 'SEK',
|
|
line_description: 'Valutakursvinst',
|
|
sort_order: 2,
|
|
})
|
|
|
|
console.log(`\nCreating ${journalEntries.length} journal entries...`)
|
|
const { error: jeError } = await supabase.from('journal_entries').insert(journalEntries)
|
|
if (jeError) {
|
|
console.error('Failed to create journal entries:', jeError.message)
|
|
process.exit(1)
|
|
}
|
|
|
|
const { error: jlError } = await supabase.from('journal_entry_lines').insert(journalLines)
|
|
if (jlError) {
|
|
console.error('Failed to create journal entry lines:', jlError.message)
|
|
console.error('Cleaning up journal entries...')
|
|
await supabase.from('journal_entries').delete().in('id', journalEntries.map(e => e.id))
|
|
process.exit(1)
|
|
}
|
|
|
|
for (const je of journalEntries) {
|
|
console.log(` ✓ A${je.voucher_number}: ${je.description}`)
|
|
}
|
|
|
|
// 8. Add Intrastat product metadata via extension_data
|
|
console.log('\nCreating Intrastat product registry...')
|
|
const extensionId = 'export/intrastat'
|
|
const extensionData = [
|
|
{
|
|
user_id: userId,
|
|
extension_id: extensionId,
|
|
key: 'product:STEEL-COMP',
|
|
value: { description: 'Steel components', cn_code: '72163100', net_weight_kg: 2.4, country_of_origin: 'SE' },
|
|
},
|
|
{
|
|
user_id: userId,
|
|
extension_id: extensionId,
|
|
key: 'product:ALU-FIT',
|
|
value: { description: 'Aluminium fittings', cn_code: '76169990', net_weight_kg: 0.8, country_of_origin: 'SE' },
|
|
},
|
|
{
|
|
user_id: userId,
|
|
extension_id: extensionId,
|
|
key: 'product:IND-SENSOR',
|
|
value: { description: 'Industrial sensors', cn_code: '90318080', net_weight_kg: 0.35, country_of_origin: 'SE' },
|
|
},
|
|
]
|
|
|
|
const { error: extError } = await supabase.from('extension_data').insert(extensionData)
|
|
if (extError) {
|
|
console.error('Warning: Failed to create extension_data (Intrastat products):', extError.message)
|
|
console.log(' (Export extensions will still work, just Intrastat product registry will be empty)')
|
|
} else {
|
|
for (const ed of extensionData) {
|
|
console.log(` ✓ ${ed.key}: ${ed.value.description} (CN: ${ed.value.cn_code})`)
|
|
}
|
|
}
|
|
|
|
// Done
|
|
console.log('\n════════════════════════════════════════════════════')
|
|
console.log(' Seed data created successfully!')
|
|
console.log('════════════════════════════════════════════════════')
|
|
console.log('\nYou should now see data in:')
|
|
console.log(' • Periodisk sammanställning (EU Sales List): 3 EU customers, 5 invoices')
|
|
console.log(' • Exportmoms-monitor (VAT Monitor): journal entries on 3305/3308')
|
|
console.log(' • Intrastat: goods invoices + product registry')
|
|
console.log(' • Valutafordringar (Currency Receivables): 3 open EUR/USD invoices')
|
|
console.log('\nSelect the current month/quarter to see the data.')
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error('Unexpected error:', err)
|
|
process.exit(1)
|
|
})
|