Refactor to generic ERP base

Remove influencer-specific features (campaigns, TikTok, gifts, shadow ledger,
contracts, briefings) and consolidate into a clean ERP foundation with core
bookkeeping, invoicing, receipts, tax reporting, and calendar functionality.
Reorganize database migrations into a clean numbered sequence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-13 15:39:13 +01:00
co-authored by Claude Opus 4.6
parent a25d10a528
commit 0aecf5b41c
184 changed files with 1418 additions and 24763 deletions
-231
View File
@@ -1,231 +0,0 @@
/**
* Customer Matcher
*
* Fuzzy matching of extracted parties to existing customers.
*/
import Fuse, { IFuseOptions } from 'fuse.js'
import type {
Customer,
ExtractedParty,
CustomerMatchResult,
CreateCustomerInput,
CustomerType,
} from '@/types'
// Fuse.js configuration for fuzzy name matching
const FUSE_OPTIONS: IFuseOptions<Customer> = {
keys: ['name'],
threshold: 0.3, // Lower = stricter matching
includeScore: true,
minMatchCharLength: 3,
}
/**
* Match an extracted party to existing customers
*/
export function matchCustomer(
extractedParty: ExtractedParty,
customers: Customer[]
): CustomerMatchResult {
// Try exact org number match first
if (extractedParty.orgNumber) {
const normalizedOrgNumber = normalizeOrgNumber(extractedParty.orgNumber)
const exactMatch = customers.find(
(c) => c.org_number && normalizeOrgNumber(c.org_number) === normalizedOrgNumber
)
if (exactMatch) {
return {
matchType: 'exact',
customer: exactMatch,
confidence: 1.0,
matchedOn: ['org_number'],
}
}
}
// Try email domain match
if (extractedParty.email) {
const domain = extractEmailDomain(extractedParty.email)
if (domain) {
const emailMatch = customers.find((c) => {
if (!c.email) return false
return extractEmailDomain(c.email) === domain
})
if (emailMatch) {
return {
matchType: 'probable',
customer: emailMatch,
confidence: 0.8,
matchedOn: ['email'],
}
}
}
}
// Try fuzzy name match
if (extractedParty.name && customers.length > 0) {
const fuse = new Fuse(customers, FUSE_OPTIONS)
const results = fuse.search(extractedParty.name)
if (results.length > 0) {
const bestMatch = results[0]
const score = bestMatch.score ?? 1
const confidence = 1 - score // Fuse score is inverse
if (confidence >= 0.7) {
return {
matchType: 'probable',
customer: bestMatch.item,
confidence,
matchedOn: ['name'],
}
}
}
}
// No match found - suggest creating new customer
return {
matchType: 'none',
customer: null,
confidence: 0,
matchedOn: [],
suggestedNewCustomer: createSuggestedCustomer(extractedParty),
}
}
/**
* Match both brand and agency from extraction
*/
export function matchParties(
brandParty: ExtractedParty | null,
agencyParty: ExtractedParty | null,
customers: Customer[]
): {
brandMatch: CustomerMatchResult | null
agencyMatch: CustomerMatchResult | null
} {
return {
brandMatch: brandParty ? matchCustomer(brandParty, customers) : null,
agencyMatch: agencyParty ? matchCustomer(agencyParty, customers) : null,
}
}
/**
* Create a suggested customer input from extracted party
*/
function createSuggestedCustomer(party: ExtractedParty): Partial<CreateCustomerInput> {
const suggested: Partial<CreateCustomerInput> = {
name: party.name,
customer_type: determineCustomerType(party),
}
if (party.orgNumber) {
suggested.org_number = party.orgNumber
}
if (party.email) {
suggested.email = party.email
}
// Default to Sweden for Swedish org numbers
if (party.orgNumber) {
suggested.country = 'Sweden'
}
return suggested
}
/**
* Determine customer type based on party info
*/
function determineCustomerType(party: ExtractedParty): CustomerType {
// Swedish org numbers indicate Swedish business
if (party.orgNumber) {
return 'swedish_business'
}
// Without org number, default to individual
return 'individual'
}
/**
* Normalize org number for comparison
*/
function normalizeOrgNumber(orgNumber: string): string {
// Remove all non-digits and return
return orgNumber.replace(/\D/g, '')
}
/**
* Extract email domain
*/
function extractEmailDomain(email: string): string | null {
const match = email.toLowerCase().match(/@([^@]+)$/)
if (match) {
const domain = match[1]
// Ignore generic email providers
const genericDomains = [
'gmail.com',
'hotmail.com',
'outlook.com',
'yahoo.com',
'icloud.com',
'live.com',
'me.com',
]
if (!genericDomains.includes(domain)) {
return domain
}
}
return null
}
/**
* Calculate overall match quality
*/
export function calculateMatchQuality(match: CustomerMatchResult): 'excellent' | 'good' | 'poor' | 'none' {
if (match.matchType === 'exact') {
return 'excellent'
}
if (match.matchType === 'probable') {
if (match.confidence >= 0.9) return 'excellent'
if (match.confidence >= 0.7) return 'good'
return 'poor'
}
return 'none'
}
/**
* Check if two customers might be the same
*/
export function areLikelySameCustomer(a: Customer, b: Customer): boolean {
// Same org number
if (a.org_number && b.org_number) {
if (normalizeOrgNumber(a.org_number) === normalizeOrgNumber(b.org_number)) {
return true
}
}
// Same email domain (non-generic)
if (a.email && b.email) {
const domainA = extractEmailDomain(a.email)
const domainB = extractEmailDomain(b.email)
if (domainA && domainB && domainA === domainB) {
return true
}
}
// Fuzzy name match
const fuse = new Fuse([a], { ...FUSE_OPTIONS, threshold: 0.2 })
const results = fuse.search(b.name)
if (results.length > 0 && results[0].score !== undefined && results[0].score < 0.2) {
return true
}
return false
}