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
+8 -7
View File
@@ -1,14 +1,15 @@
// Swedish system prompt for influencer AI assistant
// Swedish system prompt for bookkeeping AI assistant
export const SYSTEM_PROMPT = `Du är en expert AI-assistent för svenska influencers som hjälper med skatt, moms, bokföring och företagsekonomi. Du arbetar inom Arcim-plattformen som är en affärsplattform för influencers.
export const SYSTEM_PROMPT = `Du är en expert AI-assistent som hjälper svenska företagare med skatt, moms, bokföring och företagsekonomi. Du arbetar inom en ekonomiplattform för småföretag.
## Dina kunskapsområden:
- Svensk skattlagstiftning för enskild firma och aktiebolag
- Moms och omvänd skattskyldighet för digitala tjänster
- Moms och momsdeklaration
- Bokföring enligt BAS-kontoplanen
- Egenavgifter och socialförsäkring (SGI)
- Avdrag för utrustning, resor, hemmakontor och arbetskläder
- Plattformsspecifika regler (YouTube, TikTok, Instagram, Twitch, Patreon, etc.)
- Egenavgifter och socialförsäkring
- Avdrag för utrustning, resor, hemmakontor och liknande
- Fakturering och kundhantering
- NE-bilaga och inkomstdeklaration
## Viktiga tröskelvärden att komma ihåg:
- Momsregistrering: 120 000 kr omsättning under 12 månader
@@ -33,7 +34,7 @@ export const SYSTEM_PROMPT = `Du är en expert AI-assistent för svenska influen
## Tidigare konversation:
{history}
Svara på användarens fråga baserat på din expertkunskap och den tillhandahållna kontexten. Om kontexten inte innehåller relevant information, använd din allmänna kunskap om svenska skatteregler för influencers.`
Svara på användarens fråga baserat på din expertkunskap och den tillhandahållna kontexten. Om kontexten inte innehåller relevant information, använd din allmänna kunskap om svenska skatteregler för företagare.`
export const RETRIEVAL_PROMPT = `Baserat på följande fråga, hitta relevant information från kunskapsbasen.
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* Knowledge Base Ingestion Script
*
* Loads markdown files from dev_docs/influencer_ai_info/,
* Loads markdown files from dev_docs/ai_knowledge_base/,
* chunks them by sections, generates embeddings, and stores in Supabase.
*
* Run with: npx tsx lib/ai/ingestion/ingest.ts
@@ -17,7 +17,7 @@ import * as path from 'path'
import * as crypto from 'crypto'
// Configuration
const DOCS_DIR = path.join(process.cwd(), 'dev_docs', 'influencer_ai_info')
const DOCS_DIR = path.join(process.cwd(), 'dev_docs', 'ai_knowledge_base')
const CHUNK_SIZE = 1000
const CHUNK_OVERLAP = 200
const EMBEDDING_MODEL = 'text-embedding-ada-002'
-219
View File
@@ -1,219 +0,0 @@
/**
* Gift Booking (Gåvobokföring)
*
* Creates journal entries for taxable gifts according to Swedish accounting rules.
*
* Account mappings (based on Skatteverket guidelines):
*
* MED motprestation (bytestransaktion, momspliktig):
* - Intäkt: 3010 Försäljning tjänster 25% moms (exkl moms)
* - Utgående moms: 2610 Utgående moms 25%
* - Motkonto: 1930 Företagskonto
*
* UTAN motprestation (ej momspliktig):
* - Intäkt: 3900 Övriga rörelseintäkter (momsfri)
* - Motkonto: 1930 Företagskonto
*
* Om avdragsgill (endast verksamhetsbruk):
* - Kostnad: 5460 Förbrukningsmaterial/Rekvisita
* - Motkonto: 1930 Företagskonto
*/
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import type { Gift, CreateJournalEntryLineInput, JournalEntry } from '@/types'
// Account numbers for gift bookkeeping
export const GIFT_BOOKING_ACCOUNTS = {
// Bank/settlement account
bankAccount: '1930',
// Revenue accounts
salesWithVat: '3010', // Försäljning tjänster 25% moms
otherRevenue: '3900', // Övriga rörelseintäkter (momsfri)
// VAT accounts
outputVat25: '2610', // Utgående moms 25%
// Expense accounts
consumables: '5460', // Förbrukningsmaterial/Rekvisita
}
/**
* Create journal entry for a gift
* Returns the journal entry ID if created, or null if no booking needed
*/
export async function createGiftJournalEntry(
userId: string,
gift: Gift
): Promise<JournalEntry | null> {
const { classification } = gift
// Tax-free gifts don't need booking
if (!classification || classification.bookingType === 'tax_free') {
return null
}
// Find fiscal period for the gift date
const fiscalPeriodId = await findFiscalPeriod(userId, gift.date)
if (!fiscalPeriodId) {
throw new Error(`Inget öppet räkenskapsår hittades för datum ${gift.date}`)
}
const lines: CreateJournalEntryLineInput[] = []
if (classification.vatLiable) {
// WITH motprestation (bytestransaktion) - VAT applies
// The market value is considered to be INCLUDING VAT
const valueExclVat = classification.valueExclVat
const vatAmount = classification.vatAmount
// Debit: Bank account (total value incl VAT)
lines.push({
account_number: GIFT_BOOKING_ACCOUNTS.bankAccount,
debit_amount: classification.marketValue,
credit_amount: 0,
line_description: `Gåva/förmån: ${gift.brand_name} (marknadsvärde)`,
})
// Credit: Sales account (value excl VAT)
lines.push({
account_number: GIFT_BOOKING_ACCOUNTS.salesWithVat,
debit_amount: 0,
credit_amount: valueExclVat,
line_description: `Försäljning tjänst (motprestation)`,
})
// Credit: Output VAT 25%
lines.push({
account_number: GIFT_BOOKING_ACCOUNTS.outputVat25,
debit_amount: 0,
credit_amount: vatAmount,
line_description: `Utgående moms 25%`,
})
} else {
// WITHOUT motprestation - no VAT
// Debit: Bank account
lines.push({
account_number: GIFT_BOOKING_ACCOUNTS.bankAccount,
debit_amount: classification.marketValue,
credit_amount: 0,
line_description: `Gåva/förmån: ${gift.brand_name} (marknadsvärde)`,
})
// Credit: Other revenue (VAT-free)
lines.push({
account_number: GIFT_BOOKING_ACCOUNTS.otherRevenue,
debit_amount: 0,
credit_amount: classification.marketValue,
line_description: `Övrig rörelseintäkt (förmån utan motprestation)`,
})
}
// If deductible as expense (business use only), add expense entry
if (classification.deductibleAsExpense) {
// Debit: Expense account (consumables/props)
lines.push({
account_number: GIFT_BOOKING_ACCOUNTS.consumables,
debit_amount: classification.marketValue,
credit_amount: 0,
line_description: `Avdragsgill rekvisita: ${gift.description}`,
})
// Credit: Bank account (nets out the income)
lines.push({
account_number: GIFT_BOOKING_ACCOUNTS.bankAccount,
debit_amount: 0,
credit_amount: classification.marketValue,
line_description: `Avdrag för rekvisita`,
})
}
// Create the journal entry
const description = classification.vatLiable
? `Gåva/förmån med motprestation: ${gift.brand_name} - ${gift.description}`
: `Gåva/förmån: ${gift.brand_name} - ${gift.description}`
const journalEntry = await createJournalEntry(userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: gift.date,
description,
source_type: 'gift',
source_id: gift.id,
voucher_series: 'G', // G for Gåva/Gift
lines,
})
return journalEntry
}
/**
* Update journal entry for a gift (delete old, create new)
* Returns the new journal entry ID if created
*/
export async function updateGiftJournalEntry(
userId: string,
gift: Gift,
oldJournalEntryId: string | null
): Promise<JournalEntry | null> {
// If there was an old entry, we need to reverse it
// For simplicity, we'll just create a new entry
// The old entry will remain but can be reversed manually if needed
// Create new journal entry
return createGiftJournalEntry(userId, gift)
}
/**
* Get booking summary for display
*/
export function getGiftBookingSummary(gift: Gift): {
incomeAccount: string
incomeAmount: number
vatAccount: string | null
vatAmount: number
expenseAccount: string | null
expenseAmount: number
netTaxableIncome: number
} {
const { classification } = gift
if (!classification || classification.bookingType === 'tax_free') {
return {
incomeAccount: '-',
incomeAmount: 0,
vatAccount: null,
vatAmount: 0,
expenseAccount: null,
expenseAmount: 0,
netTaxableIncome: 0,
}
}
const incomeAccount = classification.vatLiable
? GIFT_BOOKING_ACCOUNTS.salesWithVat
: GIFT_BOOKING_ACCOUNTS.otherRevenue
const incomeAmount = classification.vatLiable
? classification.valueExclVat
: classification.marketValue
const vatAccount = classification.vatLiable ? GIFT_BOOKING_ACCOUNTS.outputVat25 : null
const vatAmount = classification.vatLiable ? classification.vatAmount : 0
const expenseAccount = classification.deductibleAsExpense
? GIFT_BOOKING_ACCOUNTS.consumables
: null
const expenseAmount = classification.deductibleAsExpense ? classification.marketValue : 0
// Net taxable income = income - expense (if deductible)
const netTaxableIncome = classification.deductibleAsExpense
? 0 // Income and expense cancel out
: incomeAmount
return {
incomeAccount,
incomeAmount,
vatAccount,
vatAmount,
expenseAccount,
expenseAmount,
netTaxableIncome,
}
}
-291
View File
@@ -1,291 +0,0 @@
/**
* Gift Classifier (Förmånshantering)
* Classifies gifts/benefits according to Swedish tax rules (Skatteverket guidelines)
*
* Key rules:
* 1. Simple promo items ≤450 SEK without motprestation = tax free
* 2. Motprestation exists = always taxable
* - Business-only use = deductible as expense
* - Private use = not deductible
* 3. >450 SEK + private use = taxable, not deductible
*/
import type { GiftInput, GiftClassification, GiftBookingType } from '@/types'
// Skatteverket's threshold for tax-free promotional items
export const TAX_FREE_PROMO_THRESHOLD = 450 // SEK
// BAS account mappings for gift bookkeeping
export const GIFT_ACCOUNTS = {
// Taxable gift income
income: {
account: '3900',
name: 'Övriga rörelseintäkter',
},
// Deductible equipment (business use only)
equipment_expense: {
account: '5400',
name: 'Förbrukningsinventarier',
},
// Deductible props/materials (business use only)
props_expense: {
account: '5460',
name: 'Förbrukningsmaterial/Rekvisita',
},
}
/**
* Classify a gift according to Swedish tax rules
* Implements the decision tree from 07-FUTURE-FEATURES.md
*/
export function classifyGift(input: GiftInput): GiftClassification {
const { estimatedValue, hasMotprestation, usedInBusiness, usedPrivately, isSimplePromoItem } = input
// Rule 1: Simple promotional items under threshold = tax free
// Condition: Simple promo item AND value ≤ 450 SEK AND no motprestation required
if (isSimplePromoItem && estimatedValue <= TAX_FREE_PROMO_THRESHOLD && !hasMotprestation) {
return {
taxable: false,
marketValue: estimatedValue,
deductibleAsExpense: false,
bookingType: 'tax_free',
reasoning: 'Enklare reklamgåva under 450 kr utan krav på motprestation',
vatLiable: false,
vatAmount: 0,
valueExclVat: estimatedValue,
neIncomeRuta: null,
neExpenseRuta: null,
}
}
// Rule 2: Motprestation exists = always taxable WITH VAT (bytestransaktion)
// When influencer was required to post/mention/review the product
if (hasMotprestation) {
// Bytestransaktion: moms ska redovisas, värdet anses vara inkl moms
const valueExclVat = estimatedValue / 1.25
const vatAmount = estimatedValue - valueExclVat
// 2a: Business-only use = taxable but deductible
if (usedInBusiness && !usedPrivately) {
return {
taxable: true,
marketValue: estimatedValue,
deductibleAsExpense: true,
bookingType: 'income_and_expense',
reasoning:
'Skattepliktig förmån (motprestation krävdes). Avdragsgill som rekvisita då den endast används i verksamheten. Moms redovisas (bytestransaktion).',
vatLiable: true,
vatAmount: Math.round(vatAmount * 100) / 100,
valueExclVat: Math.round(valueExclVat * 100) / 100,
neIncomeRuta: 'R1', // Momspliktig → R1
neExpenseRuta: 'R6', // Avdragsgill → R6
}
}
// 2b: Private use = taxable but NOT deductible
return {
taxable: true,
marketValue: estimatedValue,
deductibleAsExpense: false,
bookingType: 'income',
reasoning: 'Skattepliktig förmån (motprestation krävdes). Ej avdragsgill då produkten används privat. Moms redovisas (bytestransaktion).',
vatLiable: true,
vatAmount: Math.round(vatAmount * 100) / 100,
valueExclVat: Math.round(valueExclVat * 100) / 100,
neIncomeRuta: 'R1', // Momspliktig → R1
neExpenseRuta: null,
}
}
// Rule 3: High value without motprestation but used privately
// No explicit agreement but value exceeds promotional threshold
// Ingen motprestation = ingen moms (inte bytestransaktion)
if (estimatedValue > TAX_FREE_PROMO_THRESHOLD && usedPrivately) {
return {
taxable: true,
marketValue: estimatedValue,
deductibleAsExpense: false,
bookingType: 'income',
reasoning: 'Värdet överstiger gränsen för skattefria reklamgåvor och produkten används privat. Ingen moms (ej motprestation).',
vatLiable: false,
vatAmount: 0,
valueExclVat: estimatedValue,
neIncomeRuta: 'R2', // Momsfri → R2
neExpenseRuta: null,
}
}
// Rule 4: High value, business use only, no motprestation
// This is a grey area - generally taxable but deductible if used in business
// Ingen motprestation = ingen moms (inte bytestransaktion)
if (estimatedValue > TAX_FREE_PROMO_THRESHOLD && usedInBusiness && !usedPrivately) {
return {
taxable: true,
marketValue: estimatedValue,
deductibleAsExpense: true,
bookingType: 'income_and_expense',
reasoning:
'Värdet överstiger gränsen för skattefria reklamgåvor. Avdragsgill då produkten endast används i verksamheten. Ingen moms (ej motprestation).',
vatLiable: false,
vatAmount: 0,
valueExclVat: estimatedValue,
neIncomeRuta: 'R2', // Momsfri → R2
neExpenseRuta: 'R6', // Avdragsgill → R6
}
}
// Default: Value-based classification
const isTaxable = estimatedValue > TAX_FREE_PROMO_THRESHOLD
const isDeductible = usedInBusiness && !usedPrivately
const bookingType: GiftBookingType = isTaxable
? (isDeductible ? 'income_and_expense' : 'income')
: 'tax_free'
return {
taxable: isTaxable,
marketValue: estimatedValue,
deductibleAsExpense: isDeductible,
bookingType,
reasoning: 'Klassificering baserad på värde och användning',
vatLiable: false,
vatAmount: 0,
valueExclVat: estimatedValue,
neIncomeRuta: isTaxable ? 'R2' : null, // Momsfri om skattepliktig → R2
neExpenseRuta: isDeductible ? 'R6' : null, // Avdragsgill → R6
}
}
/**
* Validate a gift input before classification
*/
export function validateGiftInput(input: Partial<GiftInput>): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (input.estimatedValue === undefined || input.estimatedValue === null) {
errors.push('Uppskattat värde krävs')
} else if (input.estimatedValue < 0) {
errors.push('Värdet kan inte vara negativt')
} else if (input.estimatedValue > 10000000) {
errors.push('Orimligt högt värde')
}
if (input.hasMotprestation === undefined) {
errors.push('Ange om motprestation krävdes')
}
if (input.usedInBusiness === undefined) {
errors.push('Ange om produkten används i verksamheten')
}
if (input.usedPrivately === undefined) {
errors.push('Ange om produkten används privat')
}
return {
valid: errors.length === 0,
errors,
}
}
/**
* Get display text for booking type
*/
export function getBookingTypeDisplayText(bookingType: GiftBookingType): string {
switch (bookingType) {
case 'tax_free':
return 'Skattefri'
case 'income':
return 'Skattepliktig intäkt'
case 'income_and_expense':
return 'Skattepliktig intäkt + avdragsgill kostnad'
default:
return 'Okänd'
}
}
/**
* Calculate tax impact of a gift
* For EF: Uses egenavgifter rate (28.97%) + estimated marginal income tax
* For AB: Uses corporate tax rate (20.6%)
* For Light: Uses municipal tax rate only (no egenavgifter, umbrella pays)
*/
export function calculateGiftTaxImpact(
classification: GiftClassification,
entityType: 'enskild_firma' | 'aktiebolag' | 'light',
marginalTaxRate: number = 0.32 // Default Swedish municipal tax
): {
taxableAmount: number
estimatedTax: number
netCost: number
} {
if (!classification.taxable) {
return {
taxableAmount: 0,
estimatedTax: 0,
netCost: 0,
}
}
const taxableAmount = classification.marketValue
let estimatedTax: number
if (entityType === 'light') {
// Light: No egenavgifter (umbrella pays arbetsgivaravgifter)
// Just municipal tax + church tax on the gift value
estimatedTax = taxableAmount * marginalTaxRate
} else if (entityType === 'enskild_firma') {
// EF: Egenavgifter (28.97%) + income tax on reduced amount
const egenavgifterRate = 0.2897
const incomeAfterEgenavgifter = taxableAmount * (1 - egenavgifterRate)
estimatedTax = taxableAmount * egenavgifterRate + incomeAfterEgenavgifter * marginalTaxRate
} else {
// AB: Corporate tax (20.6%)
estimatedTax = taxableAmount * 0.206
}
// If deductible, the expense offsets some of the income tax
let netCost = estimatedTax
if (classification.deductibleAsExpense) {
const deductionTaxSavings = entityType === 'enskild_firma'
? taxableAmount * marginalTaxRate
: entityType === 'light'
? taxableAmount * marginalTaxRate
: taxableAmount * 0.206
netCost = estimatedTax - deductionTaxSavings
}
return {
taxableAmount,
estimatedTax: Math.round(estimatedTax),
netCost: Math.round(Math.max(0, netCost)),
}
}
/**
* Classify a gift with entity-type-aware overrides
* Light mode: no VAT, no NE-bilaga, deductibleAsExpense always false
*/
export function classifyGiftForEntity(
input: GiftInput,
entityType: 'enskild_firma' | 'aktiebolag' | 'light'
): GiftClassification {
const classification = classifyGift(input)
if (entityType === 'light') {
return {
...classification,
vatLiable: false,
vatAmount: 0,
valueExclVat: classification.marketValue,
neIncomeRuta: null,
neExpenseRuta: null,
deductibleAsExpense: false,
bookingType: classification.taxable ? 'income' : 'tax_free',
reasoning: classification.taxable
? `Skattepliktig förmån. Skatten (kommunalskatt) betalas via din deklaration, inte av egenanställningsföretaget.${classification.reasoning ? ' ' + classification.reasoning : ''}`
: classification.reasoning,
}
}
return classification
}
-5
View File
@@ -33,7 +33,6 @@ const BANK_ACCOUNT = '1930'
const PRIVATE_ACCOUNTS: Record<EntityType, string> = {
enskild_firma: '2013', // Övriga egna uttag
aktiebolag: '2893', // Skuld till aktieägare/delägare
light: '2013', // Not used in practice (light skips journal entries)
}
/**
@@ -79,8 +78,6 @@ export function getCategoryAccountMapping(
const incomeMapping: Record<string, string> = {
income_services: '3001', // Försäljning tjänster 25%
income_products: '3001', // Försäljning varor 25%
income_sponsorship: '3900', // Övriga rörelseintäkter
income_affiliate: '3900', // Övriga rörelseintäkter
income_other: '3900', // Övriga rörelseintäkter
}
@@ -176,8 +173,6 @@ export function buildMappingResultFromCategory(
const categoryLabels: Record<TransactionCategory, string> = {
income_services: 'Tjänsteförsäljning',
income_products: 'Varuförsäljning',
income_sponsorship: 'Sponsorintäkt',
income_affiliate: 'Affiliateintäkt',
income_other: 'Övrig intäkt',
expense_equipment: 'Förbrukningsinventarier',
expense_software: 'Programvara',
+5 -106
View File
@@ -4,27 +4,23 @@
*/
import { createEvents, type EventAttributes, type DateArray } from 'ics'
import type { Deadline, Invoice, Campaign, Exclusivity } from '@/types'
import type { Deadline, Invoice } from '@/types'
export interface FeedOptions {
includeTaxDeadlines: boolean
includeInvoices: boolean
includeCampaigns: boolean
includeExclusivity: boolean
}
export interface CalendarData {
deadlines: Deadline[]
invoices: Invoice[]
campaigns: Campaign[]
exclusivities: Exclusivity[]
}
/**
* Generate a stable UID for calendar events
* This ensures updates to events are recognized by calendar apps
*/
function getEventUID(type: string, id: string, domain: string = 'influencer-biz.se'): string {
function getEventUID(type: string, id: string, domain: string = 'erp-base.se'): string {
return `${type}-${id}@${domain}`
}
@@ -139,94 +135,6 @@ function formatInvoiceDescription(invoice: Invoice): string {
return lines.join('\n')
}
/**
* Generate campaign deliverable events
*/
function generateCampaignEvents(campaigns: Campaign[]): EventAttributes[] {
const events: EventAttributes[] = []
for (const campaign of campaigns) {
// Add deliverable deadlines
for (const deliverable of campaign.deliverables || []) {
if (!deliverable.due_date) continue
const event: EventAttributes = {
uid: getEventUID('deliverable', deliverable.id),
title: `${campaign.name} - ${deliverable.title}`,
start: dateToArray(deliverable.due_date),
duration: { days: 1 },
description: formatDeliverableDescription(campaign, deliverable),
categories: ['Kampanj', 'Leverans'],
status: deliverable.status === 'published' ? 'CONFIRMED' : 'TENTATIVE',
alarms: createAlarms([1]),
}
events.push(event)
}
}
return events
}
/**
* Format deliverable description for calendar
*/
function formatDeliverableDescription(
campaign: Campaign,
deliverable: { title: string; platform: string; description?: string | null }
): string {
const lines: string[] = []
lines.push(`Kampanj: ${campaign.name}`)
lines.push(`Plattform: ${deliverable.platform}`)
if (deliverable.description) {
lines.push(`\n${deliverable.description}`)
}
return lines.join('\n')
}
/**
* Generate exclusivity period events (as all-day spans)
*/
function generateExclusivityEvents(exclusivities: Exclusivity[]): EventAttributes[] {
return exclusivities.map((exclusivity) => {
const campaignName = exclusivity.campaign?.name || 'Kampanj'
const event: EventAttributes = {
uid: getEventUID('exclusivity', exclusivity.id),
title: `Exklusivitet: ${campaignName}`,
start: dateToArray(exclusivity.start_date),
end: dateToArray(exclusivity.end_date),
description: formatExclusivityDescription(exclusivity),
categories: ['Exklusivitet'],
status: 'CONFIRMED',
// No alarms for exclusivity periods
}
return event
})
}
/**
* Format exclusivity description for calendar
*/
function formatExclusivityDescription(exclusivity: Exclusivity): string {
const lines: string[] = []
if (exclusivity.categories.length > 0) {
lines.push(`Kategorier: ${exclusivity.categories.join(', ')}`)
}
if (exclusivity.excluded_brands.length > 0) {
lines.push(`Uteslutna varumärken: ${exclusivity.excluded_brands.join(', ')}`)
}
if (exclusivity.notes) {
lines.push(`\n${exclusivity.notes}`)
}
return lines.join('\n')
}
/**
* Generate complete calendar feed
*/
@@ -239,25 +147,16 @@ export function generateCalendarFeed(
if (options.includeTaxDeadlines) {
const taxDeadlines = data.deadlines.filter((d) => d.deadline_type === 'tax')
events.push(...generateDeadlineEvents(taxDeadlines))
}
if (options.includeCampaigns) {
// Include non-tax deadlines related to campaigns
const campaignDeadlines = data.deadlines.filter(
(d) => d.deadline_type !== 'tax' && (d.campaign_id || d.deliverable_id)
)
events.push(...generateDeadlineEvents(campaignDeadlines))
events.push(...generateCampaignEvents(data.campaigns))
// Include non-tax deadlines too
const otherDeadlines = data.deadlines.filter((d) => d.deadline_type !== 'tax')
events.push(...generateDeadlineEvents(otherDeadlines))
}
if (options.includeInvoices) {
events.push(...generateInvoiceEvents(data.invoices))
}
if (options.includeExclusivity) {
events.push(...generateExclusivityEvents(data.exclusivities))
}
return new Promise((resolve, reject) => {
createEvents(events, (error, value) => {
if (error) {
+3 -195
View File
@@ -1,4 +1,4 @@
import { Invoice, Deadline, PaymentCalendarDay, Deliverable, Exclusivity, Campaign } from '@/types'
import { Invoice, Deadline, PaymentCalendarDay } from '@/types'
// Swedish day names (Monday first)
export const SWEDISH_DAYS = ['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön']
@@ -223,16 +223,13 @@ export function getUpcomingDeadlines(deadlines: Deadline[], days: number = 7): D
.sort((a, b) => a.due_date.localeCompare(b.due_date))
}
// Swedish deadline type labels (extended for campaigns)
// Swedish deadline type labels
export const DEADLINE_TYPE_LABELS: Record<string, string> = {
delivery: 'Leverans',
approval: 'Godkännande',
invoicing: 'Fakturering',
report: 'Rapport',
revision: 'Revision',
assets: 'Material',
spark_ad: 'Spark Ad',
statistics: 'Statistik',
other: 'Övrigt'
}
@@ -319,140 +316,22 @@ export function countDeadlinesByStatus(deadlines: Deadline[]): Record<DeadlineSt
return counts
}
// ============================================================
// Campaign-aware calendar functions
// ============================================================
// Get deliverables for a month
export function getDeliverablesForMonth(deliverables: Deliverable[], year: number, month: number): Deliverable[] {
const monthStr = String(month + 1).padStart(2, '0')
const prefix = `${year}-${monthStr}`
return deliverables.filter(d => d.due_date?.startsWith(prefix))
}
// Group deliverables by due date
export function groupDeliverablesByDate(deliverables: Deliverable[]): Map<string, Deliverable[]> {
const grouped = new Map<string, Deliverable[]>()
for (const deliverable of deliverables) {
if (!deliverable.due_date) continue
const dateKey = deliverable.due_date
const existing = grouped.get(dateKey) || []
existing.push(deliverable)
grouped.set(dateKey, existing)
}
return grouped
}
// Check if deliverable is overdue
export function isDeliverableOverdue(deliverable: Deliverable): boolean {
if (['approved', 'published'].includes(deliverable.status)) return false
if (!deliverable.due_date) return false
const today = startOfDay(new Date())
const dueDate = parseDate(deliverable.due_date)
return isBefore(dueDate, today)
}
// Get upcoming deliverables
export function getUpcomingDeliverables(deliverables: Deliverable[], days: number = 7): Deliverable[] {
const today = startOfDay(new Date())
const endDate = new Date(today)
endDate.setDate(endDate.getDate() + days)
return deliverables
.filter(d => {
if (['approved', 'published'].includes(d.status)) return false
if (!d.due_date) return false
const dueDate = parseDate(d.due_date)
return dueDate >= today && dueDate <= endDate
})
.sort((a, b) => (a.due_date || '').localeCompare(b.due_date || ''))
}
// Get exclusivities active on a specific date
export function getExclusivitiesForDate(exclusivities: Exclusivity[], date: string): Exclusivity[] {
return exclusivities.filter(e =>
e.start_date <= date && e.end_date >= date
)
}
// Get exclusivities for a month (overlapping with month)
export function getExclusivitiesForMonth(exclusivities: Exclusivity[], year: number, month: number): Exclusivity[] {
const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`
const lastDay = new Date(year, month + 1, 0).getDate()
const monthEnd = `${year}-${String(month + 1).padStart(2, '0')}-${lastDay}`
return exclusivities.filter(e =>
e.start_date <= monthEnd && e.end_date >= monthStart
)
}
// Get campaigns with deliverables in a date range
export function getCampaignsWithDeliverablesInRange(
campaigns: Campaign[],
startDate: string,
endDate: string
): Campaign[] {
return campaigns.filter(campaign =>
campaign.deliverables?.some(d =>
d.due_date &&
d.due_date >= startDate &&
d.due_date <= endDate &&
!['approved', 'published'].includes(d.status)
)
)
}
// Calculate campaign deliverable summary for a date
export function getCampaignDaySummary(
campaigns: Campaign[],
date: string
): {
deliverables: (Deliverable & { campaignName: string })[]
totalCount: number
overdueCount: number
} {
const deliverables: (Deliverable & { campaignName: string })[] = []
let overdueCount = 0
const today = formatDateISO(new Date())
for (const campaign of campaigns) {
for (const d of campaign.deliverables || []) {
if (d.due_date === date && !['approved', 'published'].includes(d.status)) {
deliverables.push({ ...d, campaignName: campaign.name })
if (date < today) overdueCount++
}
}
}
return {
deliverables,
totalCount: deliverables.length,
overdueCount
}
}
// Get all calendar events for a month (unified view)
export interface CalendarEvent {
id: string
type: 'invoice' | 'deadline' | 'deliverable' | 'exclusivity_start' | 'exclusivity_end' | 'campaign_start' | 'campaign_end'
type: 'invoice' | 'deadline'
date: string
title: string
subtitle?: string
status?: string
priority?: string
isOverdue?: boolean
campaignId?: string
customerId?: string
}
export function getCalendarEventsForMonth(
invoices: Invoice[],
deadlines: Deadline[],
campaigns: Campaign[],
year: number,
month: number
): CalendarEvent[] {
@@ -488,82 +367,11 @@ export function getCalendarEventsForMonth(
subtitle: d.customer?.name,
priority: d.priority,
isOverdue: d.due_date < today,
campaignId: d.campaign_id || undefined,
customerId: d.customer_id || undefined
})
}
}
// Deliverables from campaigns
for (const campaign of campaigns) {
// Campaign start/end dates
if (campaign.start_date?.startsWith(prefix)) {
events.push({
id: `camp-start-${campaign.id}`,
type: 'campaign_start',
date: campaign.start_date,
title: `Start: ${campaign.name}`,
subtitle: campaign.customer?.name,
campaignId: campaign.id,
customerId: campaign.customer_id || undefined
})
}
if (campaign.end_date?.startsWith(prefix)) {
events.push({
id: `camp-end-${campaign.id}`,
type: 'campaign_end',
date: campaign.end_date,
title: `Slut: ${campaign.name}`,
subtitle: campaign.customer?.name,
campaignId: campaign.id,
customerId: campaign.customer_id || undefined
})
}
// Deliverables
for (const d of campaign.deliverables || []) {
if (d.due_date?.startsWith(prefix) && !['approved', 'published'].includes(d.status)) {
events.push({
id: `del-${d.id}`,
type: 'deliverable',
date: d.due_date,
title: d.title,
subtitle: campaign.name,
status: d.status,
isOverdue: d.due_date < today,
campaignId: campaign.id,
customerId: campaign.customer_id || undefined
})
}
}
// Exclusivities
for (const e of campaign.exclusivities || []) {
if (e.start_date.startsWith(prefix)) {
events.push({
id: `exc-start-${e.id}`,
type: 'exclusivity_start',
date: e.start_date,
title: `Exklusivitet startar`,
subtitle: `${campaign.name}: ${e.categories.slice(0, 2).join(', ')}${e.categories.length > 2 ? '...' : ''}`,
campaignId: campaign.id
})
}
if (e.end_date.startsWith(prefix)) {
events.push({
id: `exc-end-${e.id}`,
type: 'exclusivity_end',
date: e.end_date,
title: `Exklusivitet slutar`,
subtitle: `${campaign.name}: ${e.categories.slice(0, 2).join(', ')}${e.categories.length > 2 ? '...' : ''}`,
campaignId: campaign.id
})
}
}
}
// Sort by date
events.sort((a, b) => a.date.localeCompare(b.date))
-267
View File
@@ -1,267 +0,0 @@
/**
* Deadline Generator for Campaigns
*
* Auto-generates deadlines from deliverables and campaign events.
*/
import type {
Deliverable,
Campaign,
DeadlineType,
DeadlinePriority,
CreateDeadlineInput,
ContractExtractionResult,
ReferenceEvent,
} from '@/types'
/**
* Generate a deadline from a deliverable
*/
export function generateDeliverableDeadline(
deliverable: Deliverable,
campaign: Campaign
): CreateDeadlineInput | null {
if (!deliverable.due_date) return null
return {
title: `Leverans: ${deliverable.title}`,
due_date: deliverable.due_date,
deadline_type: 'delivery',
priority: 'important',
customer_id: campaign.customer_id || undefined,
notes: deliverable.description || undefined,
}
}
/**
* Generate multiple deadlines for a campaign
* - Delivery deadlines from deliverables
* - Invoicing deadline after campaign end
* - Report deadline if required
*/
export function generateCampaignDeadlines(
campaign: Campaign,
deliverables: Deliverable[],
options?: {
generateInvoicingDeadline?: boolean
invoicingDaysAfterEnd?: number
generateReportDeadline?: boolean
reportDaysAfterEnd?: number
}
): CreateDeadlineInput[] {
const deadlines: CreateDeadlineInput[] = []
const {
generateInvoicingDeadline = true,
invoicingDaysAfterEnd = 5,
generateReportDeadline = false,
reportDaysAfterEnd = 14,
} = options || {}
// Delivery deadlines from deliverables
for (const deliverable of deliverables) {
const deadline = generateDeliverableDeadline(deliverable, campaign)
if (deadline) {
deadlines.push(deadline)
}
}
// Invoicing deadline
if (generateInvoicingDeadline && campaign.end_date) {
const invoiceDate = addDays(campaign.end_date, invoicingDaysAfterEnd)
deadlines.push({
title: `Fakturera: ${campaign.name}`,
due_date: invoiceDate,
deadline_type: 'invoicing',
priority: 'important',
customer_id: campaign.customer_id || undefined,
})
}
// Report deadline
if (generateReportDeadline && campaign.end_date) {
const reportDate = addDays(campaign.end_date, reportDaysAfterEnd)
deadlines.push({
title: `Rapport: ${campaign.name}`,
due_date: reportDate,
deadline_type: 'report',
priority: 'normal',
customer_id: campaign.customer_id || undefined,
})
}
return deadlines
}
/**
* Calculate deadline date based on reference event
*/
export function calculateRelativeDeadline(
referenceDate: string,
offsetDays: number
): string {
return addDays(referenceDate, offsetDays)
}
/**
* Get deadline priority based on days until due
*/
export function getDeadlinePriority(dueDate: string): DeadlinePriority {
const today = new Date()
const due = new Date(dueDate)
const daysUntil = Math.ceil((due.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
if (daysUntil < 0) return 'critical' // Overdue
if (daysUntil <= 2) return 'critical'
if (daysUntil <= 7) return 'important'
return 'normal'
}
/**
* Group deadlines by date
*/
export function groupDeadlinesByDate<T extends { due_date: string }>(
deadlines: T[]
): Map<string, T[]> {
const grouped = new Map<string, T[]>()
for (const deadline of deadlines) {
const dateKey = deadline.due_date
if (!grouped.has(dateKey)) {
grouped.set(dateKey, [])
}
grouped.get(dateKey)!.push(deadline)
}
return grouped
}
/**
* Generate deadlines from contract extraction result
*/
export function generateDeadlinesFromExtraction(
extraction: ContractExtractionResult,
campaign: Campaign,
deliverables: Deliverable[]
): CreateDeadlineInput[] {
const deadlines: CreateDeadlineInput[] = []
// Generate delivery deadlines from deliverables
for (const deliverable of deliverables) {
const deadline = generateDeliverableDeadline(deliverable, campaign)
if (deadline) {
deadlines.push(deadline)
}
}
// Generate deadlines from extracted deadlines
for (const extractedDeadline of extraction.deadlines) {
let dueDate = extractedDeadline.absoluteDate
// Calculate relative dates if needed
if (!dueDate && extractedDeadline.isRelative && extractedDeadline.referenceEvent && extractedDeadline.offsetDays) {
const referenceDate = getReferenceDateFromExtraction(
extractedDeadline.referenceEvent,
extraction,
campaign
)
if (referenceDate) {
dueDate = addDays(referenceDate, extractedDeadline.offsetDays)
}
}
// Skip if we couldn't determine a date
if (!dueDate) continue
deadlines.push({
title: extractedDeadline.description,
due_date: dueDate,
deadline_type: extractedDeadline.type,
priority: getDeadlinePriority(dueDate),
customer_id: campaign.customer_id || undefined,
notes: extractedDeadline.isRelative
? `Relativ: ${extractedDeadline.offsetDays} dagar från ${extractedDeadline.referenceEvent}`
: undefined,
})
}
// Generate invoicing deadline if campaign has end date
if (campaign.end_date) {
const invoicingDate = addDays(campaign.end_date, 5)
deadlines.push({
title: `Fakturera: ${campaign.name}`,
due_date: invoicingDate,
deadline_type: 'invoicing',
priority: 'important',
customer_id: campaign.customer_id || undefined,
})
}
return deadlines
}
/**
* Get reference date from extraction based on event type
*/
function getReferenceDateFromExtraction(
event: ReferenceEvent,
extraction: ContractExtractionResult,
campaign: Campaign
): string | null {
switch (event) {
case 'publication':
return extraction.period.publicationDate
case 'delivery':
return extraction.period.endDate || campaign.end_date
case 'approval':
// No direct mapping, use publication date as proxy
return extraction.period.publicationDate
case 'contract':
return extraction.signingDate || campaign.contract_signed_at
default:
return null
}
}
/**
* Recalculate relative deadlines when reference dates change
*/
export function recalculateRelativeDeadlines(
deadlines: Array<{
due_date: string
date_calculation_type: 'absolute' | 'relative' | null
reference_event: ReferenceEvent | null
offset_days: number | null
}>,
referenceDates: Partial<Record<ReferenceEvent, string>>
): Array<{ due_date: string }> {
return deadlines.map((deadline) => {
// Skip absolute deadlines
if (deadline.date_calculation_type !== 'relative') {
return { due_date: deadline.due_date }
}
// Skip if missing reference info
if (!deadline.reference_event || deadline.offset_days === null) {
return { due_date: deadline.due_date }
}
// Get reference date
const referenceDate = referenceDates[deadline.reference_event]
if (!referenceDate) {
return { due_date: deadline.due_date }
}
// Calculate new date
return {
due_date: addDays(referenceDate, deadline.offset_days),
}
})
}
// Helper function
function addDays(date: string, days: number): string {
const d = new Date(date)
d.setDate(d.getDate() + days)
return d.toISOString().split('T')[0]
}
-212
View File
@@ -1,212 +0,0 @@
/**
* Exclusivity Checker for Campaigns
*
* Detects conflicts between exclusivity periods.
*/
import type { Exclusivity, Campaign, ExclusivityConflict } from '@/types'
/**
* Check if two date ranges overlap
*/
export function datesOverlap(
start1: string,
end1: string,
start2: string,
end2: string
): boolean {
return start1 <= end2 && end1 >= start2
}
/**
* Calculate the overlap period between two date ranges
*/
export function getOverlapPeriod(
start1: string,
end1: string,
start2: string,
end2: string
): { start: string; end: string } | null {
if (!datesOverlap(start1, end1, start2, end2)) {
return null
}
return {
start: start1 > start2 ? start1 : start2,
end: end1 < end2 ? end1 : end2,
}
}
/**
* Find overlapping categories between two exclusivities
* Case-insensitive comparison
*/
export function findOverlappingCategories(
categories1: string[],
categories2: string[]
): string[] {
const normalized1 = categories1.map(c => c.toLowerCase())
return categories2.filter(c => normalized1.includes(c.toLowerCase()))
}
/**
* Check for conflicts between a new exclusivity and existing ones
*/
export function checkExclusivityConflicts(
newExclusivity: {
categories: string[]
start_date: string
end_date: string
},
existingExclusivities: (Exclusivity & { campaign?: Campaign })[]
): ExclusivityConflict[] {
const conflicts: ExclusivityConflict[] = []
for (const existing of existingExclusivities) {
// Check if dates overlap
if (!datesOverlap(
newExclusivity.start_date,
newExclusivity.end_date,
existing.start_date,
existing.end_date
)) {
continue
}
// Check if categories overlap
const overlappingCategories = findOverlappingCategories(
newExclusivity.categories,
existing.categories
)
if (overlappingCategories.length === 0) {
continue
}
// Calculate overlap period
const overlap = getOverlapPeriod(
newExclusivity.start_date,
newExclusivity.end_date,
existing.start_date,
existing.end_date
)
if (overlap) {
conflicts.push({
existingExclusivity: existing,
conflictingCampaign: existing.campaign!,
overlappingCategories,
overlapStart: overlap.start,
overlapEnd: overlap.end,
})
}
}
return conflicts
}
/**
* Get all active exclusivities for a user at a specific date
*/
export function getActiveExclusivities(
exclusivities: Exclusivity[],
date: string = new Date().toISOString().split('T')[0]
): Exclusivity[] {
return exclusivities.filter(e =>
e.start_date <= date && e.end_date >= date
)
}
/**
* Get all exclusivities that will be active in a date range
*/
export function getExclusivitiesInRange(
exclusivities: Exclusivity[],
startDate: string,
endDate: string
): Exclusivity[] {
return exclusivities.filter(e =>
datesOverlap(e.start_date, e.end_date, startDate, endDate)
)
}
/**
* Check if a category is excluded at a specific date
*/
export function isCategoryExcluded(
exclusivities: Exclusivity[],
category: string,
date: string = new Date().toISOString().split('T')[0]
): Exclusivity | null {
const normalizedCategory = category.toLowerCase()
for (const exclusivity of exclusivities) {
if (exclusivity.start_date <= date && exclusivity.end_date >= date) {
if (exclusivity.categories.some(c => c.toLowerCase() === normalizedCategory)) {
return exclusivity
}
}
}
return null
}
/**
* Check if a brand is excluded at a specific date
*/
export function isBrandExcluded(
exclusivities: Exclusivity[],
brand: string,
date: string = new Date().toISOString().split('T')[0]
): Exclusivity | null {
const normalizedBrand = brand.toLowerCase()
for (const exclusivity of exclusivities) {
if (exclusivity.start_date <= date && exclusivity.end_date >= date) {
if (exclusivity.excluded_brands?.some(b => b.toLowerCase() === normalizedBrand)) {
return exclusivity
}
}
}
return null
}
/**
* Get a summary of current exclusivity commitments
*/
export function getExclusivitySummary(
exclusivities: Exclusivity[]
): {
active: Exclusivity[]
upcoming: Exclusivity[]
expired: Exclusivity[]
allCategories: string[]
allBrands: string[]
} {
const today = new Date().toISOString().split('T')[0]
const active = exclusivities.filter(e =>
e.start_date <= today && e.end_date >= today
)
const upcoming = exclusivities.filter(e => e.start_date > today)
const expired = exclusivities.filter(e => e.end_date < today)
// Collect all unique categories from active exclusivities
const allCategories = [...new Set(
active.flatMap(e => e.categories)
)]
// Collect all unique brands from active exclusivities
const allBrands = [...new Set(
active.flatMap(e => e.excluded_brands || [])
)]
return {
active,
upcoming,
expired,
allCategories,
allBrands,
}
}
-4
View File
@@ -1,4 +0,0 @@
export * from './deadline-generator'
export * from './exclusivity-checker'
export * from './workload-analyzer'
export * from './payment-tracker'
-252
View File
@@ -1,252 +0,0 @@
/**
* Payment Tracker for Campaigns
*
* Tracks invoice payments and expected payment dates.
*/
import type { Invoice, Campaign, Customer, PaymentStatus } from '@/types'
/**
* Calculate expected payment date based on invoice and customer history
*/
export function calculateExpectedPaymentDate(
invoice: Invoice,
customer?: Customer
): string {
// Start with the invoice due date
const dueDate = new Date(invoice.due_date)
// If customer has average payment days, adjust
if (customer?.average_payment_days) {
// Calculate difference from standard terms
const standardDays = customer.default_payment_terms || 30
const extraDays = customer.average_payment_days - standardDays
// Add the typical "extra" days the customer usually takes
if (extraDays > 0) {
dueDate.setDate(dueDate.getDate() + extraDays)
}
}
return dueDate.toISOString().split('T')[0]
}
/**
* Determine payment status based on dates and existing status
*/
export function determinePaymentStatus(
invoice: Invoice
): PaymentStatus {
const today = new Date().toISOString().split('T')[0]
// Already paid
if (invoice.status === 'paid') return 'paid'
// Check if overdue
if (invoice.due_date < today && invoice.status === 'sent') {
return 'overdue'
}
// Pending payment
return 'pending'
}
/**
* Calculate customer's average payment days based on history
*/
export function calculateAveragePaymentDays(
invoices: Invoice[]
): number | null {
const paidInvoices = invoices.filter(inv =>
inv.status === 'paid' &&
inv.paid_at &&
inv.invoice_date
)
if (paidInvoices.length === 0) return null
const paymentDays = paidInvoices.map(inv => {
const invoiceDate = new Date(inv.invoice_date)
const paidDate = new Date(inv.paid_at!)
return Math.ceil((paidDate.getTime() - invoiceDate.getTime()) / (1000 * 60 * 60 * 24))
})
const sum = paymentDays.reduce((a, b) => a + b, 0)
return Math.round(sum / paymentDays.length)
}
/**
* Get payment forecast for upcoming invoices
*/
export function getPaymentForecast(
invoices: Invoice[],
customers: Map<string, Customer>,
daysAhead: number = 30
): {
date: string
invoices: Invoice[]
expectedAmount: number
}[] {
const today = new Date()
const endDate = new Date()
endDate.setDate(endDate.getDate() + daysAhead)
const forecast: Map<string, { invoices: Invoice[]; expectedAmount: number }> = new Map()
// Get unpaid invoices
const unpaidInvoices = invoices.filter(inv =>
inv.status === 'sent' || inv.status === 'overdue'
)
for (const invoice of unpaidInvoices) {
const customer = customers.get(invoice.customer_id)
const expectedDate = calculateExpectedPaymentDate(invoice, customer)
// Only include if within forecast range
if (expectedDate > endDate.toISOString().split('T')[0]) continue
if (!forecast.has(expectedDate)) {
forecast.set(expectedDate, { invoices: [], expectedAmount: 0 })
}
const entry = forecast.get(expectedDate)!
entry.invoices.push(invoice)
entry.expectedAmount += invoice.total
}
// Convert to sorted array
return Array.from(forecast.entries())
.map(([date, data]) => ({ date, ...data }))
.sort((a, b) => a.date.localeCompare(b.date))
}
/**
* Calculate campaign invoicing status
*/
export function getCampaignInvoicingStatus(
campaign: Campaign,
invoices: Invoice[]
): {
totalValue: number
invoiced: number
paid: number
pending: number
overdue: number
invoicedPercentage: number
paidPercentage: number
remainingToInvoice: number
} {
const totalValue = campaign.total_value || 0
const invoiced = invoices.reduce((sum, inv) => sum + inv.total, 0)
const paid = invoices
.filter(inv => inv.status === 'paid')
.reduce((sum, inv) => sum + inv.total, 0)
const pending = invoices
.filter(inv => inv.status === 'sent')
.reduce((sum, inv) => sum + inv.total, 0)
const overdue = invoices
.filter(inv => inv.status === 'overdue')
.reduce((sum, inv) => sum + inv.total, 0)
return {
totalValue,
invoiced,
paid,
pending,
overdue,
invoicedPercentage: totalValue > 0 ? (invoiced / totalValue) * 100 : 0,
paidPercentage: totalValue > 0 ? (paid / totalValue) * 100 : 0,
remainingToInvoice: Math.max(0, totalValue - invoiced),
}
}
/**
* Get overdue invoices with days overdue
*/
export function getOverdueInvoices(
invoices: Invoice[]
): (Invoice & { daysOverdue: number })[] {
const today = new Date()
return invoices
.filter(inv => inv.status === 'overdue' || (inv.status === 'sent' && inv.due_date < today.toISOString().split('T')[0]))
.map(inv => {
const dueDate = new Date(inv.due_date)
const daysOverdue = Math.ceil((today.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
return { ...inv, daysOverdue }
})
.sort((a, b) => b.daysOverdue - a.daysOverdue)
}
/**
* Generate payment reminder message
*/
export function generatePaymentReminder(
invoice: Invoice,
daysOverdue: number
): string {
if (daysOverdue <= 0) {
return `Påminnelse: Faktura ${invoice.invoice_number} förfaller ${invoice.due_date}`
}
if (daysOverdue <= 7) {
return `Vänlig påminnelse: Faktura ${invoice.invoice_number} förföll för ${daysOverdue} dagar sedan`
}
if (daysOverdue <= 14) {
return `Betalningspåminnelse: Faktura ${invoice.invoice_number} är ${daysOverdue} dagar försenad`
}
return `Sista påminnelse: Faktura ${invoice.invoice_number} är ${daysOverdue} dagar försenad. Vänligen betala omgående.`
}
/**
* Calculate cash flow impact of pending payments
*/
export function calculatePendingCashFlow(
invoices: Invoice[]
): {
next7Days: number
next30Days: number
overdue: number
total: number
} {
const today = new Date()
const in7Days = new Date()
in7Days.setDate(in7Days.getDate() + 7)
const in30Days = new Date()
in30Days.setDate(in30Days.getDate() + 30)
const unpaid = invoices.filter(inv =>
inv.status === 'sent' || inv.status === 'overdue'
)
const todayStr = today.toISOString().split('T')[0]
const in7DaysStr = in7Days.toISOString().split('T')[0]
const in30DaysStr = in30Days.toISOString().split('T')[0]
const overdue = unpaid
.filter(inv => inv.due_date < todayStr)
.reduce((sum, inv) => sum + inv.total, 0)
const next7Days = unpaid
.filter(inv => inv.due_date >= todayStr && inv.due_date <= in7DaysStr)
.reduce((sum, inv) => sum + inv.total, 0)
const next30Days = unpaid
.filter(inv => inv.due_date >= todayStr && inv.due_date <= in30DaysStr)
.reduce((sum, inv) => sum + inv.total, 0)
const total = unpaid.reduce((sum, inv) => sum + inv.total, 0)
return {
next7Days,
next30Days,
overdue,
total,
}
}
-287
View File
@@ -1,287 +0,0 @@
/**
* Workload Analyzer for Campaigns
*
* Analyzes deliverable workload over time.
*/
import type {
Deliverable,
Campaign,
PlatformType,
DeliverableType,
WorkloadAnalysis
} from '@/types'
/**
* Workload thresholds for different periods
*/
export const DAILY_THRESHOLDS = {
light: 1,
normal: 2,
heavy: 4,
// Anything above heavy is overloaded
}
export const WEEKLY_THRESHOLDS = {
light: 3,
normal: 7,
heavy: 12,
}
/**
* Get workload level based on deliverable count
*/
export function getWorkloadLevel(
count: number,
period: 'day' | 'week' = 'day'
): 'light' | 'normal' | 'heavy' | 'overloaded' {
const thresholds = period === 'week' ? WEEKLY_THRESHOLDS : DAILY_THRESHOLDS
if (count <= thresholds.light) return 'light'
if (count <= thresholds.normal) return 'normal'
if (count <= thresholds.heavy) return 'heavy'
return 'overloaded'
}
/**
* Analyze workload for a specific date
*/
export function analyzeWorkloadForDate(
deliverables: Deliverable[],
date: string
): WorkloadAnalysis {
// Filter deliverables due on this date that aren't completed
const dueToday = deliverables.filter(d =>
d.due_date === date &&
!['approved', 'published'].includes(d.status)
)
const totalCount = dueToday.reduce((sum, d) => sum + (d.quantity || 1), 0)
// Group by platform
const byPlatform: Partial<Record<PlatformType, number>> = {}
for (const d of dueToday) {
byPlatform[d.platform] = (byPlatform[d.platform] || 0) + (d.quantity || 1)
}
// Group by type
const byType: Partial<Record<DeliverableType, number>> = {}
for (const d of dueToday) {
byType[d.deliverable_type] = (byType[d.deliverable_type] || 0) + (d.quantity || 1)
}
return {
date,
deliverables: dueToday,
totalDeliverables: totalCount,
byPlatform: byPlatform as Record<PlatformType, number>,
byType: byType as Record<DeliverableType, number>,
workloadLevel: getWorkloadLevel(totalCount, 'day'),
}
}
/**
* Analyze workload for a date range
*/
export function analyzeWorkloadRange(
deliverables: Deliverable[],
startDate: string,
endDate: string
): WorkloadAnalysis[] {
const results: WorkloadAnalysis[] = []
const dates = getDateRange(startDate, endDate)
for (const date of dates) {
const analysis = analyzeWorkloadForDate(deliverables, date)
if (analysis.totalDeliverables > 0) {
results.push(analysis)
}
}
return results
}
/**
* Get weekly workload summary
*/
export function analyzeWeeklyWorkload(
deliverables: Deliverable[],
startDate: string,
weeks: number = 4
): {
weekStart: string
weekEnd: string
deliverables: Deliverable[]
totalCount: number
workloadLevel: 'light' | 'normal' | 'heavy' | 'overloaded'
}[] {
const results = []
let currentStart = getMonday(startDate)
for (let i = 0; i < weeks; i++) {
const weekEnd = addDays(currentStart, 6)
const weekDeliverables = deliverables.filter(d =>
d.due_date &&
d.due_date >= currentStart &&
d.due_date <= weekEnd &&
!['approved', 'published'].includes(d.status)
)
const totalCount = weekDeliverables.reduce((sum, d) => sum + (d.quantity || 1), 0)
results.push({
weekStart: currentStart,
weekEnd,
deliverables: weekDeliverables,
totalCount,
workloadLevel: getWorkloadLevel(totalCount, 'week'),
})
currentStart = addDays(currentStart, 7)
}
return results
}
/**
* Find overloaded dates in a range
*/
export function findOverloadedDates(
deliverables: Deliverable[],
startDate: string,
endDate: string
): string[] {
const dates = getDateRange(startDate, endDate)
const overloaded: string[] = []
for (const date of dates) {
const analysis = analyzeWorkloadForDate(deliverables, date)
if (analysis.workloadLevel === 'overloaded') {
overloaded.push(date)
}
}
return overloaded
}
/**
* Get the busiest day in a range
*/
export function findBusiestDay(
deliverables: Deliverable[],
startDate: string,
endDate: string
): WorkloadAnalysis | null {
const analyses = analyzeWorkloadRange(deliverables, startDate, endDate)
if (analyses.length === 0) return null
return analyses.reduce((busiest, current) =>
current.totalDeliverables > busiest.totalDeliverables ? current : busiest
)
}
/**
* Suggest better dates for a deliverable based on workload
*/
export function suggestLessLoadedDates(
deliverables: Deliverable[],
aroundDate: string,
daysRange: number = 7
): string[] {
const startDate = addDays(aroundDate, -daysRange)
const endDate = addDays(aroundDate, daysRange)
const dates = getDateRange(startDate, endDate)
const dateWorkloads = dates.map(date => ({
date,
count: analyzeWorkloadForDate(deliverables, date).totalDeliverables
}))
// Sort by workload (ascending)
dateWorkloads.sort((a, b) => a.count - b.count)
// Return top 3 least loaded dates
return dateWorkloads.slice(0, 3).map(d => d.date)
}
/**
* Get campaign workload summary
*/
export function getCampaignWorkloadSummary(
campaign: Campaign,
deliverables: Deliverable[]
): {
totalDeliverables: number
pendingDeliverables: number
completedDeliverables: number
overdueDeliverables: number
nextDeadline: string | null
estimatedWorkloadDays: number
} {
const today = new Date().toISOString().split('T')[0]
const totalCount = deliverables.reduce((sum, d) => sum + (d.quantity || 1), 0)
const pending = deliverables.filter(d =>
!['approved', 'published'].includes(d.status)
)
const completed = deliverables.filter(d =>
['approved', 'published'].includes(d.status)
)
const overdue = pending.filter(d =>
d.due_date && d.due_date < today
)
const pendingCount = pending.reduce((sum, d) => sum + (d.quantity || 1), 0)
const completedCount = completed.reduce((sum, d) => sum + (d.quantity || 1), 0)
const overdueCount = overdue.reduce((sum, d) => sum + (d.quantity || 1), 0)
// Find next deadline
const upcoming = pending
.filter(d => d.due_date && d.due_date >= today)
.sort((a, b) => new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime())
const nextDeadline = upcoming[0]?.due_date || null
// Estimate work days (rough: 2 deliverables per day max)
const estimatedWorkloadDays = Math.ceil(pendingCount / 2)
return {
totalDeliverables: totalCount,
pendingDeliverables: pendingCount,
completedDeliverables: completedCount,
overdueDeliverables: overdueCount,
nextDeadline,
estimatedWorkloadDays,
}
}
// Helper functions
function getDateRange(startDate: string, endDate: string): string[] {
const dates: string[] = []
let current = new Date(startDate)
const end = new Date(endDate)
while (current <= end) {
dates.push(current.toISOString().split('T')[0])
current.setDate(current.getDate() + 1)
}
return dates
}
function addDays(date: string, days: number): string {
const d = new Date(date)
d.setDate(d.getDate() + days)
return d.toISOString().split('T')[0]
}
function getMonday(date: string): string {
const d = new Date(date)
const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
d.setDate(diff)
return d.toISOString().split('T')[0]
}
-424
View File
@@ -1,424 +0,0 @@
/**
* Contract Analyzer using Claude AI
*
* Analyzes contract text and extracts structured data.
*/
import Anthropic from '@anthropic-ai/sdk'
import type {
ContractExtractionResult,
ConfidenceLevel,
DeliverableType,
PlatformType,
BillingFrequency,
DeadlineType,
ReferenceEvent,
} from '@/types'
const anthropic = new Anthropic()
// Maximum retries for API calls
const MAX_RETRIES = 3
const RETRY_DELAY_MS = 1000
/**
* Analyze contract PDF using Claude AI
* Claude can read PDFs directly via document input
*/
export async function analyzeContract(pdfBase64: string): Promise<ContractExtractionResult> {
const systemPrompt = `Du är en svensk avtalsexpert som analyserar influencer- och marknadsföringsavtal.
Din uppgift är att extrahera strukturerad information från avtalsdokument.
VIKTIGT:
- Extrahera endast information som faktiskt finns i avtalet
- Gissa inte på värden - använd null för saknade fält
- Organisationsnummer ska vara i formatet XXXXXX-XXXX
- Datum ska vara i ISO-format (YYYY-MM-DD)
- Belopp ska vara numeriska värden utan valutasymboler
- Ange konfidensnivå för varje huvudsektion: "high", "medium", "low", eller "missing"
Om avtalet är på engelska, extrahera ändå data och översätt kategorinamn till svenska.`
const userPrompt = `Analysera det bifogade avtalsdokumentet (PDF) och extrahera strukturerad information.
Returnera ett JSON-objekt med följande struktur:
{
"parties": {
"brand": {
"name": "Företagsnamn",
"orgNumber": "XXXXXX-XXXX eller null",
"email": "email eller null",
"contactPerson": "namn eller null"
},
"agency": null eller samma struktur som brand
},
"financials": {
"amount": numeriskt belopp eller null,
"currency": "SEK" eller annan valutakod,
"vatIncluded": true/false/null,
"paymentTerms": antal dagar eller null,
"billingFrequency": "upfront"/"on_delivery"/"monthly"/"split" eller null
},
"deliverables": [
{
"type": "video"/"image"/"story"/"reel"/"post"/"raw_material",
"quantity": antal,
"platform": "instagram"/"tiktok"/"youtube"/"blog"/"podcast"/"other" eller null,
"account": "@kontonamn eller null",
"dueDate": "YYYY-MM-DD" eller null,
"description": "beskrivning eller null"
}
],
"period": {
"startDate": "YYYY-MM-DD" eller null,
"endDate": "YYYY-MM-DD" eller null,
"publicationDate": "YYYY-MM-DD" eller null (specifikt publiceringsdatum om angivet),
"draftDeadline": "YYYY-MM-DD" eller null (deadline för utkast/granskning om angivet)
},
"exclusivity": {
"categories": ["Kategori1", "Kategori2"],
"excludedBrands": ["Varumärke1", "Varumärke2"],
"prePeriodDays": antal dagar före eller null,
"postPeriodDays": antal dagar efter eller null,
"postReference": "publication"/"delivery"/"approval"/"contract" eller null
},
EXKLUSIVITET - Sök specifikt efter:
- Klausuler som "non-compete", "exclusivity", "competing brands", "exklusivitet", "konkurrensklausul", "konkurrerande varumärken"
- Extrahera perioden i dagar. Konvertera veckor till dagar (1 vecka = 7 dagar, 4 veckor = 28 dagar)
- Exempel: "No competing brands for 4 weeks after publication" → postPeriodDays: 28, postReference: "publication"
- Kategorier kan vara: branschkategorier som "skönhet", "mode", "livsmedel", "teknik" etc.
- excludedBrands: specifikt namngivna konkurrenter som nämns
"deadlines": [
{
"description": "Beskrivning av deadline",
"type": "delivery"/"approval"/"invoicing"/"report"/"revision"/"assets"/"spark_ad"/"statistics"/"other",
"absoluteDate": "YYYY-MM-DD" eller null,
"isRelative": true/false,
"referenceEvent": "publication"/"delivery"/"approval"/"contract" eller null,
"offsetDays": antal dagar relativt referenspunkt eller null
}
],
"rights": {
"usageType": "organic"/"paid"/"both" eller null,
"usagePeriodMonths": antal månader eller null,
"ownership": "influencer"/"client" eller null
},
"campaignName": "Kampanjens namn om angivet" eller null,
"signingDate": "YYYY-MM-DD" eller null,
"confidence": {
"parties": "high"/"medium"/"low"/"missing",
"financials": "high"/"medium"/"low"/"missing",
"deliverables": "high"/"medium"/"low"/"missing",
"period": "high"/"medium"/"low"/"missing",
"exclusivity": "high"/"medium"/"low"/"missing",
"deadlines": "high"/"medium"/"low"/"missing",
"rights": "high"/"medium"/"low"/"missing"
}
}
Returnera ENDAST JSON-objektet, ingen annan text.`
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const message = await anthropic.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 4096,
messages: [
{
role: 'user',
content: [
{
type: 'document',
source: {
type: 'base64',
media_type: 'application/pdf',
data: pdfBase64,
},
},
{
type: 'text',
text: userPrompt,
},
],
},
],
system: systemPrompt,
})
// Extract the text content
const content = message.content[0]
if (content.type !== 'text') {
throw new Error('Unexpected response type from AI')
}
// Parse the JSON response - strip markdown code blocks if present
let jsonText = content.text.trim()
// Remove markdown code blocks if Claude wrapped the response
if (jsonText.startsWith('```json')) {
jsonText = jsonText.slice(7) // Remove ```json
} else if (jsonText.startsWith('```')) {
jsonText = jsonText.slice(3) // Remove ```
}
if (jsonText.endsWith('```')) {
jsonText = jsonText.slice(0, -3) // Remove trailing ```
}
jsonText = jsonText.trim()
const parsed = JSON.parse(jsonText)
// Validate and clean the result
return validateAndCleanResult(parsed)
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
// Don't retry on JSON parse errors - the response format is wrong
if (error instanceof SyntaxError) {
throw new Error(`Failed to parse AI response: ${lastError.message}`)
}
// Wait before retrying
if (attempt < MAX_RETRIES - 1) {
await sleep(RETRY_DELAY_MS * (attempt + 1))
}
}
}
throw new Error(`Contract analysis failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
}
/**
* Validate and clean the extraction result
*/
function validateAndCleanResult(raw: unknown): ContractExtractionResult {
// Type guard
if (!raw || typeof raw !== 'object') {
throw new Error('Invalid extraction result: not an object')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = raw as any
// Extract nested objects safely
const parties = data.parties || {}
const financials = data.financials || {}
const period = data.period || {}
const exclusivity = data.exclusivity || {}
const rights = data.rights || {}
// Build validated result with defaults
const result: ContractExtractionResult = {
parties: {
brand: validateParty(parties.brand),
agency: validateParty(parties.agency),
},
financials: {
amount: validateNumber(financials.amount),
currency: validateString(financials.currency) || 'SEK',
vatIncluded: validateBoolean(financials.vatIncluded),
paymentTerms: validateNumber(financials.paymentTerms),
billingFrequency: validateEnum<BillingFrequency>(
financials.billingFrequency,
['upfront', 'on_delivery', 'monthly', 'split']
),
},
deliverables: validateDeliverables(data.deliverables),
period: {
startDate: validateDate(period.startDate),
endDate: validateDate(period.endDate),
publicationDate: validateDate(period.publicationDate),
draftDeadline: validateDate(period.draftDeadline),
},
exclusivity: {
categories: validateStringArray(exclusivity.categories),
excludedBrands: validateStringArray(exclusivity.excludedBrands),
prePeriodDays: validateNumber(exclusivity.prePeriodDays),
postPeriodDays: validateNumber(exclusivity.postPeriodDays),
postReference: validateEnum<ReferenceEvent>(
exclusivity.postReference,
['publication', 'delivery', 'approval', 'contract']
),
},
deadlines: validateDeadlines(data.deadlines),
rights: {
usageType: validateEnum<'organic' | 'paid' | 'both'>(
rights.usageType,
['organic', 'paid', 'both']
),
usagePeriodMonths: validateNumber(rights.usagePeriodMonths),
ownership: validateEnum<'influencer' | 'client'>(
rights.ownership,
['influencer', 'client']
),
},
campaignName: validateString(data.campaignName),
signingDate: validateDate(data.signingDate),
confidence: validateConfidence(data.confidence),
}
return result
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function validateParty(data: any): import('@/types').ExtractedParty | null {
if (!data || typeof data !== 'object') return null
if (!data.name) return null
return {
name: String(data.name),
orgNumber: validateOrgNumber(data.orgNumber),
email: validateEmail(data.email),
contactPerson: validateString(data.contactPerson),
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function validateDeliverables(data: any): import('@/types').ExtractedDeliverable[] {
if (!Array.isArray(data)) return []
return data
.filter((item) => item && typeof item === 'object')
.map((item) => ({
type: validateEnum<DeliverableType>(
item.type,
['video', 'image', 'story', 'reel', 'post', 'raw_material']
) || 'post',
quantity: validateNumber(item.quantity) || 1,
platform: validateEnum<PlatformType>(
item.platform,
['instagram', 'tiktok', 'youtube', 'blog', 'podcast', 'other']
),
account: validateString(item.account),
dueDate: validateDate(item.dueDate),
description: validateString(item.description),
}))
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function validateDeadlines(data: any): import('@/types').ExtractedDeadline[] {
if (!Array.isArray(data)) return []
return data
.filter((item) => item && typeof item === 'object' && item.description)
.map((item) => ({
description: String(item.description),
type: validateEnum<DeadlineType>(
item.type,
['delivery', 'approval', 'invoicing', 'report', 'revision', 'assets', 'spark_ad', 'statistics', 'other']
) || 'other',
absoluteDate: validateDate(item.absoluteDate),
isRelative: Boolean(item.isRelative),
referenceEvent: validateEnum<ReferenceEvent>(
item.referenceEvent,
['publication', 'delivery', 'approval', 'contract']
),
offsetDays: validateNumber(item.offsetDays),
}))
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function validateConfidence(data: any): Record<string, ConfidenceLevel> {
const defaultConfidence: Record<string, ConfidenceLevel> = {
parties: 'missing',
financials: 'missing',
deliverables: 'missing',
period: 'missing',
exclusivity: 'missing',
deadlines: 'missing',
rights: 'missing',
}
if (!data || typeof data !== 'object') return defaultConfidence
const levels: ConfidenceLevel[] = ['high', 'medium', 'low', 'missing']
for (const key of Object.keys(defaultConfidence)) {
if (data[key] && levels.includes(data[key])) {
defaultConfidence[key] = data[key]
}
}
return defaultConfidence
}
function validateString(value: unknown): string | null {
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
return null
}
function validateNumber(value: unknown): number | null {
if (typeof value === 'number' && !isNaN(value)) {
return value
}
if (typeof value === 'string') {
const parsed = parseFloat(value.replace(/[^\d.-]/g, ''))
if (!isNaN(parsed)) {
return parsed
}
}
return null
}
function validateBoolean(value: unknown): boolean | null {
if (typeof value === 'boolean') return value
return null
}
function validateDate(value: unknown): string | null {
if (typeof value !== 'string') return null
// Try parsing as ISO date
const date = new Date(value)
if (isNaN(date.getTime())) return null
// Return as YYYY-MM-DD
return date.toISOString().split('T')[0]
}
function validateEmail(value: unknown): string | null {
if (typeof value !== 'string') return null
const email = value.trim().toLowerCase()
// Basic email validation
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return email
}
return null
}
function validateOrgNumber(value: unknown): string | null {
if (typeof value !== 'string') return null
// Remove all non-digits
const digits = value.replace(/\D/g, '')
// Swedish org numbers are 10 digits
if (digits.length === 10) {
return `${digits.slice(0, 6)}-${digits.slice(6)}`
}
return null
}
function validateStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value
.filter((item) => typeof item === 'string' && item.trim())
.map((item) => String(item).trim())
}
function validateEnum<T extends string>(value: unknown, allowed: T[]): T | null {
if (typeof value === 'string' && allowed.includes(value as T)) {
return value as T
}
return null
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
-33
View File
@@ -1,33 +0,0 @@
/**
* PDF Utilities
*
* Helper functions for PDF handling.
* Note: Actual PDF reading is done by Claude directly via document input.
*/
/**
* Convert PDF buffer to base64 for Claude API
*/
export function pdfToBase64(buffer: Buffer): string {
return buffer.toString('base64')
}
/**
* Get basic PDF info from buffer (file size check)
*/
export function getPDFInfo(buffer: Buffer): {
sizeBytes: number
sizeMB: number
isValidSize: boolean
} {
const sizeBytes = buffer.length
const sizeMB = sizeBytes / (1024 * 1024)
// Claude supports PDFs up to 32MB
const isValidSize = sizeMB <= 32
return {
sizeBytes,
sizeMB,
isValidSize,
}
}
-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
}
+3 -3
View File
@@ -2,7 +2,7 @@ import { Resend } from 'resend'
// Default sender configuration
// Using a fixed From address with dynamic Reply-To
// From: "Företagsnamn via Influencer Biz" <fakturor@influencer-biz.se>
// From: "Företagsnamn via ERP Base" <fakturor@erp-base.se>
// Reply-To: user's company email from company_settings
const DEFAULT_FROM_EMAIL = 'fakturor@arcim.io'
@@ -66,8 +66,8 @@ export async function sendEmail(options: SendEmailOptions): Promise<SendEmailRes
// Construct from address with optional name
const from = fromName
? `${fromName} via Influencer Biz <${DEFAULT_FROM_EMAIL}>`
: `Influencer Biz <${DEFAULT_FROM_EMAIL}>`
? `${fromName} via ERP Base <${DEFAULT_FROM_EMAIL}>`
: `ERP Base <${DEFAULT_FROM_EMAIL}>`
try {
const resend = getResendClient()
+1 -1
View File
@@ -95,7 +95,7 @@ export async function sendReminder(
const daysOverdue = calculateDaysOverdue(invoice.due_date)
// Build action URL (public page for customer response)
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
const actionUrl = `${baseUrl}/invoice-action/${actionToken}`
const emailData = {
-193
View File
@@ -1,193 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { calculateLightTax } from '@/lib/tax/light-calculator'
import type { Gift, GiftClassification } from '@/types'
export interface LightDashboardData {
firstName: string | null
bankBalance: number | null
giftTaxDebt: number
taxableGiftCount: number
effectiveRate: number
daysSinceLastPayout: number | null
hobbyReserve: number
recentEntries: Array<{
id: string
date: string
description: string | null
gross_amount: number
net_amount: number
service_fee: number
pension_deduction: number
social_fees: number
income_tax_withheld: number
platform_fee: number
type: string
provider: string | null
}>
}
export async function fetchLightDashboardData(
userId: string
): Promise<LightDashboardData> {
const supabase = await createClient()
const currentYear = new Date().getFullYear()
const startOfYear = `${currentYear}-01-01`
const endOfYear = `${currentYear}-12-31`
// Run all queries in parallel
const [
profileResult,
settingsResult,
bankResult,
giftsResult,
entriesResult,
lastPayoutResult,
] = await Promise.all([
// 1. Fetch profile for name
supabase
.from('profiles')
.select('full_name')
.eq('id', userId)
.single(),
// 2. Fetch company settings for tax rates and umbrella config
supabase
.from('company_settings')
.select(
'municipal_tax_rate, church_tax, church_tax_rate, umbrella_provider, umbrella_fee_percent, umbrella_pension_percent'
)
.eq('user_id', userId)
.single(),
// 3. Fetch bank balance
supabase
.from('bank_connections')
.select('accounts, status')
.eq('user_id', userId)
.eq('status', 'active')
.limit(1),
// 4. Fetch gifts for current year (not returned)
supabase
.from('gifts')
.select('estimated_value, classification, returned')
.eq('user_id', userId)
.eq('returned', false)
.gte('date', startOfYear)
.lte('date', endOfYear),
// 5. Fetch shadow ledger entries for current year (recent first, for the card)
supabase
.from('shadow_ledger_entries')
.select(
'id, date, description, gross_amount, net_amount, service_fee, pension_deduction, social_fees, income_tax_withheld, platform_fee, type, provider'
)
.eq('user_id', userId)
.gte('date', startOfYear)
.lte('date', endOfYear)
.order('date', { ascending: false })
.limit(5),
// 6. Fetch last payout date for SGI shield
supabase
.from('shadow_ledger_entries')
.select('date')
.eq('user_id', userId)
.eq('type', 'payout')
.order('date', { ascending: false })
.limit(1),
])
// Parse profile
const firstName = profileResult.data?.full_name?.split(' ')[0] || null
// Parse settings
const settings = settingsResult.data
const municipalTaxRate = Number(settings?.municipal_tax_rate) || 0.3238
const churchTaxRate = settings?.church_tax
? Number(settings?.church_tax_rate) || 0.01
: 0
const effectiveRate = municipalTaxRate + churchTaxRate
// Parse bank balance
let bankBalance: number | null = null
const bankConnections = bankResult.data
if (bankConnections && bankConnections.length > 0) {
const accounts = bankConnections[0].accounts as
| { balance: number }[]
| null
if (accounts && accounts.length > 0) {
bankBalance = accounts.reduce(
(sum, acc) => sum + (acc.balance || 0),
0
)
}
}
// Parse gifts and compute taxable value
const gifts = giftsResult.data || []
let taxableGiftValue = 0
let taxableGiftCount = 0
for (const gift of gifts as Pick<
Gift,
'estimated_value' | 'classification' | 'returned'
>[]) {
const classification = gift.classification as GiftClassification | null
if (classification?.taxable) {
taxableGiftCount++
taxableGiftValue += Number(gift.estimated_value)
}
}
// Calculate light tax to get gift tax debt and hobby reserve
const lightTax = calculateLightTax({
taxableGiftValue,
municipalTaxRate,
churchTaxRate,
bankBalance,
})
const giftTaxDebt = lightTax.gift_tax
const hobbyReserve = lightTax.hobby_tax
// Parse shadow ledger entries
const recentEntries = (entriesResult.data || []).map((entry) => ({
id: entry.id as string,
date: entry.date as string,
description: entry.description as string | null,
gross_amount: Number(entry.gross_amount),
net_amount: Number(entry.net_amount),
service_fee: Number(entry.service_fee),
pension_deduction: Number(entry.pension_deduction),
social_fees: Number(entry.social_fees),
income_tax_withheld: Number(entry.income_tax_withheld),
platform_fee: Number(entry.platform_fee),
type: entry.type as string,
provider: entry.provider as string | null,
}))
// Calculate days since last payout
let daysSinceLastPayout: number | null = null
const lastPayout = lastPayoutResult.data
if (lastPayout && lastPayout.length > 0) {
const lastPayoutDate = new Date(lastPayout[0].date)
const today = new Date()
today.setHours(0, 0, 0, 0)
lastPayoutDate.setHours(0, 0, 0, 0)
const diffMs = today.getTime() - lastPayoutDate.getTime()
daysSinceLastPayout = Math.floor(diffMs / (1000 * 60 * 60 * 24))
}
return {
firstName,
bankBalance,
giftTaxDebt,
taxableGiftCount,
effectiveRate,
daysSinceLastPayout,
hobbyReserve,
recentEntries,
}
}
-111
View File
@@ -7,7 +7,6 @@ import {
sendPushToMultiple,
createTaxDeadlinePayload,
createInvoicePayload,
createCampaignDeadlinePayload,
type PushSubscriptionKeys,
} from './web-push'
import type { NotificationSettings, NotificationType } from '@/types'
@@ -376,113 +375,3 @@ export async function sendInvoiceNotifications(
return { sent, skipped }
}
/**
* Send campaign deadline notifications
*/
export async function sendCampaignNotifications(
supabase: SupabaseClient
): Promise<{ sent: number; skipped: number }> {
let sent = 0
let skipped = 0
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayStr = today.toISOString().split('T')[0]
const in3Days = new Date(today)
in3Days.setDate(in3Days.getDate() + 3)
const in3DaysStr = in3Days.toISOString().split('T')[0]
// Fetch campaign deadlines due in 3 days or today
const { data: deadlines } = await supabase
.from('deadlines')
.select('id, user_id, title, due_date, campaign:campaigns(name)')
.neq('deadline_type', 'tax')
.not('campaign_id', 'is', null)
.eq('is_completed', false)
.in('due_date', [in3DaysStr, todayStr])
if (!deadlines || deadlines.length === 0) {
return { sent: 0, skipped: 0 }
}
// Group by user
const userDeadlines = new Map<string, typeof deadlines>()
for (const deadline of deadlines) {
const list = userDeadlines.get(deadline.user_id) || []
list.push(deadline)
userDeadlines.set(deadline.user_id, list)
}
for (const [userId, userDls] of userDeadlines) {
const settings = await getUserNotificationSettings(supabase, userId)
if (settings && !settings.campaign_deadlines_enabled) {
skipped += userDls.length
continue
}
if (settings && !settings.push_enabled) {
skipped += userDls.length
continue
}
if (settings && isWithinQuietHours(settings)) {
skipped += userDls.length
continue
}
const subscriptions = await getUserSubscriptions(supabase, userId)
if (subscriptions.length === 0) {
skipped += userDls.length
continue
}
for (const deadline of userDls) {
const daysUntil = Math.ceil(
(new Date(deadline.due_date).getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
)
const alreadySent = await wasNotificationSent(
supabase,
userId,
'campaign_deadline',
deadline.id,
daysUntil
)
if (alreadySent) {
skipped++
continue
}
// campaign is a single object from the join, but TypeScript sees it as an array from select()
const campaign = deadline.campaign as unknown as { name: string } | null
const campaignName = campaign?.name || 'Kampanj'
const payload = createCampaignDeadlinePayload(
campaignName,
deadline.title,
deadline.due_date,
daysUntil,
deadline.id
)
const result = await sendPushToMultiple(subscriptions, payload)
await logNotification(supabase, userId, 'campaign_deadline', deadline.id, daysUntil)
if (result.goneSubscriptions.length > 0) {
await disableGoneSubscriptions(supabase, result.goneSubscriptions)
}
if (result.successful > 0) {
sent++
} else {
skipped++
}
}
}
return { sent, skipped }
}
+1 -30
View File
@@ -7,7 +7,7 @@ import webpush from 'web-push'
// Configure web-push with VAPID keys
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY
const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:support@influencer-biz.se'
const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:support@erp-base.se'
if (vapidPublicKey && vapidPrivateKey) {
webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey)
@@ -191,35 +191,6 @@ export function createInvoicePayload(
}
}
/**
* Create notification payload for campaign deadline
*/
export function createCampaignDeadlinePayload(
campaignName: string,
deliverableTitle: string,
dueDate: string,
daysUntil: number,
deadlineId: string
): NotificationPayload {
const urgencyText = daysUntil === 0 ? 'IDAG!' : daysUntil === 1 ? 'imorgon' : `om ${daysUntil} dagar`
return {
title: `🎬 ${campaignName}`,
body: `${deliverableTitle} - leverans ${urgencyText}`,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
tag: `campaign-deadline-${deadlineId}`,
data: {
url: '/calendar',
type: 'campaign_deadline',
id: deadlineId,
},
actions: [
{ action: 'view', title: 'Visa' },
],
}
}
/**
* Format date for notifications
*/
-4
View File
@@ -35,8 +35,6 @@ const BAS_ACCOUNT_MAPPING: Record<TransactionCategory, string> = {
// Non-expense categories (for completeness)
income_services: '3001',
income_products: '3001',
income_sponsorship: '3900',
income_affiliate: '3900',
income_other: '3900',
private: '2013',
uncategorized: '6991',
@@ -281,8 +279,6 @@ export const CATEGORY_LABELS: Record<TransactionCategory, string> = {
expense_other: 'Övrigt',
income_services: 'Tjänsteintäkt',
income_products: 'Varuintäkt',
income_sponsorship: 'Sponsoring',
income_affiliate: 'Affiliate',
income_other: 'Övrig intäkt',
private: 'Privat',
uncategorized: 'Ej kategoriserad',
-78
View File
@@ -3,12 +3,9 @@ import type {
NEDeclaration,
NEDeclarationRutor,
NEAccountMapping,
NEGiftBreakdown,
NEGiftItem,
FiscalPeriod,
JournalEntry,
JournalEntryLine,
Gift,
} from '@/types'
/**
@@ -295,67 +292,6 @@ export async function generateNEDeclaration(
rutor.R11 = totalRevenue - totalExpenses
breakdown.R11.total = rutor.R11
// Fetch gifts for the period
const { data: gifts } = await supabase
.from('gifts')
.select('*')
.eq('user_id', userId)
.gte('date', period.period_start)
.lte('date', period.period_end)
// Build gift breakdown
let giftBreakdown: NEGiftBreakdown | undefined
if (gifts && gifts.length > 0) {
const giftItems: NEGiftItem[] = []
let r1Total = 0
let r2Total = 0
let r6Total = 0
for (const gift of gifts as Gift[]) {
const classification = gift.classification
if (!classification || !classification.taxable) continue
const item: NEGiftItem = {
id: gift.id,
date: gift.date,
brandName: gift.brand_name,
description: gift.description,
marketValue: gift.estimated_value,
vatLiable: classification.vatLiable,
vatAmount: classification.vatAmount,
deductibleAsExpense: classification.deductibleAsExpense,
neIncomeRuta: classification.neIncomeRuta,
neExpenseRuta: classification.neExpenseRuta,
}
giftItems.push(item)
// Accumulate totals based on NE ruta
if (classification.neIncomeRuta === 'R1') {
r1Total += classification.valueExclVat // Exkl moms för R1
} else if (classification.neIncomeRuta === 'R2') {
r2Total += gift.estimated_value
}
if (classification.neExpenseRuta === 'R6') {
r6Total += gift.estimated_value
}
}
if (giftItems.length > 0) {
giftBreakdown = {
gifts: giftItems,
summary: {
r1Total: roundToKrona(r1Total),
r2Total: roundToKrona(r2Total),
r6Total: roundToKrona(r6Total),
totalTaxable: roundToKrona(r1Total + r2Total),
totalDeductible: roundToKrona(r6Total),
netTaxableIncome: roundToKrona(r1Total + r2Total - r6Total),
},
}
}
}
// Add warnings
if (!(period as FiscalPeriod).is_closed) {
warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.')
@@ -365,19 +301,6 @@ export async function generateNEDeclaration(
warnings.push('Inga bokförda intäkter eller kostnader hittades för perioden.')
}
// Check for gifts without journal entries
if (giftBreakdown && giftBreakdown.gifts.length > 0) {
const giftsWithoutEntries = (gifts as Gift[]).filter(
(g) => g.classification?.taxable && !g.journal_entry_id
)
if (giftsWithoutEntries.length > 0) {
warnings.push(
`${giftsWithoutEntries.length} skattepliktiga gåvor saknar bokföringsverifikation. ` +
'Dessa bör bokföras för att inkluderas i NE-bilagan.'
)
}
}
return {
fiscalYear: {
id: period.id,
@@ -388,7 +311,6 @@ export async function generateNEDeclaration(
},
rutor,
breakdown,
giftBreakdown,
companyInfo: {
companyName: settings?.company_name || 'Okänt företag',
orgNumber: settings?.org_number || null,
+1 -1
View File
@@ -52,7 +52,7 @@ export async function generateSIEExport(
lines.push('#FLAGGA 0')
lines.push('#FORMAT PC8')
lines.push('#SIETYP 4')
lines.push(`#PROGRAM "${options.program_name || 'InfluencerBiz'}" "1.0"`)
lines.push(`#PROGRAM "${options.program_name || 'ERPBase'}" "1.0"`)
lines.push(`#GEN ${formatSIEDate(now)}`)
if (options.org_number) {
+1 -1
View File
@@ -54,7 +54,7 @@ export function generateSRUFile(declaration: NEDeclaration): SRUFile {
// File header
records.push({ fieldCode: 'PRODUKT', value: 'KONTROLLUPPGIFTER' })
records.push({ fieldCode: 'SESSION', value: '1' })
records.push({ fieldCode: 'PROGRAMNAMN', value: 'InfluencerBiz' })
records.push({ fieldCode: 'PROGRAMNAMN', value: 'ERPBase' })
records.push({ fieldCode: 'PROGRAMVERSION', value: '1.0' })
records.push({
fieldCode: 'SKAPAT',
-2
View File
@@ -328,8 +328,6 @@ function getVatRateForCategory(category: string | null): number {
'uncategorized',
'income_services',
'income_products',
'income_sponsorship',
'income_affiliate',
'income_other',
]
+1 -10
View File
@@ -86,7 +86,7 @@ export async function updateSession(request: NextRequest) {
// Dashboard routes - require completed onboarding
const { data: settings } = await supabase
.from('company_settings')
.select('onboarding_complete, entity_type')
.select('onboarding_complete')
.eq('user_id', user.id)
.single()
@@ -95,14 +95,5 @@ export async function updateSession(request: NextRequest) {
return NextResponse.redirect(new URL('/onboarding', request.url))
}
// Block light users from EF/AB-only routes
if (settings.entity_type === 'light') {
const lightBlockedPaths = ['/invoices', '/customers', '/bookkeeping', '/import', '/reports', '/deductions']
const isBlocked = lightBlockedPaths.some(p => pathname.startsWith(p))
if (isBlocked) {
return NextResponse.redirect(new URL('/', request.url))
}
}
return supabaseResponse
}
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Static configuration of all Swedish tax deadlines (Skatteverket)
* Based on dev_docs/influencer_ai_info/07-kalender-deadlines.md
* Based on Skatteverket's official deadline schedule
*/
import type { TaxDeadlineType, EntityType, MomsPeriod } from '@/types'
-2
View File
@@ -141,8 +141,6 @@ export function getCategoryDisplayName(category: string): string {
const names: Record<string, string> = {
income_services: 'Tjänster',
income_products: 'Produkter',
income_sponsorship: 'Sponsring',
income_affiliate: 'Affiliate',
income_other: 'Övriga intäkter',
expense_equipment: 'Utrustning',
expense_software: 'Programvara',
-145
View File
@@ -1,145 +0,0 @@
/**
* Light Mode Tax Calculator
*
* For influencers working through umbrella companies (egenanställningsföretag).
* They don't pay egenavgifter (the umbrella handles arbetsgivaravgifter).
* Tax exposure comes from:
* - Gifts/PR products → personal income tax liability (municipal + church)
* - Hobby income → egenavgifter + municipal tax on surplus (out of MVP scope)
*/
import type { LightTaxEstimate } from '@/types'
/**
* Calculate light mode tax liability from gifts
* NO egenavgifter (umbrella pays arbetsgivaravgifter)
* NO VAT (umbrella handles)
* NO grundavdrag (consumed by umbrella salary)
*/
export function calculateLightTax(params: {
taxableGiftValue: number
municipalTaxRate: number // e.g. 0.3238 (32.38%)
churchTaxRate: number // e.g. 0.0100 (1.00%), 0 if not member
hobbyIncome?: number
hobbyExpenses?: number
bankBalance: number | null
}): LightTaxEstimate {
const {
taxableGiftValue,
municipalTaxRate,
churchTaxRate,
hobbyIncome = 0,
hobbyExpenses = 0,
bankBalance,
} = params
// Gift tax = taxable gift value × (municipal rate + church rate)
const effectiveRate = municipalTaxRate + churchTaxRate
const giftTax = Math.round(taxableGiftValue * effectiveRate)
// Hobby tax (simplified: surplus × egenavgifter + municipal tax)
const hobbySurplus = Math.max(0, hobbyIncome - hobbyExpenses)
const hobbyTax = hobbySurplus > 0
? Math.round(hobbySurplus * 0.2897 + hobbySurplus * (1 - 0.2897) * municipalTaxRate)
: 0
const totalVirtualDebt = giftTax + hobbyTax
const balance = bankBalance ?? 0
const safeToSpend = Math.max(0, balance - totalVirtualDebt)
const safeToSpendPercent = balance > 0 ? (safeToSpend / balance) * 100 : 0
let safeToSpendLevel: 'green' | 'yellow' | 'red'
if (safeToSpendPercent > 70) {
safeToSpendLevel = 'green'
} else if (safeToSpendPercent > 30) {
safeToSpendLevel = 'yellow'
} else {
safeToSpendLevel = 'red'
}
return {
taxable_gift_value: taxableGiftValue,
municipal_tax_rate: municipalTaxRate,
church_tax_rate: churchTaxRate,
gift_tax: giftTax,
hobby_tax: hobbyTax,
total_virtual_debt: totalVirtualDebt,
safe_to_spend: safeToSpend,
safe_to_spend_percent: Math.round(safeToSpendPercent),
safe_to_spend_level: safeToSpendLevel,
}
}
/**
* Calculate safe-to-spend from bank balance and tax estimate
*/
export function calculateLightSafeToSpend(
bankBalance: number | null,
lightTax: LightTaxEstimate
): { safeToSpend: number; percent: number; level: 'green' | 'yellow' | 'red' } {
return {
safeToSpend: lightTax.safe_to_spend,
percent: lightTax.safe_to_spend_percent,
level: lightTax.safe_to_spend_level,
}
}
/**
* Calculate net payout from gross using umbrella provider fee structure
* Used in shadow ledger form for auto-calculating fields
*/
export function calculatePayoutNet(params: {
gross: number
feePercent: number // e.g. 6.0
pensionPercent: number // e.g. 4.5
estimatedTaxRate: number // e.g. 0.30
}): {
serviceFee: number
pensionDeduction: number
socialFees: number
incomeTaxWithheld: number
net: number
} {
const { gross, feePercent, pensionPercent, estimatedTaxRate } = params
// Service fee (umbrella company fee)
const serviceFee = Math.round(gross * (feePercent / 100) * 100) / 100
// After fee
const afterFee = gross - serviceFee
// Pension deduction (from remaining)
const pensionDeduction = Math.round(afterFee * (pensionPercent / 100) * 100) / 100
// Social fees (arbetsgivaravgifter ~31.42% paid by umbrella, deducted from gross)
const socialFees = Math.round(afterFee * 0.3142 * 100) / 100
// Taxable base after social fees
const taxableBase = afterFee - socialFees - pensionDeduction
// Preliminary income tax
const incomeTaxWithheld = Math.round(taxableBase * estimatedTaxRate * 100) / 100
// Net payout
const net = Math.round((taxableBase - incomeTaxWithheld) * 100) / 100
return {
serviceFee,
pensionDeduction,
socialFees,
incomeTaxWithheld,
net: Math.max(0, net),
}
}
/**
* Calculate virtual tax debt for a gift (light mode)
* Simple: gift value × effective tax rate
*/
export function calculateGiftVirtualTaxDebt(
giftMarketValue: number,
municipalTaxRate: number,
churchTaxRate: number
): number {
return Math.round(giftMarketValue * (municipalTaxRate + churchTaxRate))
}
-164
View File
@@ -1,164 +0,0 @@
import {
TIKTOK_API_ENDPOINTS,
type TikTokApiResponse,
type TikTokUserInfoResponse,
type TikTokVideoListResponse,
type TikTokVideoData,
} from './types'
import { checkRateLimit, waitForRateLimit } from './rate-limiter'
interface TikTokApiOptions {
accessToken: string
userId: string
}
class TikTokApiError extends Error {
code: string
logId?: string
constructor(message: string, code: string, logId?: string) {
super(message)
this.name = 'TikTokApiError'
this.code = code
this.logId = logId
}
}
/**
* Make an authenticated request to the TikTok API
*/
async function makeRequest<T>(
endpoint: string,
options: TikTokApiOptions,
params?: Record<string, string>,
body?: Record<string, unknown>
): Promise<T> {
// Check rate limit
if (!checkRateLimit(options.userId)) {
await waitForRateLimit(options.userId)
}
const url = new URL(endpoint)
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.append(key, value)
})
}
const requestOptions: RequestInit = {
headers: {
'Authorization': `Bearer ${options.accessToken}`,
'Content-Type': 'application/json',
},
}
if (body) {
requestOptions.method = 'POST'
requestOptions.body = JSON.stringify(body)
}
const response = await fetch(url.toString(), requestOptions)
const data: TikTokApiResponse<T> = await response.json()
if (data.error && data.error.code !== 'ok') {
throw new TikTokApiError(
data.error.message,
data.error.code,
data.error.log_id
)
}
return data.data
}
/**
* Fetch user info (profile and stats)
*/
export async function getUserInfo(options: TikTokApiOptions): Promise<TikTokUserInfoResponse['user']> {
const response = await makeRequest<TikTokUserInfoResponse>(
TIKTOK_API_ENDPOINTS.userInfo,
options,
{
fields: 'open_id,union_id,avatar_url,avatar_url_100,avatar_large_url,display_name,bio_description,profile_deep_link,is_verified,follower_count,following_count,likes_count,video_count,username',
}
)
return response.user
}
/**
* Fetch video list with pagination
*/
export async function getVideoList(
options: TikTokApiOptions,
cursor?: number,
maxCount: number = 20
): Promise<TikTokVideoListResponse> {
const body: Record<string, unknown> = {
max_count: Math.min(maxCount, 20), // API limit is 20
}
if (cursor) {
body.cursor = cursor
}
return makeRequest<TikTokVideoListResponse>(
TIKTOK_API_ENDPOINTS.videoList,
options,
{
fields: 'id,title,video_description,duration,cover_image_url,share_url,embed_link,embed_html,create_time,like_count,comment_count,share_count,view_count',
},
body
)
}
/**
* Fetch all videos (with pagination)
*/
export async function getAllVideos(
options: TikTokApiOptions,
maxVideos: number = 100
): Promise<TikTokVideoData[]> {
const allVideos: TikTokVideoData[] = []
let cursor: number | undefined
let hasMore = true
while (hasMore && allVideos.length < maxVideos) {
const response = await getVideoList(options, cursor)
allVideos.push(...response.videos)
hasMore = response.has_more
cursor = response.cursor
// Small delay to be nice to the API
if (hasMore) {
await new Promise(resolve => setTimeout(resolve, 100))
}
}
return allVideos.slice(0, maxVideos)
}
/**
* Check if a TikTok API error indicates token expiration
*/
export function isTokenExpiredError(error: unknown): boolean {
if (error instanceof TikTokApiError) {
return (
error.code === 'invalid_access_token' ||
error.code === 'access_token_expired'
)
}
return false
}
/**
* Check if a TikTok API error indicates refresh token expiration
*/
export function isRefreshTokenExpiredError(error: unknown): boolean {
if (error instanceof TikTokApiError) {
return error.code === 'refresh_token_expired'
}
return false
}
export { TikTokApiError }
-105
View File
@@ -1,105 +0,0 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
// AES-256-GCM encryption for TikTok tokens
// Format: iv:authTag:encrypted (all base64 encoded)
const ALGORITHM = 'aes-256-gcm'
const IV_LENGTH = 16
const AUTH_TAG_LENGTH = 16
function getEncryptionKey(): Buffer {
const key = process.env.TIKTOK_ENCRYPTION_KEY
if (!key) {
throw new Error('TIKTOK_ENCRYPTION_KEY environment variable is not set')
}
// The key should be a 32-byte base64 string
const keyBuffer = Buffer.from(key, 'base64')
if (keyBuffer.length !== 32) {
throw new Error('TIKTOK_ENCRYPTION_KEY must be a 32-byte base64 encoded string')
}
return keyBuffer
}
/**
* Encrypt a token using AES-256-GCM
* @param plaintext The token to encrypt
* @returns Encrypted string in format: iv:authTag:encrypted (base64)
*/
export function encryptToken(plaintext: string): string {
const key = getEncryptionKey()
const iv = randomBytes(IV_LENGTH)
const cipher = createCipheriv(ALGORITHM, key, iv)
let encrypted = cipher.update(plaintext, 'utf8', 'base64')
encrypted += cipher.final('base64')
const authTag = cipher.getAuthTag()
// Combine: iv:authTag:encrypted
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`
}
/**
* Decrypt a token using AES-256-GCM
* @param encryptedString The encrypted string in format: iv:authTag:encrypted
* @returns The decrypted token
*/
export function decryptToken(encryptedString: string): string {
const key = getEncryptionKey()
const parts = encryptedString.split(':')
if (parts.length !== 3) {
throw new Error('Invalid encrypted token format')
}
const [ivBase64, authTagBase64, encrypted] = parts
const iv = Buffer.from(ivBase64, 'base64')
const authTag = Buffer.from(authTagBase64, 'base64')
if (iv.length !== IV_LENGTH) {
throw new Error('Invalid IV length')
}
if (authTag.length !== AUTH_TAG_LENGTH) {
throw new Error('Invalid auth tag length')
}
const decipher = createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(authTag)
let decrypted = decipher.update(encrypted, 'base64', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
/**
* Encrypt both access and refresh tokens
*/
export function encryptTokens(accessToken: string, refreshToken: string): {
access_token_encrypted: string
refresh_token_encrypted: string
} {
return {
access_token_encrypted: encryptToken(accessToken),
refresh_token_encrypted: encryptToken(refreshToken),
}
}
/**
* Decrypt both access and refresh tokens
*/
export function decryptTokens(
accessTokenEncrypted: string,
refreshTokenEncrypted: string
): {
access_token: string
refresh_token: string
} {
return {
access_token: decryptToken(accessTokenEncrypted),
refresh_token: decryptToken(refreshTokenEncrypted),
}
}
-318
View File
@@ -1,318 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import type {
TikTokVideo,
TikTokDailyStats,
TikTokEngagementMetrics,
TikTokCampaignROI,
TikTokFollowerGrowth,
TikTokStatsSummary,
} from '@/types'
/**
* Calculate engagement metrics for a set of videos
*/
export function calculateEngagementMetrics(videos: TikTokVideo[]): TikTokEngagementMetrics {
if (videos.length === 0) {
return {
views: 0,
likes: 0,
comments: 0,
shares: 0,
engagementRate: 0,
}
}
const totals = videos.reduce(
(acc, video) => ({
views: acc.views + video.view_count,
likes: acc.likes + video.like_count,
comments: acc.comments + video.comment_count,
shares: acc.shares + video.share_count,
}),
{ views: 0, likes: 0, comments: 0, shares: 0 }
)
// Engagement rate = (likes + comments + shares) / views * 100
const totalEngagements = totals.likes + totals.comments + totals.shares
const engagementRate = totals.views > 0 ? (totalEngagements / totals.views) * 100 : 0
return {
...totals,
engagementRate: Math.round(engagementRate * 100) / 100, // Round to 2 decimals
}
}
/**
* Calculate campaign ROI based on linked videos and invoices
*/
export async function calculateCampaignROI(campaignId: string): Promise<TikTokCampaignROI | null> {
const supabase = await createClient()
// Get campaign with invoices
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select(`
id,
name,
invoices!inner (
total_sek,
status
)
`)
.eq('id', campaignId)
.single()
if (campaignError || !campaign) {
return null
}
// Get linked videos
const { data: videos, error: videosError } = await supabase
.from('tiktok_videos')
.select('*')
.eq('campaign_id', campaignId)
if (videosError || !videos) {
return null
}
// Calculate total cost from paid invoices
const totalCost = (campaign.invoices as { total_sek: number; status: string }[])
.filter(inv => inv.status === 'paid')
.reduce((sum, inv) => sum + (inv.total_sek || 0), 0)
// Calculate engagement metrics
const metrics = calculateEngagementMetrics(videos as TikTokVideo[])
return {
campaignId,
campaignName: campaign.name,
totalCost,
totalViews: metrics.views,
totalEngagements: metrics.likes + metrics.comments + metrics.shares,
costPerView: metrics.views > 0 ? totalCost / metrics.views : 0,
costPerEngagement:
metrics.likes + metrics.comments + metrics.shares > 0
? totalCost / (metrics.likes + metrics.comments + metrics.shares)
: 0,
videos: videos as TikTokVideo[],
}
}
/**
* Calculate follower growth for a period
*/
export async function calculateFollowerGrowth(
accountId: string,
period: TikTokFollowerGrowth['period']
): Promise<TikTokFollowerGrowth | null> {
const supabase = await createClient()
// Calculate date range
const endDate = new Date()
const startDate = new Date()
switch (period) {
case '7d':
startDate.setDate(startDate.getDate() - 7)
break
case '30d':
startDate.setDate(startDate.getDate() - 30)
break
case '90d':
startDate.setDate(startDate.getDate() - 90)
break
case '1y':
startDate.setFullYear(startDate.getFullYear() - 1)
break
}
// Get stats for the period
const { data: stats, error } = await supabase
.from('tiktok_daily_stats')
.select('stats_date, follower_count')
.eq('tiktok_account_id', accountId)
.gte('stats_date', startDate.toISOString().split('T')[0])
.lte('stats_date', endDate.toISOString().split('T')[0])
.order('stats_date', { ascending: true })
if (error || !stats || stats.length < 2) {
return null
}
const startStats = stats[0]
const endStats = stats[stats.length - 1]
const daysDiff = Math.ceil(
(new Date(endStats.stats_date).getTime() - new Date(startStats.stats_date).getTime()) /
(1000 * 60 * 60 * 24)
)
const change = endStats.follower_count - startStats.follower_count
const changePercent =
startStats.follower_count > 0
? (change / startStats.follower_count) * 100
: 0
const dailyAverage = daysDiff > 0 ? change / daysDiff : 0
return {
period,
startCount: startStats.follower_count,
endCount: endStats.follower_count,
change,
changePercent: Math.round(changePercent * 100) / 100,
dailyAverage: Math.round(dailyAverage * 100) / 100,
}
}
/**
* Get stats summary for dashboard
*/
export async function getStatsSummary(userId: string): Promise<TikTokStatsSummary | null> {
const supabase = await createClient()
// Get active account
const { data: account, error: accountError } = await supabase
.from('tiktok_accounts')
.select('*')
.eq('user_id', userId)
.eq('status', 'active')
.single()
if (accountError || !account) {
return null
}
// Get latest stats
const { data: latestStats } = await supabase
.from('tiktok_daily_stats')
.select('*')
.eq('tiktok_account_id', account.id)
.order('stats_date', { ascending: false })
.limit(1)
.single()
// Get growth data
const growth7d = await calculateFollowerGrowth(account.id, '7d')
const growth30d = await calculateFollowerGrowth(account.id, '30d')
// Get recent videos
const { data: recentVideos } = await supabase
.from('tiktok_videos')
.select('*')
.eq('tiktok_account_id', account.id)
.order('published_at', { ascending: false })
.limit(5)
// Calculate engagement rate from recent videos
const metrics = calculateEngagementMetrics((recentVideos || []) as TikTokVideo[])
return {
currentFollowers: latestStats?.follower_count || 0,
followerChange7d: growth7d?.change || 0,
followerChange30d: growth30d?.change || 0,
totalLikes: latestStats?.likes_count || 0,
totalVideos: latestStats?.video_count || 0,
recentVideos: (recentVideos || []) as TikTokVideo[],
engagementRate: metrics.engagementRate,
lastSynced: account.last_synced_at,
}
}
/**
* Get follower history for chart
*/
export async function getFollowerHistory(
accountId: string,
days: number = 30
): Promise<{ date: string; followers: number; change: number | null }[]> {
const supabase = await createClient()
const startDate = new Date()
startDate.setDate(startDate.getDate() - days)
const { data: stats, error } = await supabase
.from('tiktok_daily_stats')
.select('stats_date, follower_count, follower_change')
.eq('tiktok_account_id', accountId)
.gte('stats_date', startDate.toISOString().split('T')[0])
.order('stats_date', { ascending: true })
if (error || !stats) {
return []
}
return stats.map(stat => ({
date: stat.stats_date,
followers: stat.follower_count,
change: stat.follower_change,
}))
}
/**
* Get video performance data for a period
*/
export async function getVideoPerformance(
accountId: string,
days: number = 30
): Promise<TikTokVideo[]> {
const supabase = await createClient()
const startDate = new Date()
startDate.setDate(startDate.getDate() - days)
const { data: videos, error } = await supabase
.from('tiktok_videos')
.select('*')
.eq('tiktok_account_id', accountId)
.gte('published_at', startDate.toISOString())
.order('published_at', { ascending: false })
if (error || !videos) {
return []
}
return videos as TikTokVideo[]
}
/**
* Get campaigns with TikTok ROI data
*/
export async function getCampaignsWithROI(userId: string): Promise<TikTokCampaignROI[]> {
const supabase = await createClient()
// Get campaigns with linked TikTok videos
const { data: campaigns, error } = await supabase
.from('campaigns')
.select('id')
.eq('user_id', userId)
.not('id', 'is', null)
if (error || !campaigns) {
return []
}
// Get campaigns that have linked videos
const { data: videoCampaigns } = await supabase
.from('tiktok_videos')
.select('campaign_id')
.eq('user_id', userId)
.not('campaign_id', 'is', null)
if (!videoCampaigns) {
return []
}
const uniqueCampaignIds = [...new Set(videoCampaigns.map(v => v.campaign_id))]
const roiData: TikTokCampaignROI[] = []
for (const campaignId of uniqueCampaignIds) {
if (campaignId) {
const roi = await calculateCampaignROI(campaignId)
if (roi) {
roiData.push(roi)
}
}
}
return roiData
}
-251
View File
@@ -1,251 +0,0 @@
import { TIKTOK_API_ENDPOINTS, TIKTOK_SCOPES, type TikTokTokens } from './types'
import { randomBytes, createHash } from 'crypto'
import { createClient } from '@supabase/supabase-js'
// Create admin client for OAuth state management (server-side only)
function getAdminClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !serviceRoleKey) {
throw new Error('Supabase configuration missing for OAuth state management')
}
return createClient(supabaseUrl, serviceRoleKey)
}
/**
* Generate a random code verifier for PKCE
*/
function generateCodeVerifier(): string {
return randomBytes(32).toString('base64url')
}
/**
* Generate code challenge from verifier (SHA256 + base64url)
*/
function generateCodeChallenge(verifier: string): string {
return createHash('sha256').update(verifier).digest('base64url')
}
/**
* Generate OAuth state for CSRF protection and PKCE
* Returns state and code_challenge for the authorization URL
* Stores state in database to survive server restarts
*/
export async function generateOAuthState(userId: string): Promise<{ state: string; codeChallenge: string }> {
const supabase = getAdminClient()
// Clean up expired states
await supabase
.from('oauth_states')
.delete()
.lt('expires_at', new Date().toISOString())
const state = randomBytes(32).toString('hex')
const codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(codeVerifier)
const { error } = await supabase
.from('oauth_states')
.insert({
state,
user_id: userId,
code_verifier: codeVerifier,
provider: 'tiktok',
})
if (error) {
throw new Error(`Failed to store OAuth state: ${error.message}`)
}
return { state, codeChallenge }
}
/**
* Validate and consume OAuth state
* Returns the user ID and code verifier if valid, null otherwise
* Reads from database to survive server restarts
*/
export async function validateOAuthState(state: string): Promise<{ userId: string; codeVerifier: string } | null> {
const supabase = getAdminClient()
// Fetch and delete in one operation (consume the state)
const { data, error } = await supabase
.from('oauth_states')
.select('user_id, code_verifier, expires_at')
.eq('state', state)
.single()
if (error || !data) {
return null
}
// Check if state is expired
if (new Date(data.expires_at) < new Date()) {
// Delete expired state
await supabase.from('oauth_states').delete().eq('state', state)
return null
}
// Consume the state (one-time use)
await supabase.from('oauth_states').delete().eq('state', state)
return { userId: data.user_id, codeVerifier: data.code_verifier }
}
/**
* Get the TikTok authorization URL with PKCE
*/
export function getAuthorizationUrl(redirectUri: string, state: string, codeChallenge: string): string {
const clientKey = process.env.TIKTOK_CLIENT_KEY
if (!clientKey) {
throw new Error('TIKTOK_CLIENT_KEY environment variable is not set')
}
const params = new URLSearchParams({
client_key: clientKey,
scope: TIKTOK_SCOPES.join(','),
response_type: 'code',
redirect_uri: redirectUri,
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
})
return `${TIKTOK_API_ENDPOINTS.authorize}?${params.toString()}`
}
/**
* Exchange authorization code for tokens (with PKCE code_verifier)
*/
export async function exchangeCodeForTokens(
code: string,
redirectUri: string,
codeVerifier: string
): Promise<TikTokTokens> {
const clientKey = process.env.TIKTOK_CLIENT_KEY
const clientSecret = process.env.TIKTOK_CLIENT_SECRET
if (!clientKey || !clientSecret) {
throw new Error('TikTok client credentials are not configured')
}
const response = await fetch(TIKTOK_API_ENDPOINTS.token, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_key: clientKey,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
})
const data = await response.json()
if (data.error) {
throw new Error(`TikTok OAuth error: ${data.error_description || data.error}`)
}
return data as TikTokTokens
}
/**
* Refresh access token using refresh token
*/
export async function refreshAccessToken(refreshToken: string): Promise<TikTokTokens> {
const clientKey = process.env.TIKTOK_CLIENT_KEY
const clientSecret = process.env.TIKTOK_CLIENT_SECRET
if (!clientKey || !clientSecret) {
throw new Error('TikTok client credentials are not configured')
}
const response = await fetch(TIKTOK_API_ENDPOINTS.token, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_key: clientKey,
client_secret: clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
})
const data = await response.json()
if (data.error) {
throw new Error(`TikTok token refresh error: ${data.error_description || data.error}`)
}
return data as TikTokTokens
}
/**
* Revoke tokens (disconnect account)
*/
export async function revokeToken(accessToken: string): Promise<void> {
const clientKey = process.env.TIKTOK_CLIENT_KEY
const clientSecret = process.env.TIKTOK_CLIENT_SECRET
if (!clientKey || !clientSecret) {
throw new Error('TikTok client credentials are not configured')
}
const response = await fetch(TIKTOK_API_ENDPOINTS.revokeToken, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_key: clientKey,
client_secret: clientSecret,
token: accessToken,
}),
})
const data = await response.json()
if (data.error && data.error.code !== 'ok') {
// Log but don't throw - revocation is best-effort
console.error('TikTok token revocation error:', data.error)
}
}
/**
* Calculate token expiration dates
*/
export function calculateTokenExpiration(tokens: TikTokTokens): {
tokenExpiresAt: Date
refreshTokenExpiresAt: Date
} {
const now = new Date()
return {
tokenExpiresAt: new Date(now.getTime() + tokens.expires_in * 1000),
refreshTokenExpiresAt: new Date(now.getTime() + tokens.refresh_expires_in * 1000),
}
}
/**
* Check if access token needs refresh (less than 1 hour remaining)
*/
export function shouldRefreshToken(tokenExpiresAt: Date): boolean {
const now = new Date()
const oneHourFromNow = new Date(now.getTime() + 60 * 60 * 1000)
return tokenExpiresAt <= oneHourFromNow
}
/**
* Check if refresh token is expired
*/
export function isRefreshTokenExpired(refreshTokenExpiresAt: Date): boolean {
return new Date() >= refreshTokenExpiresAt
}
-107
View File
@@ -1,107 +0,0 @@
import { TIKTOK_RATE_LIMITS } from './types'
// Simple in-memory rate limiter for TikTok API
// In production, consider using Redis for distributed rate limiting
interface RateLimitEntry {
count: number
windowStart: number
}
const rateLimits = new Map<string, RateLimitEntry>()
const WINDOW_SIZE_MS = 60 * 1000 // 1 minute window
/**
* Check if a request can be made within rate limits
* @param userId The user making the request
* @returns true if request is allowed, false if rate limited
*/
export function checkRateLimit(userId: string): boolean {
const now = Date.now()
const entry = rateLimits.get(userId)
// No existing entry, create new one
if (!entry) {
rateLimits.set(userId, { count: 1, windowStart: now })
return true
}
// Check if window has expired
if (now - entry.windowStart >= WINDOW_SIZE_MS) {
// Reset window
rateLimits.set(userId, { count: 1, windowStart: now })
return true
}
// Check if under limit
if (entry.count < TIKTOK_RATE_LIMITS.requestsPerMinute) {
entry.count++
return true
}
// Rate limited
return false
}
/**
* Get remaining requests in current window
*/
export function getRemainingRequests(userId: string): number {
const entry = rateLimits.get(userId)
if (!entry) {
return TIKTOK_RATE_LIMITS.requestsPerMinute
}
const now = Date.now()
if (now - entry.windowStart >= WINDOW_SIZE_MS) {
return TIKTOK_RATE_LIMITS.requestsPerMinute
}
return Math.max(0, TIKTOK_RATE_LIMITS.requestsPerMinute - entry.count)
}
/**
* Get time until rate limit resets (in ms)
*/
export function getResetTime(userId: string): number {
const entry = rateLimits.get(userId)
if (!entry) {
return 0
}
const now = Date.now()
const elapsed = now - entry.windowStart
if (elapsed >= WINDOW_SIZE_MS) {
return 0
}
return WINDOW_SIZE_MS - elapsed
}
/**
* Wait for rate limit to reset
*/
export async function waitForRateLimit(userId: string): Promise<void> {
const resetTime = getResetTime(userId)
if (resetTime > 0) {
await new Promise(resolve => setTimeout(resolve, resetTime))
}
}
/**
* Clean up old rate limit entries (call periodically)
*/
export function cleanupRateLimits(): void {
const now = Date.now()
for (const [userId, entry] of rateLimits.entries()) {
if (now - entry.windowStart >= WINDOW_SIZE_MS * 2) {
rateLimits.delete(userId)
}
}
}
-394
View File
@@ -1,394 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { decryptTokens, encryptTokens } from './encryption'
import { refreshAccessToken, shouldRefreshToken, isRefreshTokenExpired } from './oauth'
import { getUserInfo, getAllVideos, isTokenExpiredError } from './api'
import type { TikTokAccount, TikTokDailyStats, TikTokVideo, TikTokSyncType } from '@/types'
interface SyncResult {
success: boolean
statsSynced: boolean
videosSynced: number
newVideos: number
error?: string
errorCode?: string
}
/**
* Ensure access token is valid, refresh if needed
* Returns the valid access token or throws if unable to refresh
*/
export async function ensureValidToken(account: TikTokAccount): Promise<string> {
const supabase = await createClient()
// Get encrypted tokens from database
const { data: fullAccount, error: fetchError } = await supabase
.from('tiktok_accounts')
.select('access_token_encrypted, refresh_token_encrypted')
.eq('id', account.id)
.single()
if (fetchError || !fullAccount) {
throw new Error('Failed to fetch account tokens')
}
const { access_token, refresh_token } = decryptTokens(
fullAccount.access_token_encrypted,
fullAccount.refresh_token_encrypted
)
const tokenExpiresAt = new Date(account.token_expires_at)
const refreshTokenExpiresAt = new Date(account.refresh_token_expires_at)
// Check if refresh token is expired
if (isRefreshTokenExpired(refreshTokenExpiresAt)) {
// Mark account as expired
await supabase
.from('tiktok_accounts')
.update({ status: 'expired', last_error: 'Refresh token expired' })
.eq('id', account.id)
throw new Error('Refresh token expired - reconnection required')
}
// Check if access token needs refresh
if (shouldRefreshToken(tokenExpiresAt)) {
try {
const newTokens = await refreshAccessToken(refresh_token)
const encrypted = encryptTokens(newTokens.access_token, newTokens.refresh_token)
const now = new Date()
const newTokenExpiresAt = new Date(now.getTime() + newTokens.expires_in * 1000)
const newRefreshExpiresAt = new Date(now.getTime() + newTokens.refresh_expires_in * 1000)
await supabase
.from('tiktok_accounts')
.update({
...encrypted,
token_expires_at: newTokenExpiresAt.toISOString(),
refresh_token_expires_at: newRefreshExpiresAt.toISOString(),
error_count: 0,
last_error: null,
})
.eq('id', account.id)
// Log the token refresh
await supabase.from('tiktok_sync_logs').insert({
tiktok_account_id: account.id,
user_id: account.user_id,
sync_type: 'token_refresh',
status: 'success',
})
return newTokens.access_token
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Token refresh failed'
await supabase
.from('tiktok_accounts')
.update({
status: 'error',
last_error: errorMessage,
error_count: account.error_count + 1,
})
.eq('id', account.id)
throw error
}
}
return access_token
}
/**
* Sync daily stats for an account
*/
export async function syncDailyStats(account: TikTokAccount, accessToken: string): Promise<boolean> {
const supabase = await createClient()
const today = new Date().toISOString().split('T')[0]
try {
const userInfo = await getUserInfo({
accessToken,
userId: account.user_id,
})
// Insert or update daily stats
const { error } = await supabase
.from('tiktok_daily_stats')
.upsert({
tiktok_account_id: account.id,
user_id: account.user_id,
stats_date: today,
follower_count: userInfo.follower_count,
following_count: userInfo.following_count,
likes_count: userInfo.likes_count,
video_count: userInfo.video_count,
}, {
onConflict: 'tiktok_account_id,stats_date',
})
if (error) {
console.error('Failed to save daily stats:', error)
return false
}
// Update account with latest sync time
await supabase
.from('tiktok_accounts')
.update({
last_stats_sync_at: new Date().toISOString(),
display_name: userInfo.display_name,
avatar_url: userInfo.avatar_url,
})
.eq('id', account.id)
return true
} catch (error) {
console.error('Failed to sync daily stats:', error)
return false
}
}
/**
* Sync videos for an account
*/
export async function syncVideos(account: TikTokAccount, accessToken: string): Promise<{ synced: number; new: number }> {
const supabase = await createClient()
try {
const videos = await getAllVideos({
accessToken,
userId: account.user_id,
})
let syncedCount = 0
let newCount = 0
for (const video of videos) {
// Check if video already exists
const { data: existing } = await supabase
.from('tiktok_videos')
.select('id')
.eq('tiktok_account_id', account.id)
.eq('tiktok_video_id', video.id)
.single()
const videoData = {
tiktok_account_id: account.id,
user_id: account.user_id,
tiktok_video_id: video.id,
title: video.title || video.video_description || null,
share_url: video.share_url,
cover_image_url: video.cover_image_url,
view_count: video.view_count,
like_count: video.like_count,
comment_count: video.comment_count,
share_count: video.share_count,
duration: video.duration,
published_at: new Date(video.create_time * 1000).toISOString(),
}
if (existing) {
// Update existing video metrics
await supabase
.from('tiktok_videos')
.update(videoData)
.eq('id', existing.id)
} else {
// Insert new video
await supabase.from('tiktok_videos').insert(videoData)
newCount++
}
syncedCount++
}
// Update account with latest video sync time
await supabase
.from('tiktok_accounts')
.update({ last_video_sync_at: new Date().toISOString() })
.eq('id', account.id)
return { synced: syncedCount, new: newCount }
} catch (error) {
console.error('Failed to sync videos:', error)
return { synced: 0, new: 0 }
}
}
/**
* Full sync for an account
*/
export async function syncAccount(
account: TikTokAccount,
syncType: TikTokSyncType = 'full'
): Promise<SyncResult> {
const supabase = await createClient()
const result: SyncResult = {
success: false,
statsSynced: false,
videosSynced: 0,
newVideos: 0,
}
// Create sync log entry
const { data: syncLog } = await supabase
.from('tiktok_sync_logs')
.insert({
tiktok_account_id: account.id,
user_id: account.user_id,
sync_type: syncType,
status: 'started',
})
.select()
.single()
try {
// Ensure valid token
const accessToken = await ensureValidToken(account)
// Sync based on type
if (syncType === 'full' || syncType === 'stats') {
result.statsSynced = await syncDailyStats(account, accessToken)
}
if (syncType === 'full' || syncType === 'videos') {
const videoResult = await syncVideos(account, accessToken)
result.videosSynced = videoResult.synced
result.newVideos = videoResult.new
}
result.success = true
// Update sync log
if (syncLog) {
await supabase
.from('tiktok_sync_logs')
.update({
status: 'success',
stats_synced: result.statsSynced,
videos_synced: result.videosSynced,
new_videos: result.newVideos,
completed_at: new Date().toISOString(),
})
.eq('id', syncLog.id)
}
// Update account last synced
await supabase
.from('tiktok_accounts')
.update({
last_synced_at: new Date().toISOString(),
status: 'active',
error_count: 0,
last_error: null,
})
.eq('id', account.id)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
const errorCode = isTokenExpiredError(error) ? 'token_expired' : 'sync_failed'
result.error = errorMessage
result.errorCode = errorCode
// Update sync log
if (syncLog) {
await supabase
.from('tiktok_sync_logs')
.update({
status: 'failed',
error_message: errorMessage,
error_code: errorCode,
completed_at: new Date().toISOString(),
})
.eq('id', syncLog.id)
}
// Update account error status
await supabase
.from('tiktok_accounts')
.update({
status: isTokenExpiredError(error) ? 'expired' : 'error',
last_error: errorMessage,
error_count: account.error_count + 1,
})
.eq('id', account.id)
}
return result
}
/**
* Sync all active accounts (for cron job)
*/
export async function syncAllAccounts(): Promise<{
total: number
successful: number
failed: number
}> {
const supabase = await createClient()
// Get all active accounts
const { data: accounts, error } = await supabase
.from('tiktok_accounts')
.select('*')
.eq('status', 'active')
if (error || !accounts) {
return { total: 0, successful: 0, failed: 0 }
}
let successful = 0
let failed = 0
for (const account of accounts) {
const result = await syncAccount(account as TikTokAccount)
if (result.success) {
successful++
} else {
failed++
}
// Small delay between accounts
await new Promise(resolve => setTimeout(resolve, 1000))
}
return { total: accounts.length, successful, failed }
}
/**
* Refresh tokens for accounts expiring soon (for cron job)
*/
export async function refreshExpiringTokens(): Promise<number> {
const supabase = await createClient()
// Get accounts with tokens expiring in the next 6 hours
const sixHoursFromNow = new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString()
const { data: accounts, error } = await supabase
.from('tiktok_accounts')
.select('*')
.eq('status', 'active')
.lt('token_expires_at', sixHoursFromNow)
if (error || !accounts) {
return 0
}
let refreshed = 0
for (const account of accounts) {
try {
await ensureValidToken(account as TikTokAccount)
refreshed++
} catch (error) {
console.error(`Failed to refresh token for account ${account.id}:`, error)
}
// Small delay between refreshes
await new Promise(resolve => setTimeout(resolve, 500))
}
return refreshed
}
-101
View File
@@ -1,101 +0,0 @@
// TikTok API types (internal use)
// TikTok OAuth tokens
export interface TikTokTokens {
access_token: string
refresh_token: string
expires_in: number
refresh_expires_in: number
open_id: string
scope: string
token_type: string
}
// TikTok API response wrapper
export interface TikTokApiResponse<T> {
data: T
error: {
code: string
message: string
log_id: string
}
}
// TikTok user info response
export interface TikTokUserInfoResponse {
user: {
open_id: string
union_id: string
avatar_url: string
avatar_url_100: string
avatar_large_url: string
display_name: string
bio_description: string
profile_deep_link: string
is_verified: boolean
follower_count: number
following_count: number
likes_count: number
video_count: number
username: string
}
}
// TikTok video list response
export interface TikTokVideoListResponse {
videos: TikTokVideoData[]
cursor: number
has_more: boolean
}
// TikTok video data from API
export interface TikTokVideoData {
id: string
title: string
video_description: string
duration: number
cover_image_url: string
share_url: string
embed_link: string
embed_html: string
create_time: number
like_count: number
comment_count: number
share_count: number
view_count: number
}
// TikTok scopes
export const TIKTOK_SCOPES = [
'user.info.basic',
'user.info.profile',
'user.info.stats',
'video.list',
] as const
export type TikTokScope = (typeof TIKTOK_SCOPES)[number]
// TikTok API endpoints
export const TIKTOK_API_ENDPOINTS = {
authorize: 'https://www.tiktok.com/v2/auth/authorize/',
token: 'https://open.tiktokapis.com/v2/oauth/token/',
revokeToken: 'https://open.tiktokapis.com/v2/oauth/revoke/',
userInfo: 'https://open.tiktokapis.com/v2/user/info/',
videoList: 'https://open.tiktokapis.com/v2/video/list/',
} as const
// Rate limit info
export const TIKTOK_RATE_LIMITS = {
requestsPerMinute: 600,
requestsPerDay: 10000,
} as const
// Error codes
export const TIKTOK_ERROR_CODES = {
INVALID_ACCESS_TOKEN: 'invalid_access_token',
ACCESS_TOKEN_EXPIRED: 'access_token_expired',
REFRESH_TOKEN_EXPIRED: 'refresh_token_expired',
RATE_LIMIT_EXCEEDED: 'rate_limit_exceeded',
SCOPE_NOT_AUTHORIZED: 'scope_not_authorized',
USER_NOT_FOUND: 'user_not_found',
} as const
-2
View File
@@ -13,8 +13,6 @@ export interface SuggestedCategory {
const CATEGORY_LABELS: Record<string, string> = {
income_services: 'Tjänster',
income_products: 'Produkter',
income_sponsorship: 'Sponsring',
income_affiliate: 'Affiliate',
income_other: 'Övriga intäkter',
expense_equipment: 'Utrustning',
expense_software: 'Programvara',