Files
accounted/extensions/general/receipt-ocr/lib/receipt-pipeline.ts
T
MattssonandClaude Opus 4.6 0dd1f5ebc1 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>
2026-03-31 16:41:52 +02:00

173 lines
5.6 KiB
TypeScript

/**
* Receipt Pipeline — shared, reusable receipt processing function.
*
* SERVER-ONLY: uses receipt-analyzer (Anthropic SDK).
*
* Extracts receipt data, categorizes line items, inserts records,
* and attempts auto-matching against bank transactions.
*/
import 'server-only'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Receipt, ReceiptExtractionResult } from '@/types'
import { analyzeReceipt } from './receipt-analyzer'
import { processLineItems, getDefaultClassification } from './receipt-categorizer'
import { autoMatchReceipts } from './receipt-matcher'
import { eventBus } from '@/lib/events/bus'
export interface ReceiptPipelineOptions {
documentId: string | null
source: 'upload' | 'camera' | 'email'
emailFrom?: string
storageUrl: string
preExtracted?: ReceiptExtractionResult
}
export interface ProcessedReceipt {
receipt: Receipt
lineItems: unknown[]
matchedTransaction?: { transactionId: string; confidence: number }
}
/**
* Process a document through the receipt pipeline:
* 1. Analyze with Claude Vision
* 2. Categorize line items
* 3. Insert receipt + line items
* 4. Auto-match against unmatched transactions
* 5. Emit receipt.extracted event
*/
export async function processReceiptFromDocument(
supabase: SupabaseClient,
userId: string,
companyId: string,
base64: string,
mimeType: string,
opts: ReceiptPipelineOptions
): Promise<ProcessedReceipt> {
// 1. Use pre-extracted data if available, otherwise analyze with Claude Vision
const validImageType = mimeType as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
const extraction = opts.preExtracted ?? await analyzeReceipt(base64, validImageType)
// 2. Categorize line items and apply default business classification
const processedLineItems = processLineItems(extraction.lineItems)
const { defaultIsBusiness } = getDefaultClassification(
extraction.flags.isRestaurant,
extraction.flags.isSystembolaget
)
// 3. Insert receipt record
const { data: receipt, error: insertError } = await supabase
.from('receipts')
.insert({
user_id: userId,
image_url: opts.storageUrl,
status: 'extracted',
extraction_confidence: extraction.confidence,
merchant_name: extraction.merchant.name,
merchant_org_number: extraction.merchant.orgNumber,
merchant_vat_number: extraction.merchant.vatNumber,
receipt_date: extraction.receipt.date,
receipt_time: extraction.receipt.time,
total_amount: extraction.totals.total,
currency: extraction.receipt.currency,
vat_amount: extraction.totals.vatAmount,
is_restaurant: extraction.flags.isRestaurant,
is_systembolaget: extraction.flags.isSystembolaget,
is_foreign_merchant: extraction.flags.isForeignMerchant,
raw_extraction: extraction,
document_id: opts.documentId,
source: opts.source,
email_from: opts.emailFrom ?? null,
})
.select()
.single()
if (insertError || !receipt) {
throw new Error(`Failed to create receipt: ${insertError?.message}`)
}
// 4. Insert line items
if (processedLineItems.length > 0) {
const lineItemsToInsert = processedLineItems.map((item, index) => ({
receipt_id: receipt.id,
description: item.description,
quantity: item.quantity,
unit_price: item.unitPrice,
line_total: item.lineTotal,
vat_rate: item.vatRate,
vat_amount:
item.vatRate && item.lineTotal
? Math.round((item.lineTotal * item.vatRate) / (100 + item.vatRate) * 100) / 100
: null,
extraction_confidence: item.confidence,
suggested_category: item.suggestedCategory,
category: item.category,
bas_account: item.basAccount,
is_business: defaultIsBusiness,
sort_order: index,
}))
await supabase.from('receipt_line_items').insert(lineItemsToInsert)
}
// 5. Auto-match against unmatched expense transactions (±7 days from receipt date)
let matchedTransaction: ProcessedReceipt['matchedTransaction'] | undefined
if (extraction.receipt.date && extraction.totals.total) {
const receiptDate = new Date(extraction.receipt.date)
const dateFrom = new Date(receiptDate)
dateFrom.setDate(dateFrom.getDate() - 7)
const dateTo = new Date(receiptDate)
dateTo.setDate(dateTo.getDate() + 7)
const { data: transactions } = await supabase
.from('transactions')
.select('*')
.eq('company_id', userId)
.is('receipt_id', null)
.lt('amount', 0)
.gte('date', dateFrom.toISOString().split('T')[0])
.lte('date', dateTo.toISOString().split('T')[0])
if (transactions && transactions.length > 0) {
const matches = autoMatchReceipts([receipt], transactions, 0.8)
if (matches.length > 0) {
const best = matches[0]
matchedTransaction = {
transactionId: best.match.transaction.id,
confidence: best.match.confidence,
}
// Link receipt to transaction
await supabase
.from('receipts')
.update({
matched_transaction_id: best.match.transaction.id,
match_confidence: best.match.confidence,
})
.eq('id', receipt.id)
await supabase
.from('transactions')
.update({ receipt_id: receipt.id })
.eq('id', best.match.transaction.id)
}
}
}
// 6. Emit event
await eventBus.emit({
type: 'receipt.extracted',
payload: {
receipt,
documentId: opts.documentId,
confidence: extraction.confidence,
userId,
companyId,
},
})
return { receipt, lineItems: processedLineItems, matchedTransaction }
}