feat: multi-tenant company refactor (GNU-19) (#153)
* feat: multi-tenant company refactor (GNU-19) Introduce companies table, company_members, and user_preferences to support multiple companies per user. All data scoping changes from user_id to company_id across the entire codebase. Key changes: - Database migration: new tables, company_id on 40+ tables, backfill, RLS rewrite from user_id to company-member-based, updated RPCs - Types: Company, CompanyMember, CompanyRole, UserPreferences types; company_id added to all entity interfaces; companyId on all events - Engine: all 7 core functions take companyId; storno, period, year-end services updated; 16 report generators updated - Middleware: company context resolution (cookie → prefs → first company) - API routes: ~120 routes updated with requireCompanyId() - Frontend: CompanyProvider context, layout/dashboard/onboarding updated - Extensions: context factory, 9 extensions, all lib files updated - Tests: 1880 tests passing, all helpers updated with company_id defaults Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add database migrations for multi-tenant company and team system (GNU-19) Adds company_invitations, company creation RPC, team_members, account deletion RPC, and teams table refactor migrations. Updates base multi-tenant migration with cascading FKs and onboarding_step column. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add team types and update core infrastructure for multi-tenancy (GNU-19) Adds TeamRole, MemberSource, and Team types. Refactors Supabase service client to be stateless, updates middleware for team-aware routing, extends CompanyContext with team/role fields, and updates extension service types to accept companyId. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: thread company_id through business logic functions (GNU-19) Replaces user_id scoping with company_id across all lib modules: bookkeeping, documents, transactions, invoices, reconciliation, tax, deadlines, and import. Updates corresponding tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: thread company_id through API routes and extensions (GNU-19) Updates all existing API routes to extract and pass companyId. Updates enable-banking and arcim-migration extensions for company-scoped transaction ingestion and sync. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add company and team management API routes (GNU-19) Adds CRUD endpoints for company members, company invitations, team members, and team invitations. Includes invite token utilities, email templates, and company switch server action. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add team/company UI components, pages, and dashboard updates (GNU-19) Adds CompanySwitcher, ConsultantEmptyState, Step0RoleChoice, company members and team management panels. Updates dashboard layout for team-aware routing, onboarding for multi-step role choice, and auth callback for team invite acceptance. Ignores supabase/.branches/. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add null guards for company in import page (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: move appUrl declaration to outer scope in invite route (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add optional chaining for company.name in members section (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add optional chaining for second company.name in members section (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add null guards for company in extension components (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: pass companyId to executeSIEImport in arcim-migration extension (GNU-19) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update tests to use companyId instead of userId and improve type handling --------- 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
1756af6c5e
commit
0dd1f5ebc1
@@ -30,7 +30,7 @@ async function handleGetSuggestions(
|
||||
const { data: records } = await supabase
|
||||
.from('extension_data')
|
||||
.select('key, value')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('extension_id', 'ai-categorization')
|
||||
.in('key', keys)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export async function getSettings(userId: string): Promise<AiCategorizationSetti
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('extension_id', 'ai-categorization')
|
||||
.eq('key', 'settings')
|
||||
.single()
|
||||
@@ -106,7 +106,7 @@ export async function categorizeTransactions(
|
||||
const { data: transactions } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, description, amount, date, merchant_name, mcc_code, currency')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.in('id', transactionIds)
|
||||
|
||||
if (!transactions || transactions.length === 0) return []
|
||||
@@ -207,7 +207,7 @@ async function buildEnrichedContext(
|
||||
const { data: companySettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
const entityType: EntityType = companySettings?.entity_type || 'enskild_firma'
|
||||
@@ -216,7 +216,7 @@ async function buildEnrichedContext(
|
||||
const { data: historicalTxns } = await supabase
|
||||
.from('transactions')
|
||||
.select('description, category')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.not('is_business', 'is', null)
|
||||
.neq('category', 'uncategorized')
|
||||
.order('updated_at', { ascending: false })
|
||||
@@ -233,7 +233,7 @@ async function buildEnrichedContext(
|
||||
const { data: accountUsageRows } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
|
||||
const accountCounts = new Map<string, number>()
|
||||
if (accountUsageRows) {
|
||||
@@ -258,7 +258,7 @@ async function buildEnrichedContext(
|
||||
const { data: merchantRows } = await supabase
|
||||
.from('transactions')
|
||||
.select('merchant_name, category, template_id')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.not('is_business', 'is', null)
|
||||
.neq('category', 'uncategorized')
|
||||
.in('merchant_name', merchantNames)
|
||||
|
||||
@@ -19,7 +19,7 @@ async function checkRateLimitDB(supabase: SupabaseClient, userId: string): Promi
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('extension_id', 'ai-chat')
|
||||
.eq('key', 'rate_limit')
|
||||
.single()
|
||||
@@ -116,7 +116,7 @@ async function handlePostChat(
|
||||
.from('chat_sessions')
|
||||
.select('id')
|
||||
.eq('id', sessionId)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (!existingSession) {
|
||||
@@ -252,7 +252,7 @@ async function handlePostStream(
|
||||
.from('chat_sessions')
|
||||
.select('id')
|
||||
.eq('id', sessionId)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (!existingSession) {
|
||||
@@ -393,7 +393,7 @@ async function handleGetSessions(
|
||||
const { data, error, count } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
@@ -474,7 +474,7 @@ async function handleGetSession(
|
||||
.from('chat_sessions')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (sessionError || !session) {
|
||||
@@ -529,7 +529,7 @@ async function handlePatchSession(
|
||||
.from('chat_sessions')
|
||||
.update({ title })
|
||||
.eq('id', id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
@@ -578,7 +578,7 @@ async function handleDeleteSession(
|
||||
.from('chat_sessions')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting session:', error)
|
||||
|
||||
@@ -29,7 +29,7 @@ async function resolveCurrentPeriod(
|
||||
.from('fiscal_periods')
|
||||
.select('id, period_start, period_end')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
if (data) return { id: data.id, start: data.period_start, end: data.period_end }
|
||||
}
|
||||
@@ -38,7 +38,7 @@ async function resolveCurrentPeriod(
|
||||
const { data } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, period_start, period_end, is_closed')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
@@ -56,7 +56,7 @@ export function createAccountingTools(supabase: SupabaseClient, userId: string)
|
||||
let query = supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, due_date, status, total, paid_amount, currency, vat_amount, customer:customers(name)')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.order('invoice_date', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
@@ -68,7 +68,7 @@ export function createAccountingTools(supabase: SupabaseClient, userId: string)
|
||||
const { data: _data, error: _countError, count } = await supabase
|
||||
.from('invoices')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
|
||||
const { data: invoices, error: fetchError } = await query
|
||||
|
||||
@@ -111,7 +111,7 @@ export function createAccountingTools(supabase: SupabaseClient, userId: string)
|
||||
let query = supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, remaining_amount, currency, vat_amount, supplier:suppliers(name)')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.order('invoice_date', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
@@ -198,7 +198,7 @@ export function createAccountingTools(supabase: SupabaseClient, userId: string)
|
||||
let query = supabase
|
||||
.from('transactions')
|
||||
.select('id, date, description, amount, currency, category, is_business, merchant_name, journal_entry_id')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.order('date', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
@@ -245,7 +245,7 @@ export function createAccountingTools(supabase: SupabaseClient, userId: string)
|
||||
let query = supabase
|
||||
.from('journal_entries')
|
||||
.select('id, voucher_number, entry_date, description, status, source_type')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('status', 'posted')
|
||||
.order('voucher_number', { ascending: false })
|
||||
.limit(limit)
|
||||
@@ -404,7 +404,7 @@ export function createAccountingTools(supabase: SupabaseClient, userId: string)
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('moms_period')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
const periodType = settings?.moms_period || 'quarterly'
|
||||
@@ -452,7 +452,7 @@ export function createAccountingTools(supabase: SupabaseClient, userId: string)
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (!settings) return 'Inga företagsinställningar hittades.'
|
||||
@@ -466,10 +466,10 @@ export function createAccountingTools(supabase: SupabaseClient, userId: string)
|
||||
{ count: txCount },
|
||||
{ count: unbookedCount },
|
||||
] = await Promise.all([
|
||||
supabase.from('invoices').select('id', { count: 'exact', head: true }).eq('user_id', userId),
|
||||
supabase.from('invoices').select('id', { count: 'exact', head: true }).eq('user_id', userId).in('status', ['sent', 'overdue']),
|
||||
supabase.from('transactions').select('id', { count: 'exact', head: true }).eq('user_id', userId),
|
||||
supabase.from('transactions').select('id', { count: 'exact', head: true }).eq('user_id', userId).is('journal_entry_id', null),
|
||||
supabase.from('invoices').select('id', { count: 'exact', head: true }).eq('company_id', userId),
|
||||
supabase.from('invoices').select('id', { count: 'exact', head: true }).eq('company_id', userId).in('status', ['sent', 'overdue']),
|
||||
supabase.from('transactions').select('id', { count: 'exact', head: true }).eq('company_id', userId),
|
||||
supabase.from('transactions').select('id', { count: 'exact', head: true }).eq('company_id', userId).is('journal_entry_id', null),
|
||||
])
|
||||
|
||||
let netResult: number | null = null
|
||||
|
||||
@@ -401,7 +401,7 @@ export const arcimMigrationExtension: Extension = {
|
||||
.map(a => ({ number: a.number, name: a.name }))
|
||||
|
||||
// Load existing user mappings
|
||||
const existingMappings = await loadMappings(supabase, user.id)
|
||||
const existingMappings = await loadMappings(supabase, ctx?.companyId ?? user.id)
|
||||
const existingRecords = [...existingMappings.values()].map(m => ({
|
||||
id: '',
|
||||
user_id: user.id,
|
||||
@@ -485,7 +485,7 @@ export const arcimMigrationExtension: Extension = {
|
||||
await saveMappings(supabase, user.id, mappings)
|
||||
|
||||
// Execute the import via core engine
|
||||
const result = await executeSIEImport(supabase, user.id, parsed, mappings, {
|
||||
const result = await executeSIEImport(supabase, ctx?.companyId ?? user.id, user.id, parsed, mappings, {
|
||||
filename: `migration-sie-${Date.now()}.se`,
|
||||
fileContent: rawContent,
|
||||
createFiscalPeriod: options.createFiscalPeriod,
|
||||
|
||||
@@ -63,7 +63,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('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
const updates: Record<string, unknown> = {}
|
||||
@@ -83,7 +83,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('user_id', userId)
|
||||
await supabase.from('company_settings').update(updates).eq('company_id', userId)
|
||||
}
|
||||
results.companyInfo = { imported: true }
|
||||
}
|
||||
@@ -116,7 +116,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
const { data: existing } = await supabase
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('org_number', orgNumber)
|
||||
.limit(1)
|
||||
|
||||
@@ -173,7 +173,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
const { data: existing } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('org_number', orgNumber)
|
||||
.limit(1)
|
||||
|
||||
@@ -230,7 +230,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
const { data: match } = await supabase
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('org_number', customerOrgNumber)
|
||||
.limit(1)
|
||||
if (match?.[0]) customerId = match[0].id
|
||||
@@ -240,7 +240,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
const { data: match } = await supabase
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('name', inv.customer.name)
|
||||
.limit(1)
|
||||
if (match?.[0]) customerId = match[0].id
|
||||
@@ -277,7 +277,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
const { data: existingInv } = await supabase
|
||||
.from('invoices')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('invoice_number', inv.invoiceNumber)
|
||||
.limit(1)
|
||||
|
||||
@@ -341,7 +341,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
const { data: match } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('org_number', supplierOrgNumber)
|
||||
.limit(1)
|
||||
if (match?.[0]) supplierId = match[0].id
|
||||
@@ -351,7 +351,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
const { data: match } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('name', inv.supplier.name)
|
||||
.limit(1)
|
||||
if (match?.[0]) supplierId = match[0].id
|
||||
@@ -388,7 +388,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
const { data: existingInv } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('supplier_invoice_number', inv.invoiceNumber)
|
||||
.eq('supplier_id', supplierId)
|
||||
.limit(1)
|
||||
|
||||
@@ -16,7 +16,7 @@ interface PaymentCalendarProps {
|
||||
invoices: Invoice[]
|
||||
deadlines: Deadline[]
|
||||
customers: { id: string; name: string }[]
|
||||
onDeadlineCreate: (data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>) => Promise<void>
|
||||
onDeadlineCreate: (data: Omit<Deadline, 'id' | 'user_id' | 'company_id' | 'created_at' | 'updated_at'>) => Promise<void>
|
||||
onDeadlineToggle: (deadline: Deadline) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export function PaymentCalendar({
|
||||
setDeadlineFormDate(null)
|
||||
}, [])
|
||||
|
||||
const handleDeadlineSubmit = useCallback(async (data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>) => {
|
||||
const handleDeadlineSubmit = useCallback(async (data: Omit<Deadline, 'id' | 'user_id' | 'company_id' | 'created_at' | 'updated_at'>) => {
|
||||
await onDeadlineCreate(data)
|
||||
handleDeadlineFormClose()
|
||||
}, [onDeadlineCreate])
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import { Loader2, Upload } from 'lucide-react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { BankSelector, type Bank } from './BankSelector'
|
||||
import { BankConnectionStatus } from './BankConnectionStatus'
|
||||
import type { BankConnection } from '@/types'
|
||||
@@ -21,6 +22,7 @@ export default function BankingSettingsPanel() {
|
||||
const supabase = createClient()
|
||||
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
const { company } = useCompany()
|
||||
|
||||
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
|
||||
const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null)
|
||||
@@ -38,11 +40,12 @@ export default function BankingSettingsPanel() {
|
||||
setIsLoading(true)
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return
|
||||
if (!company) return
|
||||
|
||||
const { data: connections } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('company_id', company.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
setBankConnections(connections || [])
|
||||
|
||||
@@ -88,7 +88,7 @@ export const enableBankingExtension: Extension = {
|
||||
const { data: companySettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.eq('user_id', user.id)
|
||||
.eq('company_id', ctx?.companyId ?? user.id)
|
||||
.single()
|
||||
|
||||
const psuType = companySettings?.entity_type === 'aktiebolag' ? 'business' : 'personal'
|
||||
@@ -106,7 +106,7 @@ export const enableBankingExtension: Extension = {
|
||||
const { data: recentPending } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, created_at')
|
||||
.eq('user_id', user.id)
|
||||
.eq('company_id', ctx?.companyId ?? user.id)
|
||||
.eq('bank_name', aspsp_name)
|
||||
.eq('status', 'pending')
|
||||
.order('created_at', { ascending: false })
|
||||
@@ -136,7 +136,7 @@ export const enableBankingExtension: Extension = {
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: 'error', error_message: 'Superseded by new connection attempt', oauth_state: null })
|
||||
.eq('user_id', user.id)
|
||||
.eq('company_id', ctx?.companyId ?? user.id)
|
||||
.eq('bank_name', aspsp_name)
|
||||
.eq('status', 'pending')
|
||||
}
|
||||
@@ -157,6 +157,7 @@ export const enableBankingExtension: Extension = {
|
||||
const { data: connection, error } = await supabase
|
||||
.from('bank_connections')
|
||||
.insert({
|
||||
company_id: ctx?.companyId ?? user.id,
|
||||
user_id: user.id,
|
||||
provider: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
|
||||
bank_name: aspsp_name,
|
||||
@@ -217,7 +218,7 @@ export const enableBankingExtension: Extension = {
|
||||
.from('bank_connections')
|
||||
.select('*')
|
||||
.eq('id', connection_id)
|
||||
.eq('user_id', user.id)
|
||||
.eq('company_id', ctx?.companyId ?? user.id)
|
||||
.single()
|
||||
|
||||
if (connectionError || !connection) {
|
||||
@@ -246,7 +247,7 @@ export const enableBankingExtension: Extension = {
|
||||
const { data: sieOverlap } = await supabase
|
||||
.from('sie_imports')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('company_id', ctx?.companyId ?? user.id)
|
||||
.eq('status', 'completed')
|
||||
.gte('fiscal_year_end', fromDate)
|
||||
.limit(1)
|
||||
@@ -264,9 +265,11 @@ export const enableBankingExtension: Extension = {
|
||||
})
|
||||
}
|
||||
|
||||
const companyId = ctx?.companyId ?? user.id
|
||||
const results = await Promise.all(
|
||||
accounts.map(account => syncAccountTransactions(
|
||||
supabase,
|
||||
companyId,
|
||||
user.id,
|
||||
connection.id,
|
||||
account,
|
||||
@@ -314,7 +317,7 @@ export const enableBankingExtension: Extension = {
|
||||
const { data: syncedTransactions } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('company_id', ctx?.companyId ?? user.id)
|
||||
.eq('bank_connection_id', connection.id)
|
||||
.gte('created_at', syncStartedAt)
|
||||
.order('created_at', { ascending: false })
|
||||
@@ -324,7 +327,7 @@ export const enableBankingExtension: Extension = {
|
||||
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
|
||||
await emit({
|
||||
type: 'transaction.synced',
|
||||
payload: { transactions: syncedTransactions as Transaction[], userId: user.id },
|
||||
payload: { transactions: syncedTransactions as Transaction[], userId: user.id, companyId: ctx?.companyId ?? user.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -373,7 +376,7 @@ export const enableBankingExtension: Extension = {
|
||||
.from('bank_connections')
|
||||
.select('id, session_id, status')
|
||||
.eq('id', connection_id)
|
||||
.eq('user_id', user.id)
|
||||
.eq('company_id', ctx?.companyId ?? user.id)
|
||||
.single()
|
||||
|
||||
if (findError || !connection) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { syncAccountTransactions } from '../sync'
|
||||
import type { StoredAccount } from '../../types'
|
||||
|
||||
const USER_ID = 'user-1'
|
||||
const COMPANY_ID = 'company-1'
|
||||
const CONNECTION_ID = 'conn-1'
|
||||
|
||||
function makeAccount(overrides: Partial<StoredAccount> = {}): StoredAccount {
|
||||
@@ -66,6 +67,7 @@ describe('syncAccountTransactions', () => {
|
||||
const account = makeAccount()
|
||||
await syncAccountTransactions(
|
||||
{} as never,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
CONNECTION_ID,
|
||||
account,
|
||||
@@ -78,12 +80,12 @@ describe('syncAccountTransactions', () => {
|
||||
|
||||
// Verify filename pattern
|
||||
const firstCall = mockUploadDocument.mock.calls[0]
|
||||
expect(firstCall[2].name).toMatch(/^psd2-response_conn-1_acc-uid-1_.*_p1\.json$/)
|
||||
expect(firstCall[2].type).toBe('application/json')
|
||||
expect(firstCall[3]).toEqual({ upload_source: 'api' })
|
||||
expect(firstCall[3].name).toMatch(/^psd2-response_conn-1_acc-uid-1_.*_p1\.json$/)
|
||||
expect(firstCall[3].type).toBe('application/json')
|
||||
expect(firstCall[4]).toEqual({ upload_source: 'api' })
|
||||
|
||||
const secondCall = mockUploadDocument.mock.calls[1]
|
||||
expect(secondCall[2].name).toMatch(/^psd2-response_conn-1_acc-uid-1_.*_p2\.json$/)
|
||||
expect(secondCall[3].name).toMatch(/^psd2-response_conn-1_acc-uid-1_.*_p2\.json$/)
|
||||
})
|
||||
|
||||
it('completes sync even if uploadDocument throws', async () => {
|
||||
@@ -108,6 +110,7 @@ describe('syncAccountTransactions', () => {
|
||||
const account = makeAccount()
|
||||
const result = await syncAccountTransactions(
|
||||
{} as never,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
CONNECTION_ID,
|
||||
account,
|
||||
@@ -148,6 +151,7 @@ describe('syncAccountTransactions', () => {
|
||||
const account = makeAccount()
|
||||
await syncAccountTransactions(
|
||||
{} as never,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
CONNECTION_ID,
|
||||
account,
|
||||
@@ -157,7 +161,7 @@ describe('syncAccountTransactions', () => {
|
||||
)
|
||||
|
||||
expect(mockIngest).toHaveBeenCalledTimes(1)
|
||||
const rawTxns = mockIngest.mock.calls[0][2]
|
||||
const rawTxns = mockIngest.mock.calls[0][3]
|
||||
expect(rawTxns).toHaveLength(1)
|
||||
expect(rawTxns[0].external_id).toBe('conn-1_tx-500')
|
||||
expect(rawTxns[0].import_source).toBe('enable_banking')
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { StoredAccount } from '../types'
|
||||
/** Ingest function signature — matches lib/transactions/ingest */
|
||||
export type IngestFn = (
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
raw: RawTransaction[],
|
||||
options?: IngestOptions
|
||||
@@ -37,6 +38,7 @@ export interface SyncResult {
|
||||
*/
|
||||
export async function syncAccountTransactions(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
connectionId: string,
|
||||
account: StoredAccount,
|
||||
@@ -85,7 +87,7 @@ export async function syncAccountTransactions(
|
||||
const ingestOptions: IngestOptions | undefined = syncOptions?.skipAutoCategorization
|
||||
? { skipAutoCategorization: true }
|
||||
: undefined
|
||||
const ingestResult = await ingest(supabase, userId, rawTransactions, ingestOptions)
|
||||
const ingestResult = await ingest(supabase, companyId, userId, rawTransactions, ingestOptions)
|
||||
|
||||
console.log('[enable-banking] Ingest result', {
|
||||
connectionId,
|
||||
@@ -100,7 +102,7 @@ export async function syncAccountTransactions(
|
||||
try {
|
||||
const fileName = `psd2-response_${connectionId}_${account.uid}_${new Date().toISOString().replace(/[:.]/g, '-')}_p${i + 1}.json`
|
||||
const buffer = new TextEncoder().encode(rawPages[i]).buffer as ArrayBuffer
|
||||
await uploadDocument(supabase, userId,
|
||||
await uploadDocument(supabase, companyId, userId,
|
||||
{ name: fileName, buffer, type: 'application/json' },
|
||||
{ upload_source: 'api' }
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ async function handleGetInbox(
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const companyId = ctx!.companyId
|
||||
const supabase = await getSupabase()
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
@@ -41,7 +42,7 @@ async function handleGetInbox(
|
||||
let query = supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*, document:document_attachments(id, file_name, mime_type, storage_path), supplier:suppliers(id, name), receipt:receipts(id, merchant_name, total_amount, receipt_date, status, matched_transaction_id)')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
|
||||
if (status && status !== 'all') {
|
||||
query = query.eq('status', status)
|
||||
@@ -69,6 +70,7 @@ async function handlePostInbox(
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const companyId = ctx!.companyId
|
||||
const supabase = await getSupabase()
|
||||
|
||||
const formData = await request.formData()
|
||||
@@ -100,11 +102,11 @@ async function handlePostInbox(
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await uploadAndCreateInboxItem(supabase, userId, file)
|
||||
const result = await uploadAndCreateInboxItem(supabase, userId, companyId, file)
|
||||
items.push(result.inboxItem)
|
||||
|
||||
// Process asynchronously
|
||||
processInboxItem(result.inboxItem.id as string, userId, result.base64, file.type).catch((err) =>
|
||||
processInboxItem(result.inboxItem.id as string, userId, companyId, result.base64, file.type).catch((err) =>
|
||||
console.error('[invoice-inbox] Background processing failed:', err)
|
||||
)
|
||||
} catch (error) {
|
||||
@@ -124,6 +126,7 @@ async function handlePostInbox(
|
||||
async function uploadAndCreateInboxItem(
|
||||
supabase: Awaited<ReturnType<typeof getSupabase>>,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
file: File
|
||||
): Promise<{ inboxItem: Record<string, unknown>; base64: string }> {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
@@ -144,6 +147,7 @@ async function uploadAndCreateInboxItem(
|
||||
.from('document_attachments')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
storage_path: storagePath,
|
||||
file_name: file.name,
|
||||
file_size_bytes: buffer.length,
|
||||
@@ -175,7 +179,7 @@ async function uploadAndCreateInboxItem(
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.received',
|
||||
payload: { inboxItem, userId },
|
||||
payload: { inboxItem, userId, companyId },
|
||||
})
|
||||
|
||||
return { inboxItem, base64 }
|
||||
@@ -184,6 +188,7 @@ async function uploadAndCreateInboxItem(
|
||||
async function processInboxItem(
|
||||
itemId: string,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
base64: string,
|
||||
mimeType: string
|
||||
): Promise<void> {
|
||||
@@ -198,9 +203,9 @@ async function processInboxItem(
|
||||
|
||||
// Handle based on document type
|
||||
if (classification.type === 'receipt' && result.receipt) {
|
||||
await processAsReceipt(supabase, itemId, userId, result.receipt)
|
||||
await processAsReceipt(supabase, itemId, userId, companyId, result.receipt)
|
||||
} else if (classification.type === 'supplier_invoice' && result.invoice) {
|
||||
await processAsInvoice(supabase, itemId, userId, result.invoice)
|
||||
await processAsInvoice(supabase, itemId, userId, companyId, result.invoice)
|
||||
} else if (classification.type === 'government_letter' || classification.type === 'unknown') {
|
||||
// Store classification but no extraction — user must review manually
|
||||
await supabase
|
||||
@@ -216,7 +221,7 @@ async function processInboxItem(
|
||||
console.warn(`[invoice-inbox] item=${itemId}: classified as ${classification.type} but no extraction data, falling back`)
|
||||
const { extractInvoice } = await import('@/lib/ai/document-analyzer')
|
||||
const fallbackExtraction = await extractInvoice(base64, mimeType)
|
||||
await processAsInvoice(supabase, itemId, userId, fallbackExtraction)
|
||||
await processAsInvoice(supabase, itemId, userId, companyId, fallbackExtraction)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
@@ -231,6 +236,7 @@ async function processAsReceipt(
|
||||
supabase: Awaited<ReturnType<typeof getSupabase>>,
|
||||
itemId: string,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
extraction: import('@/types').ReceiptExtractionResult
|
||||
): Promise<void> {
|
||||
console.log(`[invoice-inbox] item=${itemId} processing as receipt: merchant=${extraction.merchant?.name}, total=${extraction.totals?.total}`)
|
||||
@@ -332,6 +338,7 @@ async function processAsReceipt(
|
||||
documentId: inboxItem?.document_id || null,
|
||||
confidence: extraction.confidence,
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
@@ -372,6 +379,7 @@ async function processAsInvoice(
|
||||
supabase: Awaited<ReturnType<typeof getSupabase>>,
|
||||
itemId: string,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
extraction: InvoiceExtractionResult
|
||||
): Promise<void> {
|
||||
console.log(`[invoice-inbox] item=${itemId} processing as supplier_invoice: supplier=${extraction.supplier?.name}, total=${extraction.totals?.total}`)
|
||||
@@ -384,7 +392,7 @@ async function processAsInvoice(
|
||||
const { data: suppliers } = await supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
|
||||
if (suppliers && suppliers.length > 0) {
|
||||
const match = matchSupplier(extraction, suppliers)
|
||||
@@ -429,6 +437,7 @@ async function processAsInvoice(
|
||||
inboxItem: updatedItem,
|
||||
confidence: extraction.confidence,
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -465,6 +474,7 @@ async function handleGetInboxItem(
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const companyId = ctx!.companyId
|
||||
const supabase = await getSupabase()
|
||||
const id = getIdParam(request)
|
||||
|
||||
@@ -476,7 +486,7 @@ async function handleGetInboxItem(
|
||||
.from('invoice_inbox_items')
|
||||
.select('*, document:document_attachments(id, file_name, mime_type, storage_path), supplier:suppliers(id, name)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
@@ -495,6 +505,7 @@ async function handlePatchInboxItem(
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const companyId = ctx!.companyId
|
||||
const supabase = await getSupabase()
|
||||
const id = getIdParam(request)
|
||||
|
||||
@@ -509,7 +520,7 @@ async function handlePatchInboxItem(
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (findError || !existing) {
|
||||
@@ -552,6 +563,7 @@ async function handleDeleteInboxItem(
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const companyId = ctx!.companyId
|
||||
const supabase = await getSupabase()
|
||||
const id = getIdParam(request)
|
||||
|
||||
@@ -564,7 +576,7 @@ async function handleDeleteInboxItem(
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'rejected' })
|
||||
.eq('id', id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
@@ -584,6 +596,7 @@ async function handleProcessInboxItem(
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const companyId = ctx!.companyId
|
||||
const supabase = await getSupabase()
|
||||
const id = getIdParam(request)
|
||||
|
||||
@@ -596,7 +609,7 @@ async function handleProcessInboxItem(
|
||||
.from('invoice_inbox_items')
|
||||
.select('*, document:document_attachments(id, storage_path, mime_type)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (findError || !inboxItem) {
|
||||
@@ -654,9 +667,9 @@ async function handleProcessInboxItem(
|
||||
console.log(`[invoice-inbox] Re-process item=${id} classified as: ${classification.type} (confidence=${classification.confidence})`)
|
||||
|
||||
if (classification.type === 'receipt' && result.receipt) {
|
||||
await processAsReceipt(supabase, id, userId, result.receipt)
|
||||
await processAsReceipt(supabase, id, userId, companyId, result.receipt)
|
||||
} else if (classification.type === 'supplier_invoice' && result.invoice) {
|
||||
await processAsInvoice(supabase, id, userId, result.invoice)
|
||||
await processAsInvoice(supabase, id, userId, companyId, result.invoice)
|
||||
} else if (classification.type === 'government_letter' || classification.type === 'unknown') {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
@@ -671,7 +684,7 @@ async function handleProcessInboxItem(
|
||||
// Fallback: extract as invoice
|
||||
const { extractInvoice } = await import('@/lib/ai/document-analyzer')
|
||||
const fallbackExtraction = await extractInvoice(base64, document.mime_type)
|
||||
await processAsInvoice(supabase, id, userId, fallbackExtraction)
|
||||
await processAsInvoice(supabase, id, userId, companyId, fallbackExtraction)
|
||||
}
|
||||
|
||||
// Fetch final state
|
||||
@@ -705,6 +718,7 @@ async function handleConfirmInboxItem(
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const companyId = ctx!.companyId
|
||||
const supabase = await getSupabase()
|
||||
const id = getIdParam(request)
|
||||
|
||||
@@ -717,7 +731,7 @@ async function handleConfirmInboxItem(
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (findError || !inboxItem) {
|
||||
@@ -794,7 +808,7 @@ async function handleConfirmInboxItem(
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.eq('id', supplierId)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (supplierCheckError || !supplier) {
|
||||
@@ -981,6 +995,7 @@ async function handleConfirmInboxItem(
|
||||
inboxItem: { ...inboxItem, status: 'confirmed' },
|
||||
supplierInvoice: invoice as SupplierInvoice,
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
@@ -1010,6 +1025,7 @@ async function handleConfirmReceipt(
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const companyId = ctx!.companyId
|
||||
const supabase = await getSupabase()
|
||||
const id = getIdParam(request)
|
||||
|
||||
@@ -1022,7 +1038,7 @@ async function handleConfirmReceipt(
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (findError || !inboxItem) {
|
||||
@@ -1111,7 +1127,7 @@ async function handleConfirmReceipt(
|
||||
.from('transactions')
|
||||
.update({ receipt_id: inboxItem.linked_receipt_id })
|
||||
.eq('id', matched_transaction_id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
}
|
||||
|
||||
// Update inbox item status
|
||||
@@ -1136,6 +1152,7 @@ async function handleConfirmReceipt(
|
||||
businessTotal,
|
||||
privateTotal,
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1155,6 +1172,7 @@ async function handleGetSettings(
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const companyId = ctx!.companyId
|
||||
const settings = await getSettings(userId)
|
||||
return NextResponse.json({ data: settings })
|
||||
}
|
||||
@@ -1168,6 +1186,7 @@ async function handlePutSettings(
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const companyId = ctx!.companyId
|
||||
const body = await request.json()
|
||||
const settings = await saveSettings(userId, body)
|
||||
return NextResponse.json({ data: settings })
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function getSettings(userId: string): Promise<InvoiceInboxSettings>
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('extension_id', 'invoice-inbox')
|
||||
.eq('key', 'settings')
|
||||
.single()
|
||||
@@ -84,7 +84,7 @@ async function handleDocumentUploaded(
|
||||
payload: EventPayload<'document.uploaded'>,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<void> {
|
||||
const { document, userId } = payload
|
||||
const { document, userId, companyId } = payload
|
||||
const log = ctx?.log ?? console
|
||||
|
||||
// Gate: Is it a supported file type?
|
||||
@@ -103,7 +103,7 @@ async function handleDocumentUploaded(
|
||||
const { data: existing } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('document_id', document.id)
|
||||
.limit(1)
|
||||
|
||||
@@ -157,7 +157,7 @@ async function handleDocumentUploaded(
|
||||
const { data: suppliers } = await supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
|
||||
if (suppliers && suppliers.length > 0) {
|
||||
const match = matchSupplier(extraction, suppliers)
|
||||
@@ -193,6 +193,7 @@ async function handleDocumentUploaded(
|
||||
inboxItem: updatedItem,
|
||||
confidence: extraction.confidence,
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -125,8 +125,8 @@ describe('Email Handler', () => {
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ user_id: 'user-1', value: { inboxEmail: 'test@inbox.com' } },
|
||||
{ user_id: 'user-2', value: { inboxEmail: 'other@inbox.com' } },
|
||||
{ user_id: 'user-1', company_id: 'company-1', value: { inboxEmail: 'test@inbox.com' } },
|
||||
{ user_id: 'user-2', company_id: 'company-2', value: { inboxEmail: 'other@inbox.com' } },
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
@@ -136,7 +136,7 @@ describe('Email Handler', () => {
|
||||
}
|
||||
|
||||
const result = await resolveUserFromEmail('test@inbox.com', mockClient)
|
||||
expect(result).toBe('user-1')
|
||||
expect(result).toEqual({ userId: 'user-1', companyId: 'company-1' })
|
||||
})
|
||||
|
||||
it('handles case-insensitive email matching', async () => {
|
||||
@@ -146,7 +146,7 @@ describe('Email Handler', () => {
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ user_id: 'user-1', value: { inboxEmail: 'Test@Inbox.Com' } },
|
||||
{ user_id: 'user-1', company_id: 'company-1', value: { inboxEmail: 'Test@Inbox.Com' } },
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
@@ -156,7 +156,7 @@ describe('Email Handler', () => {
|
||||
}
|
||||
|
||||
const result = await resolveUserFromEmail('test@inbox.com', mockClient)
|
||||
expect(result).toBe('user-1')
|
||||
expect(result).toEqual({ userId: 'user-1', companyId: 'company-1' })
|
||||
})
|
||||
|
||||
it('returns null when no matching email', async () => {
|
||||
|
||||
@@ -57,14 +57,14 @@ export async function resolveUserFromEmail(
|
||||
recipientEmail: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
serviceClient: any
|
||||
): Promise<string | null> {
|
||||
): Promise<{ userId: string; companyId: string } | null> {
|
||||
// Extract the local part (before @) to handle address variants
|
||||
const normalizedEmail = recipientEmail.toLowerCase().trim()
|
||||
|
||||
// Look up in extension_data where invoice-inbox settings store the inbox email
|
||||
const { data, error } = await serviceClient
|
||||
.from('extension_data')
|
||||
.select('user_id, value')
|
||||
.select('user_id, company_id, value')
|
||||
.eq('extension_id', 'invoice-inbox')
|
||||
.eq('key', 'settings')
|
||||
|
||||
@@ -75,7 +75,7 @@ export async function resolveUserFromEmail(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const settings = row.value as any
|
||||
if (settings?.inboxEmail && settings.inboxEmail.toLowerCase().trim() === normalizedEmail) {
|
||||
return row.user_id
|
||||
return { userId: row.user_id, companyId: row.company_id }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ describe('MCP Receipt Matcher', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(eventBus as { clear: ReturnType<typeof vi.fn> }).clear()
|
||||
eventBus.clear()
|
||||
const mock = createQueuedMockSupabase()
|
||||
supabase = mock.supabase
|
||||
enqueueMany = mock.enqueueMany
|
||||
|
||||
@@ -101,7 +101,7 @@ const VALID_VAT_TREATMENTS = [
|
||||
|
||||
async function stagePendingOperation(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
operationType: string,
|
||||
title: string,
|
||||
params: Record<string, unknown>,
|
||||
@@ -110,7 +110,7 @@ async function stagePendingOperation(
|
||||
const { data, error } = await supabase
|
||||
.from('pending_operations')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
operation_type: operationType,
|
||||
title,
|
||||
params,
|
||||
@@ -136,6 +136,7 @@ async function categorizeTransactionCore(
|
||||
category: TransactionCategory,
|
||||
vatTreatment: VatTreatment | undefined,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
supabase: SupabaseClient,
|
||||
confirm: boolean = false
|
||||
): Promise<{
|
||||
@@ -173,7 +174,7 @@ async function categorizeTransactionCore(
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', txId)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !transaction) {
|
||||
@@ -199,7 +200,7 @@ async function categorizeTransactionCore(
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, fiscal_year_start_month')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
@@ -280,6 +281,7 @@ async function categorizeTransactionCore(
|
||||
try {
|
||||
const journalEntry = await createTransactionJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
transaction as Transaction,
|
||||
mappingResult
|
||||
@@ -309,6 +311,7 @@ async function categorizeTransactionCore(
|
||||
account: mappingResult.debit_account,
|
||||
taxCode: mappingResult.vat_lines[0]?.account_number || '',
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -381,7 +384,7 @@ const tools: McpTool[] = [
|
||||
const { count: totalCount, error: countError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.is('journal_entry_id', null)
|
||||
|
||||
if (countError) throw new Error(`Database error: ${countError.message}`)
|
||||
@@ -391,7 +394,7 @@ const tools: McpTool[] = [
|
||||
.select(
|
||||
'id, date, description, amount, currency, merchant_name, reference, is_business, category'
|
||||
)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.is('journal_entry_id', null)
|
||||
.order('date', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
@@ -465,6 +468,7 @@ const tools: McpTool[] = [
|
||||
args.category as TransactionCategory,
|
||||
args.vat_treatment as VatTreatment | undefined,
|
||||
userId,
|
||||
userId,
|
||||
supabase,
|
||||
false // preview mode — execution happens via web UI commit
|
||||
)
|
||||
@@ -480,7 +484,7 @@ const tools: McpTool[] = [
|
||||
.from('transactions')
|
||||
.select('description, merchant_name, amount, currency')
|
||||
.eq('id', args.transaction_id as string)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
const txDesc = tx
|
||||
@@ -545,7 +549,7 @@ const tools: McpTool[] = [
|
||||
.select(
|
||||
'id, date, description, amount, currency, merchant_name, reference, is_business, category'
|
||||
)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.is('journal_entry_id', null)
|
||||
.order('date', { ascending: false })
|
||||
.limit(limit)
|
||||
@@ -581,7 +585,7 @@ const tools: McpTool[] = [
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, customer_type, email, org_number, vat_number, default_payment_terms, city, country')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.order('name')
|
||||
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
@@ -706,7 +710,7 @@ const tools: McpTool[] = [
|
||||
let query = supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, status, customer_id, total, currency, invoice_date, due_date, document_type, customers(name)', { count: 'exact' })
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status)
|
||||
@@ -825,7 +829,7 @@ const tools: McpTool[] = [
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', customerId)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (custError || !customer) {
|
||||
@@ -928,7 +932,7 @@ const tools: McpTool[] = [
|
||||
const { data: periods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
@@ -944,7 +948,7 @@ const tools: McpTool[] = [
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end')
|
||||
.eq('id', periodId)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (!period) throw new Error('Fiscal period not found.')
|
||||
@@ -953,7 +957,7 @@ const tools: McpTool[] = [
|
||||
const { data: lines, error } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, debit_amount, credit_amount, journal_entries!inner(status, user_id, fiscal_period_id)')
|
||||
.eq('journal_entries.user_id', userId)
|
||||
.eq('journal_entries.company_id', userId)
|
||||
.eq('journal_entries.fiscal_period_id', periodId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
|
||||
@@ -963,7 +967,7 @@ const tools: McpTool[] = [
|
||||
const { data: accounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
|
||||
const accountMap = new Map((accounts ?? []).map((a: { account_number: string; account_name: string }) => [a.account_number, a.account_name]))
|
||||
|
||||
@@ -1074,7 +1078,7 @@ const tools: McpTool[] = [
|
||||
const { data: lines, error } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, debit_amount, credit_amount, journal_entries!inner(entry_date, status, user_id)')
|
||||
.eq('journal_entries.user_id', userId)
|
||||
.eq('journal_entries.company_id', userId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
.gte('journal_entries.entry_date', startDate)
|
||||
.lte('journal_entries.entry_date', endDate)
|
||||
@@ -1178,7 +1182,7 @@ const tools: McpTool[] = [
|
||||
const { data: periods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
@@ -1194,7 +1198,7 @@ const tools: McpTool[] = [
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end')
|
||||
.eq('id', periodId)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (!period) throw new Error('Fiscal period not found.')
|
||||
@@ -1209,7 +1213,7 @@ const tools: McpTool[] = [
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('invoice_date, paid_at')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('status', 'paid')
|
||||
.not('paid_at', 'is', null),
|
||||
])
|
||||
@@ -1288,7 +1292,7 @@ const tools: McpTool[] = [
|
||||
const { data: periods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
@@ -1303,7 +1307,7 @@ const tools: McpTool[] = [
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end')
|
||||
.eq('id', periodId)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (!period) throw new Error('Fiscal period not found.')
|
||||
@@ -1357,7 +1361,7 @@ const tools: McpTool[] = [
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)')
|
||||
.eq('id', invoiceId)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) throw new Error('Invoice not found')
|
||||
@@ -1424,7 +1428,7 @@ const tools: McpTool[] = [
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)')
|
||||
.eq('id', invoiceId)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) throw new Error('Invoice not found')
|
||||
@@ -1480,7 +1484,7 @@ const tools: McpTool[] = [
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)')
|
||||
.eq('id', invoiceId)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) throw new Error('Invoice not found')
|
||||
@@ -1520,7 +1524,7 @@ const tools: McpTool[] = [
|
||||
const { data, error } = await supabase
|
||||
.from('suppliers')
|
||||
.select('id, name, supplier_type, email, phone, org_number, vat_number, default_expense_account, default_payment_terms, default_currency, city, country')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
@@ -1565,7 +1569,7 @@ const tools: McpTool[] = [
|
||||
let query = supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, total_sek, currency, vat_treatment, remaining_amount, supplier:suppliers(id, name)')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
|
||||
if (status !== 'all') {
|
||||
if (status === 'to_pay') {
|
||||
@@ -1614,7 +1618,7 @@ const tools: McpTool[] = [
|
||||
const { data, error } = await supabase
|
||||
.from('categorization_templates')
|
||||
.select('id, counterparty_name, counterparty_aliases, debit_account, credit_account, vat_treatment, vat_account, category, line_pattern, occurrence_count, confidence, last_seen_date, source')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('is_active', true)
|
||||
.order('occurrence_count', { ascending: false })
|
||||
.limit(limit)
|
||||
@@ -1669,7 +1673,7 @@ const tools: McpTool[] = [
|
||||
const { data: transactions, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.in('id', limitedIds)
|
||||
|
||||
if (txError) throw new Error(`Database error: ${txError.message}`)
|
||||
@@ -1679,7 +1683,7 @@ const tools: McpTool[] = [
|
||||
const { data: mappingRules } = await supabase
|
||||
.from('mapping_rules')
|
||||
.select('*')
|
||||
.or(`user_id.eq.${userId},user_id.is.null`)
|
||||
.or(`company_id.eq.${userId},company_id.is.null`)
|
||||
.eq('is_active', true)
|
||||
.order('priority', { ascending: false })
|
||||
|
||||
@@ -1687,7 +1691,7 @@ const tools: McpTool[] = [
|
||||
const { data: historicalTxns } = await supabase
|
||||
.from('transactions')
|
||||
.select('category')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.not('is_business', 'is', null)
|
||||
.neq('category', 'uncategorized')
|
||||
.neq('category', 'private')
|
||||
@@ -1763,7 +1767,7 @@ const tools: McpTool[] = [
|
||||
let query = supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, account_class, account_group, account_type, normal_balance, is_active, description')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.order('sort_order')
|
||||
|
||||
if (activeOnly) query = query.eq('is_active', true)
|
||||
@@ -1807,7 +1811,7 @@ const tools: McpTool[] = [
|
||||
const { data: periods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
@@ -1820,7 +1824,7 @@ const tools: McpTool[] = [
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end')
|
||||
.eq('id', periodId)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (!period) throw new Error('Fiscal period not found.')
|
||||
@@ -1868,7 +1872,7 @@ const tools: McpTool[] = [
|
||||
const { data: periods } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
@@ -1984,7 +1988,7 @@ const tools: McpTool[] = [
|
||||
.from('transactions')
|
||||
.select('id, description, merchant_name, amount, currency, invoice_id')
|
||||
.eq('id', transactionId)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (txError || !transaction) throw new Error('Transaction not found')
|
||||
@@ -1995,7 +1999,7 @@ const tools: McpTool[] = [
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)')
|
||||
.eq('id', invoiceId)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (invError || !invoice) throw new Error('Invoice not found')
|
||||
@@ -2042,7 +2046,7 @@ const tools: McpTool[] = [
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end, status')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.order('period_start', { ascending: false })
|
||||
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
@@ -2152,7 +2156,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
})
|
||||
}
|
||||
|
||||
const { userId, scopes: keyScopes } = authResult
|
||||
const { userId, companyId, scopes: keyScopes } = authResult
|
||||
const supabase = createServiceClientNoCookies()
|
||||
|
||||
// ── Parse JSON-RPC ──
|
||||
@@ -2244,7 +2248,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.execute(toolArgs, userId, supabase)
|
||||
const result = await tool.execute(toolArgs, companyId, supabase)
|
||||
const response: Record<string, unknown> = {
|
||||
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Bell, BellOff, Loader2, Moon } from 'lucide-react'
|
||||
import type { NotificationSettings as NotificationSettingsType } from '@/types'
|
||||
|
||||
@@ -18,6 +19,7 @@ interface NotificationSettingsProps {
|
||||
export function NotificationSettings({ onSettingsChange }: NotificationSettingsProps) {
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
const { company } = useCompany()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
@@ -49,11 +51,12 @@ export function NotificationSettings({ onSettingsChange }: NotificationSettingsP
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return
|
||||
if (!company) return
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('company_id', company.id)
|
||||
.single()
|
||||
|
||||
if (!error && data) {
|
||||
@@ -64,6 +67,7 @@ export function NotificationSettings({ onSettingsChange }: NotificationSettingsP
|
||||
.from('notification_settings')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
company_id: company.id,
|
||||
tax_deadlines_enabled: true,
|
||||
invoice_reminders_enabled: true,
|
||||
push_enabled: true,
|
||||
|
||||
@@ -123,7 +123,7 @@ async function handleDeleteSubscribe(
|
||||
const { error } = await supabase
|
||||
.from('push_subscriptions')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('endpoint', endpoint)
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -59,7 +59,7 @@ export async function getSettings(userId: string): Promise<PushNotificationSetti
|
||||
.select(
|
||||
'period_locked_enabled, period_year_closed_enabled, invoice_sent_enabled, receipt_extracted_enabled, receipt_matched_enabled, missing_underlag_enabled'
|
||||
)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (!data) return { ...DEFAULT_SETTINGS }
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function sendTaxDeadlineNotifications(
|
||||
const { data: settings } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('tax_deadlines_enabled')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (settings && !settings.tax_deadlines_enabled) {
|
||||
@@ -155,7 +155,7 @@ export async function sendInvoiceNotifications(
|
||||
const { data: settings } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('invoice_reminders_enabled')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (settings && !settings.invoice_reminders_enabled) {
|
||||
@@ -271,7 +271,7 @@ export async function sendMissingUnderlagNotifications(
|
||||
const { data: settings } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('missing_underlag_enabled')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (settings && settings.missing_underlag_enabled === false) {
|
||||
|
||||
@@ -238,7 +238,7 @@ async function wasNotificationSent(
|
||||
const { data } = await supabase
|
||||
.from('notification_log')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('notification_type', notificationType)
|
||||
.eq('reference_id', referenceId)
|
||||
.eq('days_before', daysBefore)
|
||||
@@ -270,7 +270,7 @@ export async function getUserSubscriptions(
|
||||
const { data } = await supabase
|
||||
.from('push_subscriptions')
|
||||
.select('endpoint, p256dh, auth')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('is_active', true)
|
||||
|
||||
return data || []
|
||||
@@ -283,7 +283,7 @@ export async function getUserNotificationSettings(
|
||||
const { data } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
return data
|
||||
|
||||
@@ -150,6 +150,7 @@ describe('handleDocumentUploaded gates', () => {
|
||||
storage_path: 'docs/file.pdf',
|
||||
} as never,
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -173,6 +174,7 @@ describe('handleDocumentUploaded gates', () => {
|
||||
storage_path: 'docs/receipt.jpg',
|
||||
} as never,
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -205,6 +207,7 @@ describe('handleDocumentUploaded gates', () => {
|
||||
storage_path: 'docs/receipt.jpg',
|
||||
} as never,
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -230,6 +233,7 @@ describe('handleTransactionSynced gates', () => {
|
||||
payload: {
|
||||
transactions: [{ id: 'tx1', amount: -100 }] as never,
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -248,6 +252,7 @@ describe('handleTransactionSynced gates', () => {
|
||||
payload: {
|
||||
transactions: [{ id: 'tx1', amount: 500 }] as never, // income
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ async function handleListReceipts(
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`, { count: 'exact' })
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
@@ -61,6 +61,7 @@ async function handleUpload(
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const companyId = ctx!.companyId
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
@@ -106,7 +107,7 @@ async function handleUpload(
|
||||
let wormDocumentId: string | null = null
|
||||
try {
|
||||
const { uploadDocument } = await import('@/lib/core/documents/document-service')
|
||||
const wormDoc = await uploadDocument(supabase, userId, {
|
||||
const wormDoc = await uploadDocument(supabase, userId, companyId, {
|
||||
name: imageFile.name,
|
||||
buffer: arrayBuffer,
|
||||
type: imageFile.type,
|
||||
@@ -231,6 +232,7 @@ async function handleUpload(
|
||||
documentId: wormDocumentId,
|
||||
confidence: extraction.confidence,
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -290,7 +292,7 @@ async function handleGetQueue(
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (status) {
|
||||
@@ -312,20 +314,20 @@ async function handleGetQueue(
|
||||
const { count: unmatchedReceiptsCount } = await supabase
|
||||
.from('receipts')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('status', 'confirmed')
|
||||
.is('matched_transaction_id', null)
|
||||
|
||||
const { count: pendingReviewCount } = await supabase
|
||||
.from('receipts')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('status', 'extracted')
|
||||
|
||||
const { count: unmatchedTransactionsCount } = await supabase
|
||||
.from('transactions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.lt('amount', 0)
|
||||
.is('receipt_id', null)
|
||||
|
||||
@@ -333,7 +335,7 @@ async function handleGetQueue(
|
||||
const { data: recentActivity } = await supabase
|
||||
.from('receipts')
|
||||
.select('created_at')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('status', 'confirmed')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(30)
|
||||
@@ -394,7 +396,7 @@ async function handleGetReceipt(
|
||||
matched_transaction:transactions(*)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
@@ -431,7 +433,7 @@ async function handleDeleteReceipt(
|
||||
.from('receipts')
|
||||
.select('image_url, matched_transaction_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (!receipt) {
|
||||
@@ -451,7 +453,7 @@ async function handleDeleteReceipt(
|
||||
.from('receipts')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
@@ -481,6 +483,7 @@ async function handleConfirm(
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const companyId = ctx!.companyId
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
@@ -496,7 +499,7 @@ async function handleConfirm(
|
||||
.from('receipts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !receipt) {
|
||||
@@ -544,7 +547,7 @@ async function handleConfirm(
|
||||
.from('transactions')
|
||||
.select('id')
|
||||
.eq('id', body.matched_transaction_id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (!txError && transaction) {
|
||||
@@ -595,6 +598,7 @@ async function handleConfirm(
|
||||
businessTotal: Math.round(businessTotal * 100) / 100,
|
||||
privateTotal: Math.round(privateTotal * 100) / 100,
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -625,7 +629,7 @@ async function handleFindMatches(
|
||||
.from('receipts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (receiptError || !receipt) {
|
||||
@@ -643,7 +647,7 @@ async function handleFindMatches(
|
||||
const { data: transactions, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.is('receipt_id', null)
|
||||
.lt('amount', 0) // Only expenses
|
||||
.gte('date', startDate.toISOString().split('T')[0])
|
||||
@@ -678,6 +682,7 @@ async function handleLinkMatch(
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const companyId = ctx!.companyId
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
@@ -700,7 +705,7 @@ async function handleLinkMatch(
|
||||
.from('receipts')
|
||||
.select('*, line_items:receipt_line_items(*)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (receiptError || !receipt) {
|
||||
@@ -712,7 +717,7 @@ async function handleLinkMatch(
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', transaction_id)
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (txError || !transaction) {
|
||||
@@ -753,6 +758,7 @@ async function handleLinkMatch(
|
||||
confidence: match_confidence || 0,
|
||||
autoMatched: false,
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function getSettings(userId: string): Promise<ReceiptOcrSettings> {
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.eq('extension_id', 'receipt-ocr')
|
||||
.eq('key', 'settings')
|
||||
.single()
|
||||
@@ -87,7 +87,7 @@ async function handleDocumentUploaded(
|
||||
payload: EventPayload<'document.uploaded'>,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<void> {
|
||||
const { document, userId } = payload
|
||||
const { document, userId, companyId } = payload
|
||||
const log = ctx?.log ?? console
|
||||
|
||||
// Gate: Is it an image?
|
||||
@@ -208,6 +208,7 @@ async function handleDocumentUploaded(
|
||||
documentId: document.id,
|
||||
confidence: extraction.confidence,
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -224,7 +225,7 @@ async function handleTransactionSynced(
|
||||
payload: EventPayload<'transaction.synced'>,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<void> {
|
||||
const { transactions: syncedTransactions, userId } = payload
|
||||
const { transactions: syncedTransactions, userId, companyId } = payload
|
||||
const log = ctx?.log ?? console
|
||||
|
||||
// Gate: Is autoMatchEnabled?
|
||||
@@ -248,7 +249,7 @@ async function handleTransactionSynced(
|
||||
const { data: unmatchedReceipts, error: fetchError } = await supabase
|
||||
.from('receipts')
|
||||
.select('*, line_items:receipt_line_items(*)')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.in('status', ['extracted', 'confirmed'])
|
||||
.is('matched_transaction_id', null)
|
||||
|
||||
@@ -291,6 +292,7 @@ async function handleTransactionSynced(
|
||||
confidence: match.confidence,
|
||||
autoMatched: true,
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('processReceiptFromDocument', () => {
|
||||
setResult(receipt)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
|
||||
const result = await processReceiptFromDocument(supabase as any, 'user-1', 'company-1', 'base64data', 'image/jpeg', {
|
||||
documentId: 'doc-1',
|
||||
source: 'email',
|
||||
emailFrom: 'sender@example.com',
|
||||
@@ -133,7 +133,7 @@ describe('processReceiptFromDocument', () => {
|
||||
setResult(receipt)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
|
||||
await processReceiptFromDocument(supabase as any, 'user-1', 'company-1', 'base64data', 'image/jpeg', {
|
||||
documentId: 'doc-1',
|
||||
source: 'upload',
|
||||
storageUrl: 'https://storage.example.com/file.jpg',
|
||||
@@ -146,6 +146,7 @@ describe('processReceiptFromDocument', () => {
|
||||
documentId: 'doc-1',
|
||||
confidence: 0.92,
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
}),
|
||||
})
|
||||
})
|
||||
@@ -155,7 +156,7 @@ describe('processReceiptFromDocument', () => {
|
||||
setResult(null, { message: 'insert failed' })
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await expect(processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
|
||||
await expect(processReceiptFromDocument(supabase as any, 'user-1', 'company-1', 'base64data', 'image/jpeg', {
|
||||
documentId: null,
|
||||
source: 'upload',
|
||||
storageUrl: 'https://storage.example.com/file.jpg',
|
||||
@@ -167,7 +168,7 @@ describe('processReceiptFromDocument', () => {
|
||||
setResult({ id: 'receipt-1', user_id: 'user-1' })
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
|
||||
await processReceiptFromDocument(supabase as any, 'user-1', 'company-1', 'base64data', 'image/jpeg', {
|
||||
documentId: null,
|
||||
source: 'upload',
|
||||
storageUrl: 'https://storage.example.com/file.jpg',
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface ProcessedReceipt {
|
||||
export async function processReceiptFromDocument(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
base64: string,
|
||||
mimeType: string,
|
||||
opts: ReceiptPipelineOptions
|
||||
@@ -123,7 +124,7 @@ export async function processReceiptFromDocument(
|
||||
const { data: transactions } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.is('receipt_id', null)
|
||||
.lt('amount', 0)
|
||||
.gte('date', dateFrom.toISOString().split('T')[0])
|
||||
@@ -163,6 +164,7 @@ export async function processReceiptFromDocument(
|
||||
documentId: opts.documentId,
|
||||
confidence: extraction.confidence,
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ export const skatteverketExtension: Extension = {
|
||||
const { data: settingsData } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', user.id)
|
||||
.eq('company_id', user.id)
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.eq('key', 'oauth_state')
|
||||
.single()
|
||||
@@ -122,7 +122,7 @@ export const skatteverketExtension: Extension = {
|
||||
const { data: redirectData } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', user.id)
|
||||
.eq('company_id', user.id)
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.eq('key', 'oauth_redirect_uri')
|
||||
.single()
|
||||
@@ -138,7 +138,7 @@ export const skatteverketExtension: Extension = {
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.delete()
|
||||
.eq('user_id', user.id)
|
||||
.eq('company_id', user.id)
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.eq('key', 'oauth_state')
|
||||
|
||||
@@ -165,7 +165,7 @@ export const skatteverketExtension: Extension = {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
|
||||
const tokens = await getTokens(ctx.supabase, ctx.userId)
|
||||
const tokens = await getTokens(ctx.supabase, ctx.companyId)
|
||||
if (!tokens) {
|
||||
return NextResponse.json({ connected: false })
|
||||
}
|
||||
@@ -192,7 +192,7 @@ export const skatteverketExtension: Extension = {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
|
||||
await deleteTokens(ctx.supabase, ctx.userId)
|
||||
await deleteTokens(ctx.supabase, ctx.companyId)
|
||||
return NextResponse.json({ success: true })
|
||||
},
|
||||
},
|
||||
@@ -220,7 +220,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
'POST',
|
||||
`/kontrollera/${redovisare}/${redovisningsperiod}`,
|
||||
momsuppgift
|
||||
@@ -266,7 +266,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
'POST',
|
||||
`/utkast/${redovisare}/${redovisningsperiod}`,
|
||||
momsuppgift
|
||||
@@ -316,7 +316,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
'GET',
|
||||
`/utkast/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
@@ -355,7 +355,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
'DELETE',
|
||||
`/utkast/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
@@ -392,7 +392,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
'PUT',
|
||||
`/las/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
@@ -439,7 +439,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
'DELETE',
|
||||
`/las/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
@@ -483,7 +483,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
'GET',
|
||||
`/inlamnat/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
@@ -522,7 +522,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
const response = await skvRequest(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
'GET',
|
||||
`/beslutat/${redovisare}/${redovisningsperiod}`
|
||||
)
|
||||
@@ -578,7 +578,7 @@ async function parseDeclarationRequest(
|
||||
const { data: settings } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('org_number, entity_type')
|
||||
.eq('user_id', ctx.userId)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.single()
|
||||
|
||||
if (!settings?.org_number) {
|
||||
@@ -591,7 +591,7 @@ async function parseDeclarationRequest(
|
||||
// Calculate VAT declaration from the general ledger
|
||||
const declaration = await calculateVatDeclaration(
|
||||
ctx.supabase,
|
||||
ctx.userId,
|
||||
ctx.companyId,
|
||||
periodType,
|
||||
year,
|
||||
period
|
||||
|
||||
@@ -79,7 +79,7 @@ export async function getTokens(
|
||||
const { data, error } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('access_token, refresh_token, expires_at, refresh_count, scope')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (error || !data) return null
|
||||
@@ -108,5 +108,5 @@ export async function deleteTokens(
|
||||
await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', userId)
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('TIC lookup route', () => {
|
||||
it('returns full lookup result on happy path', async () => {
|
||||
mockSearch.mockResolvedValue(mockDoc)
|
||||
mockBank.mockResolvedValue([
|
||||
{ bankAccountType: 1, accountNumber: '123-456', swift_BIC: null },
|
||||
{ bankAccountType: 1, accountNumber: '123-456', swift_BIC: undefined },
|
||||
])
|
||||
mockSNI.mockResolvedValue([
|
||||
{ sni_2007Code: '62010', sni_2007Name: 'Dataprogrammering' },
|
||||
|
||||
@@ -77,7 +77,7 @@ const mockDoc: TICCompanyDocument = {
|
||||
|
||||
function mockAllSupplementary() {
|
||||
mockBank.mockResolvedValue([
|
||||
{ bankAccountType: 1, accountNumber: '123-456', swift_BIC: null },
|
||||
{ bankAccountType: 1, accountNumber: '123-456', swift_BIC: undefined },
|
||||
])
|
||||
mockSNI.mockResolvedValue([
|
||||
{ sni_2007Code: '62010', sni_2007Name: 'Dataprogrammering' },
|
||||
|
||||
Reference in New Issue
Block a user