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:
co-authored by
Claude Opus 4.6
parent
a25d10a528
commit
0aecf5b41c
@@ -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.
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './deadline-generator'
|
||||
export * from './exclusivity-checker'
|
||||
export * from './workload-analyzer'
|
||||
export * from './payment-tracker'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -328,8 +328,6 @@ function getVatRateForCategory(category: string | null): number {
|
||||
'uncategorized',
|
||||
'income_services',
|
||||
'income_products',
|
||||
'income_sponsorship',
|
||||
'income_affiliate',
|
||||
'income_other',
|
||||
]
|
||||
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user