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:
co-authored by
Claude Opus 4.6
parent
fad4899cb4
commit
e89f2c402d
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user