feat: complete multi-tenant refactor + settings validation + fiscal period API (#156)

* feat: complete multi-tenant refactor for reconciliation, arcim, settings validation

- Migrate bank-reconciliation to company_id (all functions + tests)
- Migrate arcim-migration entity mappers and orchestrator to company_id
- Fix enable-banking reconciliation calls to use companyId
- Add Swedish law validation to settings schema:
  - VAT number required when VAT-registered (ML 11 kap. 8§)
  - Moms period required when VAT-registered (SFL 26 kap.)
  - Aktiebolag must use accrual accounting (BFNAR 2006:1)
- Fix fiscal year period creation: always 12 months after first year (BFL 3 kap.)
- Add plusgiro, website, pays_salaries fields to CompanySettings
- Add plusgiro to invoice PDF template
- Add fiscal period CRUD and opening balances API routes
- Add frame-src CSP directive for future iframe embedding
- Fix unlinked_1930_lines RPC to use company_id parameter
- Update CLAUDE.md documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review findings (P1 + P2)

- Fix reconciliation events emitting companyId as userId — thread
  actual userId through runReconciliation and manualLink
- Move VAT cross-field validation (vat_number, moms_period) from
  schema refinements to route handler where effective stored state
  is available, preventing false rejection on partial updates
- Add plusgiro format validation regex (N-N pattern)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-01 14:46:41 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent fad4899cb4
commit e89f2c402d
20 changed files with 427 additions and 76 deletions
@@ -560,6 +560,7 @@ export const arcimMigrationExtension: Extension = {
const results = await executeMigration({
consentId,
userId: user.id,
companyId: ctx?.companyId ?? user.id,
supabase,
importCompanyInfo,
importCustomers,
@@ -219,10 +219,11 @@ function inferVatRate(taxPercent?: number): number {
// ── Public mappers ──────────────────────────────────────────────────
export function mapCustomer(dto: CustomerDto, userId: string): Record<string, unknown> {
export function mapCustomer(dto: CustomerDto, userId: string, companyId: string): Record<string, unknown> {
const addr = formatAddress(dto.party.postalAddress)
return {
user_id: userId,
company_id: companyId,
name: dto.party.name,
customer_type: inferCustomerType(dto),
email: dto.party.contact?.email || null,
@@ -236,10 +237,11 @@ export function mapCustomer(dto: CustomerDto, userId: string): Record<string, un
}
}
export function mapSupplier(dto: SupplierDto, userId: string): Record<string, unknown> {
export function mapSupplier(dto: SupplierDto, userId: string, companyId: string): Record<string, unknown> {
const addr = formatAddress(dto.party.postalAddress)
return {
user_id: userId,
company_id: companyId,
name: dto.party.name,
supplier_type: inferSupplierType(dto),
email: dto.party.contact?.email || null,
@@ -262,6 +264,7 @@ export function mapSupplier(dto: SupplierDto, userId: string): Record<string, un
export function mapSalesInvoice(
dto: SalesInvoiceDto,
userId: string,
companyId: string,
customerId: string
): { invoice: Record<string, unknown>; items: Record<string, unknown>[] } {
const subtotal = round2(dto.legalMonetaryTotal.lineExtensionAmount.value)
@@ -287,6 +290,7 @@ export function mapSalesInvoice(
const invoice: Record<string, unknown> = {
user_id: userId,
company_id: companyId,
customer_id: customerId,
invoice_number: dto.invoiceNumber,
invoice_date: dto.issueDate,
@@ -331,6 +335,7 @@ function mapSalesInvoiceLine(line: SalesInvoiceLineDto, index: number): Record<s
export function mapSupplierInvoice(
dto: SupplierInvoiceDto,
userId: string,
companyId: string,
supplierId: string
): { invoice: Record<string, unknown>; items: Record<string, unknown>[] } {
const subtotal = round2(dto.legalMonetaryTotal.lineExtensionAmount.value)
@@ -354,6 +359,7 @@ export function mapSupplierInvoice(
const invoice: Record<string, unknown> = {
user_id: userId,
company_id: companyId,
supplier_id: supplierId,
supplier_invoice_number: dto.invoiceNumber,
invoice_date: dto.issueDate,
@@ -33,6 +33,7 @@ import {
export interface MigrationOptions {
consentId: string
userId: string
companyId: string
supabase: SupabaseClient
importCompanyInfo?: boolean
importCustomers?: boolean
@@ -49,7 +50,7 @@ function emitProgress(options: MigrationOptions, progress: MigrationProgress) {
// ── Main orchestrator ─────────────────────────────────────────────
export async function executeMigration(options: MigrationOptions): Promise<MigrationResults> {
const { consentId, userId, supabase } = options
const { consentId, userId, companyId, supabase } = options
const results: MigrationResults = {}
try {
@@ -63,7 +64,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const { data: existing } = await supabase
.from('company_settings')
.select('company_name, org_number, vat_number')
.eq('company_id', userId)
.eq('company_id', companyId)
.single()
const updates: Record<string, unknown> = {}
@@ -83,7 +84,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
if (mapped.email) updates.email = mapped.email
if (Object.keys(updates).length > 0) {
await supabase.from('company_settings').update(updates).eq('company_id', userId)
await supabase.from('company_settings').update(updates).eq('company_id', companyId)
}
results.companyInfo = { imported: true }
}
@@ -116,7 +117,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const { data: existing } = await supabase
.from('customers')
.select('id')
.eq('company_id', userId)
.eq('company_id', companyId)
.eq('org_number', orgNumber)
.limit(1)
@@ -128,7 +129,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
const mapped = mapCustomer(customer, userId)
const mapped = mapCustomer(customer, userId, companyId)
const { data: inserted, error } = await supabase
.from('customers')
.insert(mapped)
@@ -173,7 +174,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const { data: existing } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', userId)
.eq('company_id', companyId)
.eq('org_number', orgNumber)
.limit(1)
@@ -185,7 +186,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
}
}
const mapped = mapSupplier(supplier, userId)
const mapped = mapSupplier(supplier, userId, companyId)
const { data: inserted, error } = await supabase
.from('suppliers')
.insert(mapped)
@@ -230,7 +231,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const { data: match } = await supabase
.from('customers')
.select('id')
.eq('company_id', userId)
.eq('company_id', companyId)
.eq('org_number', customerOrgNumber)
.limit(1)
if (match?.[0]) customerId = match[0].id
@@ -240,7 +241,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const { data: match } = await supabase
.from('customers')
.select('id')
.eq('company_id', userId)
.eq('company_id', companyId)
.eq('name', inv.customer.name)
.limit(1)
if (match?.[0]) customerId = match[0].id
@@ -250,6 +251,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const customerType = inferTypeFromParty(inv.customer)
const minimalCustomer = {
user_id: userId,
company_id: companyId,
name: inv.customer.name,
customer_type: customerType,
default_payment_terms: 30,
@@ -277,7 +279,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const { data: existingInv } = await supabase
.from('invoices')
.select('id')
.eq('company_id', userId)
.eq('company_id', companyId)
.eq('invoice_number', inv.invoiceNumber)
.limit(1)
@@ -287,7 +289,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
continue
}
const { invoice: mappedInvoice, items: mappedItems } = mapSalesInvoice(inv, userId, customerId)
const { invoice: mappedInvoice, items: mappedItems } = mapSalesInvoice(inv, userId, companyId, customerId)
const { data: insertedInv, error: invError } = await supabase
.from('invoices')
@@ -341,7 +343,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const { data: match } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', userId)
.eq('company_id', companyId)
.eq('org_number', supplierOrgNumber)
.limit(1)
if (match?.[0]) supplierId = match[0].id
@@ -351,7 +353,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const { data: match } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', userId)
.eq('company_id', companyId)
.eq('name', inv.supplier.name)
.limit(1)
if (match?.[0]) supplierId = match[0].id
@@ -361,6 +363,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const supplierType = inferTypeFromParty(inv.supplier)
const minimalSupplier = {
user_id: userId,
company_id: companyId,
name: inv.supplier.name,
supplier_type: supplierType,
default_payment_terms: 30,
@@ -388,7 +391,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
const { data: existingInv } = await supabase
.from('supplier_invoices')
.select('id')
.eq('company_id', userId)
.eq('company_id', companyId)
.eq('supplier_invoice_number', inv.invoiceNumber)
.eq('supplier_id', supplierId)
.limit(1)
@@ -399,11 +402,11 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
continue
}
const { invoice: mappedInvoice, items: mappedItems } = mapSupplierInvoice(inv, userId, supplierId)
const { invoice: mappedInvoice, items: mappedItems } = mapSupplierInvoice(inv, userId, companyId, supplierId)
// Get next arrival number (ankomstnummer) — required NOT NULL column
const { data: arrivalNum, error: arrivalError } = await supabase
.rpc('get_next_arrival_number', { p_company_id: userId })
.rpc('get_next_arrival_number', { p_company_id: companyId })
if (arrivalError || arrivalNum == null) {
console.error(`[migration] Supplier invoice ${inv.invoiceNumber} skipped — could not get arrival number:`, arrivalError?.message)
+1 -1
View File
@@ -289,7 +289,7 @@ export const enableBankingExtension: Extension = {
// pass may have missed due to processing order.
if (sieOverlap && totalImported > 0) {
try {
const reconResult = await runReconciliation(supabase, user.id, {
const reconResult = await runReconciliation(supabase, ctx?.companyId ?? user.id, {
dateFrom: fromDate,
dateTo: toDate,
})