Initial copy from influencer-biz

This commit is contained in:
Jakob Wennberg
2026-02-13 14:00:46 +01:00
commit a25d10a528
391 changed files with 96224 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createSession, getAccountBalance, type AccountInfo } from '@/lib/banking/enable-banking'
interface StoredAccount {
uid: string
iban?: string
name?: string
currency: string
balance?: number
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
// Enable Banking returns: ?code=XXX&state=user_id or ?error=XXX&error_description=YYY
const code = searchParams.get('code')
const state = searchParams.get('state') // This is the user_id we passed during authorization
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')
// Redirect URL for success/error
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
// Handle errors from bank authorization
if (error) {
const errorMessage = errorDescription || error
console.error('Bank authorization error:', errorMessage)
return NextResponse.redirect(
`${baseUrl}/settings?bank_error=${encodeURIComponent(errorMessage)}`
)
}
if (!code || !state) {
return NextResponse.redirect(`${baseUrl}/settings?bank_error=missing_parameters`)
}
const supabase = await createClient()
try {
// Create session from authorization code
const sessionData = await createSession(code)
// Extract data from session response
const { session_id, accounts, access, aspsp } = sessionData
const consentExpiresAt = access.valid_until
// Get balances for each account
const accountsWithBalances: StoredAccount[] = await Promise.all(
accounts.map(async (account: AccountInfo) => {
try {
const balance = await getAccountBalance(account.uid)
return {
uid: account.uid,
iban: account.account_id?.iban,
name: account.name || account.product,
currency: account.currency,
balance: balance.amount,
}
} catch (balanceError) {
console.error(`Failed to get balance for account ${account.uid}:`, balanceError)
return {
uid: account.uid,
iban: account.account_id?.iban,
name: account.name || account.product,
currency: account.currency,
balance: undefined,
}
}
})
)
// Find the pending connection for this user
// We match by user_id (state) and status='pending'
const { data: pendingConnection, error: findError } = await supabase
.from('bank_connections')
.select('id')
.eq('user_id', state)
.eq('status', 'pending')
.order('created_at', { ascending: false })
.limit(1)
.single()
if (findError || !pendingConnection) {
console.error('Could not find pending connection:', findError)
// Create a new connection if no pending one exists
const { error: insertError } = await supabase
.from('bank_connections')
.insert({
user_id: state,
bank_id: `${aspsp.name.toLowerCase().replace(/\s+/g, '-')}-${aspsp.country.toLowerCase()}`,
bank_name: aspsp.name,
session_id,
status: 'active',
accounts: accountsWithBalances,
consent_expires_at: consentExpiresAt,
last_synced_at: new Date().toISOString(),
})
if (insertError) {
throw new Error('Failed to create connection')
}
} else {
// Update the pending connection with session data
const { error: updateError } = await supabase
.from('bank_connections')
.update({
session_id,
status: 'active',
accounts: accountsWithBalances,
consent_expires_at: consentExpiresAt,
last_synced_at: new Date().toISOString(),
})
.eq('id', pendingConnection.id)
if (updateError) {
throw new Error('Failed to update connection')
}
}
// Check if the user has completed onboarding to decide redirect target
const { data: userSettings } = await supabase
.from('company_settings')
.select('onboarding_complete')
.eq('user_id', state)
.single()
const redirectTarget = userSettings?.onboarding_complete
? '/settings?bank_connected=true'
: '/onboarding?bank_connected=true'
return NextResponse.redirect(`${baseUrl}${redirectTarget}`)
} catch (error) {
console.error('Bank callback error:', error)
// Try to update connection status to error
try {
await supabase
.from('bank_connections')
.update({ status: 'error' })
.eq('user_id', state)
.eq('status', 'pending')
} catch {
// Ignore cleanup errors
}
return NextResponse.redirect(
`${baseUrl}/settings?bank_error=${encodeURIComponent('Connection failed')}`
)
}
}
+92
View File
@@ -0,0 +1,92 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { startAuthorization, getASPSPs, type ASPSP } from '@/lib/banking/enable-banking'
export async function GET() {
try {
const aspsps = await getASPSPs('SE')
// Transform to frontend-friendly format
const banks = aspsps.map((aspsp: ASPSP) => ({
name: aspsp.name,
country: aspsp.country,
logo: aspsp.logo,
bic: aspsp.bic,
}))
return NextResponse.json({ banks })
} catch (error) {
console.error('Error fetching banks:', error)
// Return fallback list
return NextResponse.json({
banks: [
{ name: 'Nordea', country: 'SE', bic: 'NDEASESS' },
{ name: 'SEB', country: 'SE', bic: 'ESSESESS' },
{ name: 'Swedbank', country: 'SE', bic: 'SWEDSESS' },
{ name: 'Handelsbanken', country: 'SE', bic: 'HANDSESS' },
]
})
}
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { aspsp_name, aspsp_country } = await request.json()
if (!aspsp_name || !aspsp_country) {
return NextResponse.json(
{ error: 'aspsp_name and aspsp_country are required' },
{ status: 400 }
)
}
try {
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/banking/callback`
// Start the authorization flow with Enable Banking
const { url, authorization_id } = await startAuthorization(
aspsp_name,
aspsp_country,
redirectUrl,
user.id, // state parameter - returned in callback
'personal'
)
// Store pending connection in database with authorization_id
// Note: session_id will be set after callback receives the code
const { data: connection, error } = await supabase
.from('bank_connections')
.insert({
user_id: user.id,
bank_id: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
bank_name: aspsp_name,
authorization_id,
status: 'pending',
})
.select()
.single()
if (error) {
console.error('Database error:', error)
throw new Error('Failed to store connection')
}
return NextResponse.json({
connection_id: connection.id,
authorization_url: url,
})
} catch (error) {
console.error('Bank connection error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Connection failed' },
{ status: 500 }
)
}
}
+176
View File
@@ -0,0 +1,176 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { syncAccountTransactions } from '@/lib/banking/sync-transactions'
import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/lib/banking/enable-banking'
interface StoredAccount {
uid: string
iban?: string
name?: string
currency: string
balance?: number
}
/**
* GET /api/banking/sync/cron
* Automatic daily bank transaction sync
* Runs at 05:00 UTC (07:00 Swedish time)
*
* Processes up to 10 connections per run (Vercel Hobby 60s timeout).
* Prioritizes connections not synced for the longest time.
* Deduplication via external_id makes repeated runs safe.
*/
export async function GET(request: Request) {
// Verify cron secret
const authHeader = request.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return NextResponse.json(
{ error: 'Missing Supabase configuration' },
{ status: 500 }
)
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
// Fetch active bank connections, prioritize least recently synced
const { data: connections, error: connError } = await supabase
.from('bank_connections')
.select('*')
.eq('status', 'active')
.order('last_synced_at', { ascending: true, nullsFirst: true })
.limit(10)
if (connError) {
console.error('Failed to fetch bank connections:', connError)
return NextResponse.json({ error: 'Failed to fetch connections' }, { status: 500 })
}
if (!connections || connections.length === 0) {
return NextResponse.json({ message: 'No active connections to sync', processed: 0 })
}
const results: {
connectionId: string
userId: string
bankName: string
imported: number
duplicates: number
errors: number
status: 'synced' | 'expired' | 'expiring_soon' | 'error'
daysUntilExpiry?: number | null
}[] = []
for (const connection of connections) {
try {
// Check consent expiry
const daysLeft = getDaysUntilExpiry(connection.consent_expires_at)
const isExpired = daysLeft !== null && daysLeft <= 0
if (isExpired) {
// Mark as expired, skip sync
await supabase
.from('bank_connections')
.update({ status: 'expired' })
.eq('id', connection.id)
results.push({
connectionId: connection.id,
userId: connection.user_id,
bankName: connection.bank_name,
imported: 0,
duplicates: 0,
errors: 0,
status: 'expired',
daysUntilExpiry: 0,
})
continue
}
const expiringSoon = isConsentExpiringSoon(connection.consent_expires_at)
// Sync last 7 days (daily cron, with overlap for safety)
const toDate = new Date().toISOString().split('T')[0]
const fromDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0]
const accounts = (connection.accounts as StoredAccount[] || []).map(a => ({ ...a }))
let totalImported = 0
let totalDuplicates = 0
let totalErrors = 0
for (const account of accounts) {
const result = await syncAccountTransactions(
supabase,
connection.user_id,
connection.id,
account,
fromDate,
toDate
)
totalImported += result.imported
totalDuplicates += result.duplicates
totalErrors += result.errors
}
// Update connection with new account balances and sync timestamp
await supabase
.from('bank_connections')
.update({
accounts,
last_synced_at: new Date().toISOString(),
})
.eq('id', connection.id)
results.push({
connectionId: connection.id,
userId: connection.user_id,
bankName: connection.bank_name,
imported: totalImported,
duplicates: totalDuplicates,
errors: totalErrors,
status: expiringSoon ? 'expiring_soon' : 'synced',
daysUntilExpiry: daysLeft,
})
} catch (error) {
console.error(`Sync failed for connection ${connection.id}:`, error)
results.push({
connectionId: connection.id,
userId: connection.user_id,
bankName: connection.bank_name,
imported: 0,
duplicates: 0,
errors: 1,
status: 'error',
})
// Continue with other connections
}
}
const totalImported = results.reduce((sum, r) => sum + r.imported, 0)
const totalExpired = results.filter(r => r.status === 'expired').length
const totalExpiringSoon = results.filter(r => r.status === 'expiring_soon').length
const totalFailed = results.filter(r => r.status === 'error').length
console.log(`[bank-sync-cron] Processed ${results.length} connections: ${totalImported} imported, ${totalExpired} expired, ${totalExpiringSoon} expiring soon, ${totalFailed} failed`)
return NextResponse.json({
processed: results.length,
totalImported,
totalExpired,
totalExpiringSoon,
totalFailed,
results,
})
}
+86
View File
@@ -0,0 +1,86 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { syncAccountTransactions } from '@/lib/banking/sync-transactions'
interface StoredAccount {
uid: string
iban?: string
name?: string
currency: string
balance?: number
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { connection_id, days_back = 30 } = await request.json()
// Get the bank connection
const { data: connection, error: connectionError } = await supabase
.from('bank_connections')
.select('*')
.eq('id', connection_id)
.eq('user_id', user.id)
.single()
if (connectionError || !connection) {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
}
if (connection.status !== 'active') {
return NextResponse.json({ error: 'Connection is not active' }, { status: 400 })
}
try {
const accounts = (connection.accounts as StoredAccount[] || []).map(a => ({ ...a }))
const toDate = new Date().toISOString().split('T')[0]
const fromDate = new Date(Date.now() - days_back * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0]
let totalImported = 0
let totalDuplicates = 0
for (const account of accounts) {
const result = await syncAccountTransactions(
supabase,
user.id,
connection.id,
account,
fromDate,
toDate
)
totalImported += result.imported
totalDuplicates += result.duplicates
}
// Update connection with new account balances and sync timestamp
await supabase
.from('bank_connections')
.update({
accounts,
last_synced_at: new Date().toISOString(),
})
.eq('id', connection.id)
return NextResponse.json({
imported: totalImported,
duplicates: totalDuplicates,
last_synced_at: new Date().toISOString(),
})
} catch (error) {
console.error('Sync error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Sync failed' },
{ status: 500 }
)
}
}
@@ -0,0 +1,36 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function PUT(
request: Request,
{ params }: { params: Promise<{ number: string }> }
) {
const { number } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { data, error } = await supabase
.from('chart_of_accounts')
.update({
account_name: body.account_name,
is_active: body.is_active,
description: body.description,
default_vat_code: body.default_vat_code,
})
.eq('user_id', user.id)
.eq('account_number', number)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
+72
View File
@@ -0,0 +1,72 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const accountClass = searchParams.get('class')
const activeOnly = searchParams.get('active') !== 'false'
let query = supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.order('sort_order')
if (activeOnly) {
query = query.eq('is_active', true)
}
if (accountClass) {
query = query.eq('account_class', parseInt(accountClass))
}
const { data, error } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { data, error } = await supabase
.from('chart_of_accounts')
.insert({
user_id: user.id,
account_number: body.account_number,
account_name: body.account_name,
account_class: parseInt(body.account_number[0]),
account_group: body.account_number.substring(0, 2),
account_type: body.account_type,
normal_balance: body.normal_balance,
plan_type: body.plan_type || 'k1',
is_system_account: false,
description: body.description || null,
sort_order: parseInt(body.account_number),
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
@@ -0,0 +1,52 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateFiscalPeriodInput } from '@/types'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('fiscal_periods')
.select('*')
.eq('user_id', user.id)
.order('period_start', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json() as CreateFiscalPeriodInput
const { data, error } = await supabase
.from('fiscal_periods')
.insert({
user_id: user.id,
name: body.name,
period_start: body.period_start,
period_end: body.period_end,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
@@ -0,0 +1,26 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { reverseEntry } from '@/lib/bookkeeping/engine'
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const reversalEntry = await reverseEntry(user.id, id)
return NextResponse.json({ data: reversalEntry })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to reverse entry' },
{ status: 400 }
)
}
}
@@ -0,0 +1,28 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 404 })
}
return NextResponse.json({ data })
}
@@ -0,0 +1,74 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import type { CreateJournalEntryInput } from '@/types'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const status = searchParams.get('status')
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
const dateFrom = searchParams.get('date_from')
const dateTo = searchParams.get('date_to')
let query = supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)', { count: 'exact' })
.eq('user_id', user.id)
.order('entry_date', { ascending: false })
.order('voucher_number', { ascending: false })
.range(offset, offset + limit - 1)
if (periodId) {
query = query.eq('fiscal_period_id', periodId)
}
if (status) {
query = query.eq('status', status)
}
if (dateFrom) {
query = query.gte('entry_date', dateFrom)
}
if (dateTo) {
query = query.lte('entry_date', dateTo)
}
const { data, error, count } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data, count })
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json() as CreateJournalEntryInput
try {
const entry = await createJournalEntry(user.id, body)
return NextResponse.json({ data: entry })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to create journal entry' },
{ status: 400 }
)
}
}
@@ -0,0 +1,45 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
import type { Transaction } from '@/types'
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
// Accept either a transaction ID or raw transaction data
let transaction: Transaction
if (body.transaction_id) {
const { data, error } = await supabase
.from('transactions')
.select('*')
.eq('id', body.transaction_id)
.eq('user_id', user.id)
.single()
if (error || !data) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
transaction = data as Transaction
} else {
transaction = body as Transaction
}
try {
const result = await evaluateMappingRules(user.id, transaction)
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Evaluation failed' },
{ status: 500 }
)
}
}
@@ -0,0 +1,64 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('mapping_rules')
.select('*')
.or(`user_id.eq.${user.id},user_id.is.null`)
.eq('is_active', true)
.order('priority')
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { data, error } = await supabase
.from('mapping_rules')
.insert({
user_id: user.id,
rule_name: body.rule_name,
rule_type: body.rule_type,
priority: body.priority || 10,
mcc_codes: body.mcc_codes || null,
merchant_pattern: body.merchant_pattern || null,
description_pattern: body.description_pattern || null,
amount_min: body.amount_min || null,
amount_max: body.amount_max || null,
debit_account: body.debit_account,
credit_account: body.credit_account,
vat_treatment: body.vat_treatment || null,
risk_level: body.risk_level || 'NONE',
default_private: body.default_private || false,
requires_review: body.requires_review || false,
confidence_score: body.confidence_score || 0.9,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
+65
View File
@@ -0,0 +1,65 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/briefings/[id]/download
* Get a signed URL to download a PDF briefing
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get the briefing
const { data: briefing, error: fetchError } = await supabase
.from('briefings')
.select('briefing_type, content, filename')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Briefing not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Verify it's a PDF type
if (briefing.briefing_type !== 'pdf') {
return NextResponse.json(
{ error: 'Download is only available for PDF briefings' },
{ status: 400 }
)
}
if (!briefing.content) {
return NextResponse.json(
{ error: 'No file path found for this briefing' },
{ status: 404 }
)
}
// Create signed URL (valid for 1 hour)
const { data: signedUrl, error: signError } = await supabase.storage
.from('contracts')
.createSignedUrl(briefing.content, 3600, {
download: briefing.filename || 'briefing.pdf',
})
if (signError) {
return NextResponse.json({ error: signError.message }, { status: 500 })
}
return NextResponse.json({ url: signedUrl.signedUrl })
}
+159
View File
@@ -0,0 +1,159 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateBriefingInput } from '@/types'
/**
* GET /api/briefings/[id]
* Get a single briefing
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('briefings')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Briefing not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* PATCH /api/briefings/[id]
* Update a briefing
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify briefing exists and belongs to user
const { data: existing, error: fetchError } = await supabase
.from('briefings')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Briefing not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
const body: Partial<CreateBriefingInput> = await request.json()
// Build update object
const updateData: Record<string, unknown> = {}
if (body.title !== undefined) updateData.title = body.title
if (body.content !== undefined) updateData.content = body.content
if (body.text_content !== undefined) updateData.text_content = body.text_content
if (body.notes !== undefined) updateData.notes = body.notes
// Only allow updating certain fields for PDF type
if (existing.briefing_type === 'pdf') {
if (body.filename !== undefined) updateData.filename = body.filename
if (body.file_size !== undefined) updateData.file_size = body.file_size
if (body.mime_type !== undefined) updateData.mime_type = body.mime_type
}
// Update the briefing
const { data, error } = await supabase
.from('briefings')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* DELETE /api/briefings/[id]
* Delete a briefing
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get the briefing to check if we need to delete a file
const { data: briefing, error: fetchError } = await supabase
.from('briefings')
.select('briefing_type, content')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Briefing not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// If it's a PDF, delete the file from storage
if (briefing.briefing_type === 'pdf' && briefing.content) {
await supabase.storage.from('contracts').remove([briefing.content])
}
// Delete the briefing
const { error } = await supabase
.from('briefings')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
+57
View File
@@ -0,0 +1,57 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import Anthropic from '@anthropic-ai/sdk'
const anthropic = new Anthropic()
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { text } = await request.json()
if (!text || typeof text !== 'string') {
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
}
try {
const message = await anthropic.messages.create({
model: 'claude-sonnet-4-5-20250929',
max_tokens: 1024,
messages: [
{
role: 'user',
content: `Du är en assistent som hjälper influencers. Sammanfatta följande mailkonversation/text till en strukturerad briefing.
Formatera sammanfattningen med dessa rubriker (hoppa över de som inte nämns):
- **Varumärke/Kund**: Vilken kund eller varumärke det gäller
- **Vad ska göras**: Innehåll/publiceringar som förväntas
- **Deadlines**: Datum och tidsramar
- **Belopp**: Ersättning om nämnt
- **Övriga detaljer**: Annat viktigt
Texten att sammanfatta:
${text}`,
},
],
})
const summary = message.content[0].type === 'text' ? message.content[0].text : ''
return NextResponse.json({ data: { summary } })
} catch (error) {
console.error('Briefing summarization error:', error)
return NextResponse.json(
{ error: 'Failed to summarize briefing' },
{ status: 500 }
)
}
}
+136
View File
@@ -0,0 +1,136 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { generateCalendarFeed } from '@/lib/calendar/ics-generator'
/**
* GET /api/calendar/feed/[token]
* Returns an ICS calendar feed for the given token
* No authentication required - the token IS the authentication
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params
// Validate token format (UUID)
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!uuidRegex.test(token)) {
return new NextResponse('Invalid token', { status: 400 })
}
// Create service client (no user auth required)
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return new NextResponse('Server configuration error', { status: 500 })
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
// Fetch feed settings by token
const { data: feed, error: feedError } = await supabase
.from('calendar_feeds')
.select('*')
.eq('feed_token', token)
.eq('is_active', true)
.single()
if (feedError || !feed) {
return new NextResponse('Feed not found or inactive', { status: 404 })
}
// Update access tracking
await supabase
.from('calendar_feeds')
.update({
last_accessed_at: new Date().toISOString(),
access_count: feed.access_count + 1,
})
.eq('id', feed.id)
// Calculate date range: 3 months back, 12 months forward
const now = new Date()
const startDate = new Date(now)
startDate.setMonth(startDate.getMonth() - 3)
const endDate = new Date(now)
endDate.setMonth(endDate.getMonth() + 12)
const startStr = startDate.toISOString().split('T')[0]
const endStr = endDate.toISOString().split('T')[0]
// Fetch relevant data based on feed options
const [deadlinesResult, invoicesResult, campaignsResult, exclusivitiesResult] = await Promise.all([
// Deadlines
(feed.include_tax_deadlines || feed.include_campaigns)
? supabase
.from('deadlines')
.select('*')
.eq('user_id', feed.user_id)
.gte('due_date', startStr)
.lte('due_date', endStr)
.order('due_date')
: { data: [] },
// Invoices
feed.include_invoices
? supabase
.from('invoices')
.select('*, customer:customers(*)')
.eq('user_id', feed.user_id)
.gte('due_date', startStr)
.lte('due_date', endStr)
.order('due_date')
: { data: [] },
// Campaigns with deliverables
feed.include_campaigns
? supabase
.from('campaigns')
.select('*, deliverables(*)')
.eq('user_id', feed.user_id)
.in('status', ['active', 'contracted', 'delivered'])
: { data: [] },
// Exclusivities
feed.include_exclusivity
? supabase
.from('exclusivities')
.select('*, campaign:campaigns(name)')
.eq('user_id', feed.user_id)
.gte('end_date', startStr)
.lte('start_date', endStr)
: { data: [] },
])
try {
const icsContent = await generateCalendarFeed(
{
deadlines: deadlinesResult.data || [],
invoices: invoicesResult.data || [],
campaigns: campaignsResult.data || [],
exclusivities: exclusivitiesResult.data || [],
},
{
includeTaxDeadlines: feed.include_tax_deadlines,
includeInvoices: feed.include_invoices,
includeCampaigns: feed.include_campaigns,
includeExclusivity: feed.include_exclusivity,
}
)
return new NextResponse(icsContent, {
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': 'attachment; filename="influencer-biz.ics"',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
})
} catch (error) {
console.error('Error generating ICS feed:', error)
return new NextResponse('Failed to generate calendar feed', { status: 500 })
}
}
+178
View File
@@ -0,0 +1,178 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { UpdateCalendarFeedInput } from '@/types'
/**
* GET /api/calendar/feed
* Get current user's calendar feed settings
*/
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data: feed, error } = await supabase
.from('calendar_feeds')
.select('*')
.eq('user_id', user.id)
.single()
if (error && error.code !== 'PGRST116') {
// PGRST116 = no rows returned, which is fine
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Generate the feed URL
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
if (feed) {
return NextResponse.json({
data: {
...feed,
// Generate webcal:// URL for Apple Calendar
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
// Generate https:// URL for other calendars
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
},
})
}
return NextResponse.json({ data: null })
}
/**
* POST /api/calendar/feed
* Create a new calendar feed for the current user
*/
export async function POST() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if feed already exists
const { data: existingFeed } = await supabase
.from('calendar_feeds')
.select('id')
.eq('user_id', user.id)
.single()
if (existingFeed) {
return NextResponse.json(
{ error: 'Calendar feed already exists' },
{ status: 409 }
)
}
// Create new feed
const { data: feed, error } = await supabase
.from('calendar_feeds')
.insert({
user_id: user.id,
is_active: true,
include_tax_deadlines: true,
include_invoices: true,
include_campaigns: true,
include_exclusivity: false, // Avstängt som standard
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
return NextResponse.json({
data: {
...feed,
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
},
})
}
/**
* PUT /api/calendar/feed
* Update calendar feed settings
*/
export async function PUT(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: UpdateCalendarFeedInput = await request.json()
const { data: feed, error } = await supabase
.from('calendar_feeds')
.update(body)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
return NextResponse.json({
data: {
...feed,
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
},
})
}
/**
* DELETE /api/calendar/feed
* Regenerate calendar feed token (invalidates old URL)
*/
export async function DELETE() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Generate a new token by updating with a new UUID
const { data: feed, error } = await supabase
.from('calendar_feeds')
.update({
feed_token: crypto.randomUUID(),
access_count: 0,
last_accessed_at: null,
})
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
return NextResponse.json({
data: {
...feed,
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
},
})
}
+146
View File
@@ -0,0 +1,146 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateBriefingInput, BriefingType } from '@/types'
/**
* GET /api/campaigns/[id]/briefings
* List all briefings for a campaign
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
// Fetch briefings
const { data, error } = await supabase
.from('briefings')
.select('*')
.eq('campaign_id', campaignId)
.eq('user_id', user.id)
.order('created_at', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* POST /api/campaigns/[id]/briefings
* Create a new briefing for a campaign
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
const body: Omit<CreateBriefingInput, 'campaign_id'> = await request.json()
// Validate required fields
if (!body.title || !body.briefing_type) {
return NextResponse.json(
{ error: 'Title and briefing_type are required' },
{ status: 400 }
)
}
// Validate briefing type
const validTypes: BriefingType[] = ['pdf', 'link', 'text']
if (!validTypes.includes(body.briefing_type)) {
return NextResponse.json(
{ error: 'Invalid briefing_type. Must be pdf, link, or text' },
{ status: 400 }
)
}
// Validate type-specific content
if (body.briefing_type === 'text' && !body.text_content) {
return NextResponse.json(
{ error: 'text_content is required for text type briefings' },
{ status: 400 }
)
}
if (body.briefing_type === 'link' && !body.content) {
return NextResponse.json(
{ error: 'content (URL) is required for link type briefings' },
{ status: 400 }
)
}
if (body.briefing_type === 'pdf' && !body.content) {
return NextResponse.json(
{ error: 'content (file path) is required for pdf type briefings' },
{ status: 400 }
)
}
// Create the briefing
const { data, error } = await supabase
.from('briefings')
.insert({
campaign_id: campaignId,
user_id: user.id,
briefing_type: body.briefing_type,
title: body.title,
content: body.content || null,
text_content: body.text_content || null,
filename: body.filename || null,
file_size: body.file_size || null,
mime_type: body.mime_type || null,
notes: body.notes || null,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data }, { status: 201 })
}
@@ -0,0 +1,117 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* POST /api/campaigns/[id]/briefings/upload
* Upload a PDF briefing file
* Expects multipart/form-data with:
* - file: the PDF file
* - title: briefing title
* - notes: optional notes
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign exists and belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
try {
const formData = await request.formData()
const file = formData.get('file') as File | null
const title = formData.get('title') as string | null
const notes = formData.get('notes') as string | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
if (!title) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 })
}
// Validate file type (only PDF)
if (file.type !== 'application/pdf') {
return NextResponse.json(
{ error: 'Invalid file type. Only PDF files are allowed.' },
{ status: 400 }
)
}
// Max file size: 10MB
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
return NextResponse.json(
{ error: 'File too large. Max size: 10MB' },
{ status: 400 }
)
}
// Generate unique file path in contracts bucket (reusing existing bucket)
// Path: {user_id}/{campaign_id}/briefings/{timestamp}_{filename}
const timestamp = Date.now()
const safeFilename = file.name.replace(/[^a-zA-Z0-9.-]/g, '_')
const filePath = `${user.id}/${campaignId}/briefings/${timestamp}_${safeFilename}`
// Upload to Supabase Storage (using contracts bucket)
const { error: uploadError } = await supabase.storage
.from('contracts')
.upload(filePath, file, {
cacheControl: '3600',
upsert: false,
})
if (uploadError) {
console.error('Upload error:', uploadError)
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })
}
// Create briefing record
const { data, error } = await supabase
.from('briefings')
.insert({
user_id: user.id,
campaign_id: campaignId,
briefing_type: 'pdf',
title: title.trim(),
content: filePath,
filename: file.name,
file_size: file.size,
mime_type: file.type,
notes: notes?.trim() || null,
})
.select()
.single()
if (error) {
// Try to clean up uploaded file
await supabase.storage.from('contracts').remove([filePath])
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data }, { status: 201 })
} catch (err) {
console.error('Briefing upload error:', err)
return NextResponse.json({ error: 'Failed to process upload' }, { status: 500 })
}
}
+185
View File
@@ -0,0 +1,185 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/campaigns/[id]/contracts
* List contracts for a campaign
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign exists and belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
const { data, error } = await supabase
.from('contracts')
.select('*')
.eq('campaign_id', campaignId)
.order('uploaded_at', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* POST /api/campaigns/[id]/contracts
* Upload a contract to a campaign
* Expects multipart/form-data with:
* - file: the contract file
* - signing_date: optional ISO date string
* - is_primary: optional boolean
* - notes: optional string
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign exists and belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id, name')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
try {
const formData = await request.formData()
const file = formData.get('file') as File | null
const signingDate = formData.get('signing_date') as string | null
const isPrimary = formData.get('is_primary') === 'true'
const notes = formData.get('notes') as string | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
// Validate file type (PDF, DOC, DOCX, images)
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'image/jpeg',
'image/png',
'image/webp'
]
if (!allowedTypes.includes(file.type)) {
return NextResponse.json({
error: 'Invalid file type. Allowed: PDF, DOC, DOCX, JPG, PNG, WEBP'
}, { status: 400 })
}
// Max file size: 10MB
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
return NextResponse.json({ error: 'File too large. Max size: 10MB' }, { status: 400 })
}
// Generate unique file path
const timestamp = Date.now()
const safeFilename = file.name.replace(/[^a-zA-Z0-9.-]/g, '_')
const filePath = `${user.id}/${campaignId}/${timestamp}_${safeFilename}`
// Upload to Supabase Storage
const { error: uploadError } = await supabase.storage
.from('contracts')
.upload(filePath, file, {
cacheControl: '3600',
upsert: false
})
if (uploadError) {
console.error('Upload error:', uploadError)
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })
}
// If this is set as primary, unset other primary contracts
if (isPrimary) {
await supabase
.from('contracts')
.update({ is_primary: false })
.eq('campaign_id', campaignId)
.eq('is_primary', true)
}
// Create contract record
const { data, error } = await supabase
.from('contracts')
.insert({
user_id: user.id,
campaign_id: campaignId,
filename: file.name,
file_path: filePath,
file_size: file.size,
mime_type: file.type,
signing_date: signingDate || null,
is_primary: isPrimary,
notes: notes || null,
extraction_status: 'pending'
})
.select()
.single()
if (error) {
// Try to clean up uploaded file
await supabase.storage.from('contracts').remove([filePath])
return NextResponse.json({ error: error.message }, { status: 500 })
}
// If contract is signed and this is primary, update campaign status
if (isPrimary && signingDate) {
await supabase
.from('campaigns')
.update({
contract_signed_at: signingDate,
status: 'contracted'
})
.eq('id', campaignId)
.eq('status', 'negotiation')
}
return NextResponse.json({ data })
} catch (err) {
console.error('Contract upload error:', err)
return NextResponse.json({ error: 'Failed to process upload' }, { status: 500 })
}
}
@@ -0,0 +1,126 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateDeliverableInput } from '@/types'
/**
* GET /api/campaigns/[id]/deliverables
* List deliverables for a campaign
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign exists and belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
const { data, error } = await supabase
.from('deliverables')
.select('*')
.eq('campaign_id', campaignId)
.order('due_date', { ascending: true, nullsFirst: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* POST /api/campaigns/[id]/deliverables
* Add a deliverable to a campaign
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign exists and belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
const body: Omit<CreateDeliverableInput, 'campaign_id'> = await request.json()
// Validate required fields
if (!body.title || !body.deliverable_type || !body.platform) {
return NextResponse.json({ error: 'Missing required fields (title, deliverable_type, platform)' }, { status: 400 })
}
// Insert the deliverable
const { data, error } = await supabase
.from('deliverables')
.insert({
user_id: user.id,
campaign_id: campaignId,
title: body.title,
deliverable_type: body.deliverable_type,
platform: body.platform,
account_handle: body.account_handle || null,
quantity: body.quantity || 1,
description: body.description || null,
specifications: body.specifications || {},
due_date: body.due_date || null,
status: 'pending',
notes: body.notes || null,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Optionally auto-generate a deadline for this deliverable
if (body.due_date) {
await supabase.from('deadlines').insert({
user_id: user.id,
campaign_id: campaignId,
deliverable_id: data.id,
title: `Leverans: ${body.title}`,
due_date: body.due_date,
deadline_type: 'delivery',
priority: 'important',
is_auto_generated: true,
})
}
return NextResponse.json({ data })
}
@@ -0,0 +1,159 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateExclusivityInput } from '@/types'
/**
* GET /api/campaigns/[id]/exclusivities
* List exclusivities for a campaign
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign exists and belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
const { data, error } = await supabase
.from('exclusivities')
.select('*')
.eq('campaign_id', campaignId)
.order('start_date', { ascending: true })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* POST /api/campaigns/[id]/exclusivities
* Add an exclusivity to a campaign
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign exists and belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id, name')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
const body: Omit<CreateExclusivityInput, 'campaign_id'> = await request.json()
// Validate required fields
if (!body.categories || body.categories.length === 0 || !body.start_date || !body.end_date) {
return NextResponse.json({ error: 'Missing required fields (categories, start_date, end_date)' }, { status: 400 })
}
// Validate date range
if (new Date(body.end_date) < new Date(body.start_date)) {
return NextResponse.json({ error: 'End date must be after start date' }, { status: 400 })
}
// Check for conflicts with existing exclusivities
const { data: existingExclusivities } = await supabase
.from('exclusivities')
.select(`
*,
campaign:campaigns(id, name, customer_id)
`)
.eq('user_id', user.id)
.neq('campaign_id', campaignId)
.lte('start_date', body.end_date)
.gte('end_date', body.start_date)
const conflicts = []
if (existingExclusivities) {
for (const existing of existingExclusivities) {
const overlappingCategories = body.categories.filter(cat =>
existing.categories.some((existingCat: string) =>
existingCat.toLowerCase() === cat.toLowerCase()
)
)
if (overlappingCategories.length > 0) {
conflicts.push({
exclusivity_id: existing.id,
campaign_id: existing.campaign?.id,
campaign_name: existing.campaign?.name,
overlapping_categories: overlappingCategories,
overlap_start: body.start_date > existing.start_date ? body.start_date : existing.start_date,
overlap_end: body.end_date < existing.end_date ? body.end_date : existing.end_date
})
}
}
}
// Insert the exclusivity
const { data, error } = await supabase
.from('exclusivities')
.insert({
user_id: user.id,
campaign_id: campaignId,
categories: body.categories,
excluded_brands: body.excluded_brands || [],
start_date: body.start_date,
end_date: body.end_date,
start_calculation_type: body.start_calculation_type || 'absolute',
end_calculation_type: body.end_calculation_type || 'absolute',
start_reference: body.start_reference || null,
end_reference: body.end_reference || null,
start_offset_days: body.start_offset_days || null,
end_offset_days: body.end_offset_days || null,
notes: body.notes || null,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Return with conflict warnings if any
return NextResponse.json({
data,
conflicts: conflicts.length > 0 ? conflicts : undefined,
warning: conflicts.length > 0
? `Varning: ${conflicts.length} överlappande exklusivitet(er) hittades`
: undefined
})
}
+204
View File
@@ -0,0 +1,204 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateCampaignInput, CampaignStatus } from '@/types'
/**
* GET /api/campaigns/[id]
* Get a single campaign with all related data
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('campaigns')
.select(`
*,
customer:customers!campaigns_customer_id_fkey(id, name, email, customer_category, customer_type),
end_customer:customers!campaigns_end_customer_id_fkey(id, name),
deliverables(
id, title, description, deliverable_type, platform, account_handle,
quantity, specifications, due_date, status, submitted_at, approved_at,
published_at, notes, created_at
),
exclusivities(
id, categories, excluded_brands, start_date, end_date,
start_calculation_type, end_calculation_type, notes, created_at
),
contracts(
id, filename, file_path, file_size, mime_type, signing_date,
is_primary, extraction_status, notes, uploaded_at
),
briefings(
id, briefing_type, title, content, text_content, filename,
file_size, mime_type, notes, created_at, updated_at
)
`)
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Also fetch related invoices
const { data: invoices } = await supabase
.from('invoices')
.select('id, invoice_number, invoice_date, due_date, status, total, currency, payment_status')
.eq('campaign_id', id)
.eq('user_id', user.id)
.order('invoice_date', { ascending: false })
return NextResponse.json({ data: { ...data, invoices: invoices || [] } })
}
/**
* PATCH /api/campaigns/[id]
* Update a campaign
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: Partial<CreateCampaignInput> & { status?: CampaignStatus; contract_signed_at?: string } = await request.json()
// Verify campaign exists and belongs to user
const { data: existing, error: fetchError } = await supabase
.from('campaigns')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Validate customer if changed
if (body.customer_id && body.customer_id !== existing.customer_id) {
const { data: customer, error: customerError } = await supabase
.from('customers')
.select('id')
.eq('id', body.customer_id)
.eq('user_id', user.id)
.single()
if (customerError || !customer) {
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
}
}
// Build update object
const updateData: Record<string, unknown> = {}
if (body.name !== undefined) updateData.name = body.name
if (body.description !== undefined) updateData.description = body.description
if (body.customer_id !== undefined) updateData.customer_id = body.customer_id || null
if (body.end_customer_id !== undefined) updateData.end_customer_id = body.end_customer_id || null
if (body.campaign_type !== undefined) updateData.campaign_type = body.campaign_type
if (body.status !== undefined) updateData.status = body.status
if (body.total_value !== undefined) updateData.total_value = body.total_value
if (body.currency !== undefined) updateData.currency = body.currency
if (body.vat_included !== undefined) updateData.vat_included = body.vat_included
if (body.payment_terms !== undefined) updateData.payment_terms = body.payment_terms
if (body.billing_frequency !== undefined) updateData.billing_frequency = body.billing_frequency
if (body.brand_name !== undefined) updateData.brand_name = body.brand_name
if (body.start_date !== undefined) updateData.start_date = body.start_date
if (body.end_date !== undefined) updateData.end_date = body.end_date
if (body.publication_date !== undefined) updateData.publication_date = body.publication_date
if (body.draft_deadline !== undefined) updateData.draft_deadline = body.draft_deadline
if (body.contract_signed_at !== undefined) updateData.contract_signed_at = body.contract_signed_at
if (body.notes !== undefined) updateData.notes = body.notes
// Update the campaign
const { data, error } = await supabase
.from('campaigns')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.select(`
*,
customer:customers!campaigns_customer_id_fkey(id, name),
end_customer:customers!campaigns_end_customer_id_fkey(id, name)
`)
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* DELETE /api/campaigns/[id]
* Delete a campaign (cascades to deliverables, exclusivities, contracts)
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// First, delete any contract files from storage
const { data: contracts } = await supabase
.from('contracts')
.select('file_path')
.eq('campaign_id', id)
if (contracts && contracts.length > 0) {
const filePaths = contracts.map(c => c.file_path)
await supabase.storage.from('contracts').remove(filePaths)
}
// Delete the campaign (cascades to related tables)
const { error } = await supabase
.from('campaigns')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
+321
View File
@@ -0,0 +1,321 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { checkExclusivityConflicts } from '@/lib/campaigns/exclusivity-checker'
import type {
ContractExtractionResult,
CreateCampaignInput,
CreateDeliverableInput,
CreateExclusivityInput,
Exclusivity,
} from '@/types'
interface CreateFromContractInput {
contractId: string
extraction: ContractExtractionResult
customerId: string | null
endCustomerId: string | null
brandName?: string | null
createNewCustomer?: {
name: string
org_number?: string
email?: string
customer_type: 'individual' | 'swedish_business' | 'eu_business' | 'non_eu_business'
}
createNewEndCustomer?: {
name: string
org_number?: string
email?: string
customer_type: 'individual' | 'swedish_business' | 'eu_business' | 'non_eu_business'
}
overrides?: Partial<CreateCampaignInput>
}
/**
* POST /api/campaigns/from-contract
* Create a campaign from extracted contract data
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: CreateFromContractInput = await request.json()
const { contractId, extraction, overrides } = body
let { customerId, endCustomerId } = body
// Verify contract exists and belongs to user
const { data: contract, error: contractError } = await supabase
.from('contracts')
.select('*')
.eq('id', contractId)
.eq('user_id', user.id)
.single()
if (contractError) {
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
}
try {
// Create new customers if needed
if (body.createNewCustomer && !customerId) {
const { data: newCustomer, error: customerError } = await supabase
.from('customers')
.insert({
user_id: user.id,
name: body.createNewCustomer.name,
org_number: body.createNewCustomer.org_number,
email: body.createNewCustomer.email,
customer_type: body.createNewCustomer.customer_type,
country: 'Sweden',
default_payment_terms: extraction.financials.paymentTerms || 30,
})
.select()
.single()
if (customerError) {
throw new Error(`Failed to create customer: ${customerError.message}`)
}
customerId = newCustomer.id
}
if (body.createNewEndCustomer && !endCustomerId) {
const { data: newEndCustomer, error: endCustomerError } = await supabase
.from('customers')
.insert({
user_id: user.id,
name: body.createNewEndCustomer.name,
org_number: body.createNewEndCustomer.org_number,
email: body.createNewEndCustomer.email,
customer_type: body.createNewEndCustomer.customer_type,
country: 'Sweden',
default_payment_terms: 30,
})
.select()
.single()
if (endCustomerError) {
throw new Error(`Failed to create end customer: ${endCustomerError.message}`)
}
endCustomerId = newEndCustomer.id
}
// Create campaign
const campaignData: CreateCampaignInput = {
customer_id: customerId || undefined,
end_customer_id: endCustomerId || undefined,
name: extraction.campaignName || `Samarbete ${new Date().toLocaleDateString('sv-SE')}`,
brand_name: body.brandName || extraction.parties.brand?.name || undefined,
campaign_type: 'influencer',
total_value: extraction.financials.amount || undefined,
currency: extraction.financials.currency || 'SEK',
vat_included: extraction.financials.vatIncluded ?? false,
payment_terms: extraction.financials.paymentTerms || undefined,
billing_frequency: extraction.financials.billingFrequency || undefined,
start_date: extraction.period.startDate || undefined,
end_date: extraction.period.endDate || undefined,
publication_date: extraction.period.publicationDate || undefined,
draft_deadline: (extraction.period as Record<string, unknown>).draftDeadline as string || undefined,
...overrides,
}
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.insert({
user_id: user.id,
...campaignData,
status: 'contracted',
contract_signed_at: extraction.signingDate || new Date().toISOString().split('T')[0],
})
.select()
.single()
if (campaignError) {
throw new Error(`Failed to create campaign: ${campaignError.message}`)
}
// Link contract to campaign
await supabase
.from('contracts')
.update({
campaign_id: campaign.id,
is_primary: true,
extraction_status: 'completed',
})
.eq('id', contractId)
// Create deliverables
const deliverables: { id: string }[] = []
for (const del of extraction.deliverables) {
const deliverableData: CreateDeliverableInput = {
campaign_id: campaign.id,
title: del.description || `${del.type} - ${del.platform || 'Okänd plattform'}`,
deliverable_type: del.type,
platform: del.platform || 'instagram',
account_handle: del.account || undefined,
quantity: del.quantity,
due_date: del.dueDate || undefined,
}
const { data: deliverable, error: deliverableError } = await supabase
.from('deliverables')
.insert({
user_id: user.id,
...deliverableData,
status: 'pending',
})
.select('id')
.single()
if (deliverableError) {
console.error('Failed to create deliverable:', deliverableError)
continue
}
deliverables.push(deliverable)
}
// Create exclusivity if present
let exclusivityConflicts: unknown[] = []
if (extraction.exclusivity.categories.length > 0) {
// Calculate exclusivity dates
const exclusivityStart = extraction.period.startDate ||
new Date().toISOString().split('T')[0]
let exclusivityEnd = extraction.period.endDate ||
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
// Extend end date if post period specified
if (extraction.exclusivity.postPeriodDays) {
const endDate = new Date(exclusivityEnd)
endDate.setDate(endDate.getDate() + extraction.exclusivity.postPeriodDays)
exclusivityEnd = endDate.toISOString().split('T')[0]
}
// Check for conflicts
const { data: existingExclusivities } = await supabase
.from('exclusivities')
.select('*, campaign:campaigns(*)')
.eq('user_id', user.id)
.neq('campaign_id', campaign.id)
if (existingExclusivities) {
exclusivityConflicts = checkExclusivityConflicts(
{
categories: extraction.exclusivity.categories,
start_date: exclusivityStart,
end_date: exclusivityEnd,
} as Exclusivity,
existingExclusivities as Exclusivity[]
)
}
const exclusivityData: CreateExclusivityInput = {
campaign_id: campaign.id,
categories: extraction.exclusivity.categories,
excluded_brands: extraction.exclusivity.excludedBrands,
start_date: exclusivityStart,
end_date: exclusivityEnd,
start_calculation_type: 'absolute',
end_calculation_type: extraction.exclusivity.postReference ? 'relative' : 'absolute',
end_reference: extraction.exclusivity.postReference || undefined,
end_offset_days: extraction.exclusivity.postPeriodDays || undefined,
}
await supabase
.from('exclusivities')
.insert({
user_id: user.id,
...exclusivityData,
})
}
// Create deadlines
const createdDeadlines: unknown[] = []
for (const deadline of extraction.deadlines) {
let dueDate = deadline.absoluteDate
// Calculate relative dates if possible
if (!dueDate && deadline.isRelative && deadline.referenceEvent && deadline.offsetDays) {
let referenceDate: string | null = null
switch (deadline.referenceEvent) {
case 'publication':
referenceDate = extraction.period.publicationDate
break
case 'delivery':
referenceDate = extraction.period.endDate
break
case 'contract':
referenceDate = extraction.signingDate
break
}
if (referenceDate) {
const refDate = new Date(referenceDate)
refDate.setDate(refDate.getDate() + deadline.offsetDays)
dueDate = refDate.toISOString().split('T')[0]
}
}
// Skip if we still don't have a date
if (!dueDate) continue
const { data: createdDeadline, error: deadlineError } = await supabase
.from('deadlines')
.insert({
user_id: user.id,
title: deadline.description,
due_date: dueDate,
deadline_type: deadline.type,
priority: 'normal',
customer_id: customerId,
campaign_id: campaign.id,
is_auto_generated: true,
date_calculation_type: deadline.isRelative ? 'relative' : 'absolute',
reference_event: deadline.referenceEvent,
offset_days: deadline.offsetDays,
})
.select()
.single()
if (!deadlineError && createdDeadline) {
createdDeadlines.push(createdDeadline)
}
}
// Also create standard campaign deadlines
if (campaign.end_date) {
// Invoicing deadline (5 days after end)
const invoiceDate = new Date(campaign.end_date)
invoiceDate.setDate(invoiceDate.getDate() + 5)
await supabase.from('deadlines').insert({
user_id: user.id,
title: `Fakturera: ${campaign.name}`,
due_date: invoiceDate.toISOString().split('T')[0],
deadline_type: 'invoicing',
priority: 'important',
customer_id: customerId,
campaign_id: campaign.id,
is_auto_generated: true,
})
}
return NextResponse.json({
data: {
campaign,
deliverablesCreated: deliverables.length,
deadlinesCreated: createdDeadlines.length,
exclusivityConflicts,
},
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create campaign'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+173
View File
@@ -0,0 +1,173 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateCampaignInput } from '@/types'
/**
* GET /api/campaigns
* List campaigns for the authenticated user
* Query params:
* - status: CampaignStatus (optional, comma-separated for multiple)
* - type: CampaignType (optional)
* - customer_id: string (optional)
* - from: ISO date string (optional, start_date >=)
* - to: ISO date string (optional, end_date <=)
* - limit: number (default: 50)
* - offset: number (default: 0)
*/
export async function GET(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Parse query params
const { searchParams } = new URL(request.url)
const status = searchParams.get('status')
const type = searchParams.get('type')
const customerId = searchParams.get('customer_id')
const from = searchParams.get('from')
const to = searchParams.get('to')
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
// Build query with relations
let query = supabase
.from('campaigns')
.select(`
*,
customer:customers!campaigns_customer_id_fkey(id, name, customer_category),
end_customer:customers!campaigns_end_customer_id_fkey(id, name),
deliverables(id, title, status, due_date, platform, deliverable_type),
exclusivities(id, categories, start_date, end_date),
contracts(id, filename, is_primary)
`, { count: 'exact' })
.eq('user_id', user.id)
// Apply filters
if (status) {
const statuses = status.split(',')
if (statuses.length === 1) {
query = query.eq('status', status)
} else {
query = query.in('status', statuses)
}
}
if (type) {
query = query.eq('campaign_type', type)
}
if (customerId) {
query = query.eq('customer_id', customerId)
}
if (from) {
query = query.gte('start_date', from)
}
if (to) {
query = query.lte('end_date', to)
}
const { data, error, count } = await query
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data, count })
}
/**
* POST /api/campaigns
* Create a new campaign
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: CreateCampaignInput = await request.json()
// Validate required fields
if (!body.name) {
return NextResponse.json({ error: 'Campaign name is required' }, { status: 400 })
}
// Validate customer exists if provided
if (body.customer_id) {
const { data: customer, error: customerError } = await supabase
.from('customers')
.select('id')
.eq('id', body.customer_id)
.eq('user_id', user.id)
.single()
if (customerError || !customer) {
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
}
}
// Validate end_customer exists if provided
if (body.end_customer_id) {
const { data: endCustomer, error: endCustomerError } = await supabase
.from('customers')
.select('id')
.eq('id', body.end_customer_id)
.eq('user_id', user.id)
.single()
if (endCustomerError || !endCustomer) {
return NextResponse.json({ error: 'End customer not found' }, { status: 404 })
}
}
// Insert the campaign
const { data, error } = await supabase
.from('campaigns')
.insert({
user_id: user.id,
customer_id: body.customer_id || null,
end_customer_id: body.end_customer_id || null,
name: body.name,
description: body.description || null,
brand_name: body.brand_name || null,
campaign_type: body.campaign_type || 'influencer',
status: 'negotiation',
total_value: body.total_value || null,
currency: body.currency || 'SEK',
vat_included: body.vat_included || false,
payment_terms: body.payment_terms || null,
billing_frequency: body.billing_frequency || null,
start_date: body.start_date || null,
end_date: body.end_date || null,
publication_date: body.publication_date || null,
draft_deadline: body.draft_deadline || null,
notes: body.notes || null,
})
.select(`
*,
customer:customers!campaigns_customer_id_fkey(id, name),
end_customer:customers!campaigns_end_customer_id_fkey(id, name)
`)
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
+144
View File
@@ -0,0 +1,144 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { PlatformType, DeliverableType, Deliverable } from '@/types'
interface WorkloadDay {
date: string
deliverables: Deliverable[]
totalDeliverables: number
byPlatform: Partial<Record<PlatformType, number>>
byType: Partial<Record<DeliverableType, number>>
workloadLevel: 'light' | 'normal' | 'heavy' | 'overloaded'
}
/**
* GET /api/campaigns/workload
* Get workload analysis for deliverables
* Query params:
* - from: ISO date string (default: today)
* - to: ISO date string (default: 30 days from now)
* - group_by: 'day' | 'week' (default: 'day')
*/
export async function GET(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const from = searchParams.get('from') || new Date().toISOString().split('T')[0]
const defaultTo = new Date()
defaultTo.setDate(defaultTo.getDate() + 30)
const to = searchParams.get('to') || defaultTo.toISOString().split('T')[0]
const groupBy = searchParams.get('group_by') || 'day'
// Fetch deliverables with due dates in range
const { data: deliverables, error } = await supabase
.from('deliverables')
.select(`
*,
campaign:campaigns(id, name, customer_id, customer:customers(id, name))
`)
.eq('user_id', user.id)
.gte('due_date', from)
.lte('due_date', to)
.not('status', 'in', '("approved","published")')
.order('due_date', { ascending: true })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Group deliverables by date
const workloadByDate: Record<string, WorkloadDay> = {}
for (const deliverable of deliverables || []) {
if (!deliverable.due_date) continue
let dateKey = deliverable.due_date
// If grouping by week, use the Monday of that week
if (groupBy === 'week') {
const date = new Date(deliverable.due_date)
const day = date.getDay()
const diff = date.getDate() - day + (day === 0 ? -6 : 1)
date.setDate(diff)
dateKey = date.toISOString().split('T')[0]
}
if (!workloadByDate[dateKey]) {
workloadByDate[dateKey] = {
date: dateKey,
deliverables: [],
totalDeliverables: 0,
byPlatform: {},
byType: {},
workloadLevel: 'light'
}
}
const day = workloadByDate[dateKey]
day.deliverables.push(deliverable)
day.totalDeliverables += deliverable.quantity || 1
// Count by platform
const platform = deliverable.platform as PlatformType
day.byPlatform[platform] = (day.byPlatform[platform] || 0) + (deliverable.quantity || 1)
// Count by type
const type = deliverable.deliverable_type as DeliverableType
day.byType[type] = (day.byType[type] || 0) + (deliverable.quantity || 1)
}
// Calculate workload levels
for (const day of Object.values(workloadByDate)) {
if (groupBy === 'week') {
// Weekly thresholds
if (day.totalDeliverables <= 3) day.workloadLevel = 'light'
else if (day.totalDeliverables <= 7) day.workloadLevel = 'normal'
else if (day.totalDeliverables <= 12) day.workloadLevel = 'heavy'
else day.workloadLevel = 'overloaded'
} else {
// Daily thresholds
if (day.totalDeliverables <= 1) day.workloadLevel = 'light'
else if (day.totalDeliverables <= 2) day.workloadLevel = 'normal'
else if (day.totalDeliverables <= 4) day.workloadLevel = 'heavy'
else day.workloadLevel = 'overloaded'
}
}
// Convert to sorted array
const workload = Object.values(workloadByDate).sort((a, b) =>
a.date.localeCompare(b.date)
)
// Calculate summary stats
const totalDeliverables = deliverables?.reduce((sum, d) => sum + (d.quantity || 1), 0) || 0
const heavyDays = workload.filter(d => d.workloadLevel === 'heavy').length
const overloadedDays = workload.filter(d => d.workloadLevel === 'overloaded').length
// Find busiest day/week
const busiestPeriod = workload.length > 0
? workload.reduce((max, day) => day.totalDeliverables > max.totalDeliverables ? day : max)
: null
return NextResponse.json({
workload,
summary: {
period: { from, to, group_by: groupBy },
total_deliverables: totalDeliverables,
total_periods: workload.length,
heavy_periods: heavyDays,
overloaded_periods: overloadedDays,
busiest_period: busiestPeriod ? {
date: busiestPeriod.date,
count: busiestPeriod.totalDeliverables
} : null
}
})
}
+169
View File
@@ -0,0 +1,169 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateChatResponse } from '@/lib/ai/chatbot/chain'
import { CHATBOT_CONFIG } from '@/lib/ai/chatbot/config'
import type { ChatMessage, ChatRequest } from '@/types/chat'
// Simple in-memory rate limiting (per user)
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
function checkRateLimit(userId: string): boolean {
const now = Date.now()
const limit = rateLimitMap.get(userId)
if (!limit || now > limit.resetTime) {
rateLimitMap.set(userId, {
count: 1,
resetTime: now + 60000, // 1 minute window
})
return true
}
if (limit.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
return false
}
limit.count++
return true
}
/**
* POST /api/chat
* Send a message and get a response
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Rate limiting
if (!checkRateLimit(user.id)) {
return NextResponse.json(
{ error: 'Rate limit exceeded. Please wait a moment.' },
{ status: 429 }
)
}
try {
const body: ChatRequest = await request.json()
const { message, session_id } = body
if (!message || typeof message !== 'string' || message.trim().length === 0) {
return NextResponse.json(
{ error: 'Message is required' },
{ status: 400 }
)
}
let sessionId = session_id
// Create new session if not provided
if (!sessionId) {
const { data: newSession, error: sessionError } = await supabase
.from('chat_sessions')
.insert({
user_id: user.id,
title: message.slice(0, 100), // Use first 100 chars of message as title
})
.select()
.single()
if (sessionError) {
console.error('Error creating session:', sessionError)
return NextResponse.json(
{ error: 'Failed to create chat session' },
{ status: 500 }
)
}
sessionId = newSession.id
} else {
// Verify session belongs to user
const { data: existingSession } = await supabase
.from('chat_sessions')
.select('id')
.eq('id', sessionId)
.eq('user_id', user.id)
.single()
if (!existingSession) {
return NextResponse.json(
{ error: 'Session not found' },
{ status: 404 }
)
}
}
// Save user message
const { data: userMessage, error: userMsgError } = await supabase
.from('chat_messages')
.insert({
session_id: sessionId,
user_id: user.id,
role: 'user',
content: message.trim(),
sources: [],
})
.select()
.single()
if (userMsgError) {
console.error('Error saving user message:', userMsgError)
return NextResponse.json(
{ error: 'Failed to save message' },
{ status: 500 }
)
}
// Get conversation history
const { data: history } = await supabase
.from('chat_messages')
.select('role, content')
.eq('session_id', sessionId)
.order('created_at', { ascending: true })
.limit(CHATBOT_CONFIG.maxHistoryMessages)
const conversationHistory = (history || []) as ChatMessage[]
// Generate AI response
const result = await generateChatResponse(message.trim(), conversationHistory)
// Save assistant message
const { data: assistantMessage, error: assistantMsgError } = await supabase
.from('chat_messages')
.insert({
session_id: sessionId,
user_id: user.id,
role: 'assistant',
content: result.content,
sources: result.sources,
})
.select()
.single()
if (assistantMsgError) {
console.error('Error saving assistant message:', assistantMsgError)
return NextResponse.json(
{ error: 'Failed to save response' },
{ status: 500 }
)
}
return NextResponse.json({
message: assistantMessage,
session_id: sessionId,
})
} catch (err) {
console.error('Chat error:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to process chat' },
{ status: 500 }
)
}
}
+146
View File
@@ -0,0 +1,146 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/chat/sessions/[id]
* Get a single chat session with its messages
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
// Get session with messages
const { data: session, error: sessionError } = await supabase
.from('chat_sessions')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (sessionError || !session) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
}
// Get messages
const { data: messages, error: messagesError } = await supabase
.from('chat_messages')
.select('*')
.eq('session_id', id)
.order('created_at', { ascending: true })
if (messagesError) {
console.error('Error fetching messages:', messagesError)
return NextResponse.json(
{ error: 'Failed to fetch messages' },
{ status: 500 }
)
}
return NextResponse.json({
session,
messages: messages || [],
})
}
/**
* PATCH /api/chat/sessions/[id]
* Update a chat session (e.g., rename)
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const body = await request.json()
const { title } = body
const { data, error } = await supabase
.from('chat_sessions')
.update({ title })
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
console.error('Error updating session:', error)
return NextResponse.json(
{ error: 'Failed to update session' },
{ status: 500 }
)
}
if (!data) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
}
return NextResponse.json({ data })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to update session' },
{ status: 400 }
)
}
}
/**
* DELETE /api/chat/sessions/[id]
* Delete a chat session and its messages
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
// Delete session (messages will cascade delete due to FK)
const { error } = await supabase
.from('chat_sessions')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
console.error('Error deleting session:', error)
return NextResponse.json(
{ error: 'Failed to delete session' },
{ status: 500 }
)
}
return NextResponse.json({ success: true })
}
+84
View File
@@ -0,0 +1,84 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/chat/sessions
* List all chat sessions for the authenticated user
*/
export async function GET(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get('limit') || '20')
const offset = parseInt(searchParams.get('offset') || '0')
const { data, error, count } = await supabase
.from('chat_sessions')
.select('*', { count: 'exact' })
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (error) {
console.error('Error fetching sessions:', error)
return NextResponse.json(
{ error: 'Failed to fetch sessions' },
{ status: 500 }
)
}
return NextResponse.json({ data, count })
}
/**
* POST /api/chat/sessions
* Create a new chat session
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { title } = body
const { data, error } = await supabase
.from('chat_sessions')
.insert({
user_id: user.id,
title: title || null,
})
.select()
.single()
if (error) {
console.error('Error creating session:', error)
return NextResponse.json(
{ error: 'Failed to create session' },
{ status: 500 }
)
}
return NextResponse.json({ data })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to create session' },
{ status: 400 }
)
}
}
+195
View File
@@ -0,0 +1,195 @@
import { createClient } from '@/lib/supabase/server'
import { streamChatResponse } from '@/lib/ai/chatbot/chain'
import { CHATBOT_CONFIG } from '@/lib/ai/chatbot/config'
import type { ChatMessage, ChatRequest, SourceReference } from '@/types/chat'
// Simple in-memory rate limiting (per user)
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
function checkRateLimit(userId: string): boolean {
const now = Date.now()
const limit = rateLimitMap.get(userId)
if (!limit || now > limit.resetTime) {
rateLimitMap.set(userId, {
count: 1,
resetTime: now + 60000,
})
return true
}
if (limit.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
return false
}
limit.count++
return true
}
/**
* POST /api/chat/stream
* Streaming chat response via Server-Sent Events
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return new Response(
JSON.stringify({ error: 'Unauthorized' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
)
}
if (!checkRateLimit(user.id)) {
return new Response(
JSON.stringify({ error: 'Rate limit exceeded' }),
{ status: 429, headers: { 'Content-Type': 'application/json' } }
)
}
try {
const body: ChatRequest = await request.json()
const { message, session_id } = body
if (!message || typeof message !== 'string' || message.trim().length === 0) {
return new Response(
JSON.stringify({ error: 'Message is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
let sessionId = session_id
// Create new session if not provided
if (!sessionId) {
const { data: newSession, error: sessionError } = await supabase
.from('chat_sessions')
.insert({
user_id: user.id,
title: message.slice(0, 100),
})
.select()
.single()
if (sessionError) {
return new Response(
JSON.stringify({ error: 'Failed to create session' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
sessionId = newSession.id
} else {
// Verify session belongs to user
const { data: existingSession } = await supabase
.from('chat_sessions')
.select('id')
.eq('id', sessionId)
.eq('user_id', user.id)
.single()
if (!existingSession) {
return new Response(
JSON.stringify({ error: 'Session not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
)
}
}
// Save user message
await supabase
.from('chat_messages')
.insert({
session_id: sessionId,
user_id: user.id,
role: 'user',
content: message.trim(),
sources: [],
})
// Get conversation history
const { data: history } = await supabase
.from('chat_messages')
.select('role, content')
.eq('session_id', sessionId)
.order('created_at', { ascending: true })
.limit(CHATBOT_CONFIG.maxHistoryMessages)
const conversationHistory = (history || []) as ChatMessage[]
// Create streaming response
const encoder = new TextEncoder()
let fullContent = ''
let sources: SourceReference[] = []
const stream = new ReadableStream({
async start(controller) {
try {
// Send session ID first
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'session', session_id: sessionId })}\n\n`)
)
// Stream the response
for await (const chunk of streamChatResponse(message.trim(), conversationHistory)) {
if (chunk.type === 'content') {
fullContent += chunk.data as string
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'content', content: chunk.data })}\n\n`)
)
} else if (chunk.type === 'sources') {
sources = chunk.data as SourceReference[]
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'sources', sources: chunk.data })}\n\n`)
)
}
}
// Save the complete assistant message
const { data: savedMessage } = await supabase
.from('chat_messages')
.insert({
session_id: sessionId,
user_id: user.id,
role: 'assistant',
content: fullContent,
sources,
})
.select()
.single()
// Send done signal with message ID
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'done', message_id: savedMessage?.id })}\n\n`)
)
controller.close()
} catch (error) {
console.error('Streaming error:', error)
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', error: 'Streaming failed' })}\n\n`)
)
controller.close()
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
} catch (err) {
console.error('Stream setup error:', err)
return new Response(
JSON.stringify({ error: 'Failed to setup stream' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
}
+55
View File
@@ -0,0 +1,55 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/contracts/[id]/download
* Download a contract file
* Returns a signed URL for direct download
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get contract to verify ownership and get file path
const { data: contract, error: fetchError } = await supabase
.from('contracts')
.select('file_path, filename, mime_type')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Create signed URL (valid for 1 hour)
const { data: signedUrl, error: signError } = await supabase.storage
.from('contracts')
.createSignedUrl(contract.file_path, 3600, {
download: contract.filename
})
if (signError || !signedUrl) {
return NextResponse.json({ error: 'Failed to generate download URL' }, { status: 500 })
}
return NextResponse.json({
url: signedUrl.signedUrl,
filename: contract.filename,
mime_type: contract.mime_type
})
}
+244
View File
@@ -0,0 +1,244 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { pdfToBase64, getPDFInfo } from '@/lib/contracts/pdf-extractor'
import { analyzeContract } from '@/lib/contracts/contract-analyzer'
import { matchParties } from '@/lib/customers/customer-matcher'
import type { ContractExtractionResult, Customer } from '@/types'
// Rate limiting map (in production, use Redis or similar)
const extractionTimestamps = new Map<string, number[]>()
const RATE_LIMIT = 10 // Max extractions per minute
const RATE_WINDOW_MS = 60 * 1000 // 1 minute
/**
* POST /api/contracts/[id]/extract
* Extract and analyze contract content using AI
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
// Authenticate user
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Rate limiting
const now = Date.now()
const userTimestamps = extractionTimestamps.get(user.id) || []
const recentTimestamps = userTimestamps.filter((t) => now - t < RATE_WINDOW_MS)
if (recentTimestamps.length >= RATE_LIMIT) {
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 }
)
}
// Record this extraction attempt
recentTimestamps.push(now)
extractionTimestamps.set(user.id, recentTimestamps)
// Get contract
const { data: contract, error: fetchError } = await supabase
.from('contracts')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Check if already extracted
if (contract.extraction_status === 'completed' && contract.extracted_data) {
// Return cached result
const customers = await fetchUserCustomers(supabase, user.id)
const extraction = contract.extracted_data as ContractExtractionResult
const matches = matchParties(
extraction.parties.brand,
extraction.parties.agency,
customers
)
return NextResponse.json({
data: {
extraction,
customerMatches: matches,
cached: true,
},
})
}
// Update status to processing
await supabase
.from('contracts')
.update({ extraction_status: 'processing' })
.eq('id', id)
try {
console.log('[Extract] Starting extraction for contract:', id)
console.log('[Extract] File path:', contract.file_path)
// Download file from storage
const { data: fileData, error: downloadError } = await supabase.storage
.from('contracts')
.download(contract.file_path)
if (downloadError) {
console.error('[Extract] Download error:', downloadError)
throw new Error(`Failed to download contract: ${downloadError.message}`)
}
console.log('[Extract] File downloaded, size:', fileData.size)
// Check file type
if (contract.mime_type !== 'application/pdf') {
throw new Error('Only PDF files are supported for extraction')
}
// Convert PDF to base64 for Claude
console.log('[Extract] Converting PDF to base64...')
const buffer = Buffer.from(await fileData.arrayBuffer())
const pdfInfo = getPDFInfo(buffer)
if (!pdfInfo.isValidSize) {
throw new Error(`PDF is too large (${pdfInfo.sizeMB.toFixed(1)}MB). Maximum size is 32MB.`)
}
const pdfBase64 = pdfToBase64(buffer)
console.log('[Extract] PDF converted, size:', pdfInfo.sizeMB.toFixed(2), 'MB')
// Analyze with Claude AI (sending PDF directly)
console.log('[Extract] Analyzing with Claude AI...')
const extraction = await analyzeContract(pdfBase64)
console.log('[Extract] Analysis complete')
// Get customers for matching
const customers = await fetchUserCustomers(supabase, user.id)
const matches = matchParties(
extraction.parties.brand,
extraction.parties.agency,
customers
)
// Save extraction result
await supabase
.from('contracts')
.update({
extracted_data: extraction as unknown as Record<string, unknown>,
extraction_status: 'completed',
})
.eq('id', id)
return NextResponse.json({
data: {
extraction,
customerMatches: matches,
cached: false,
},
})
} catch (error) {
console.error('[Extract] Error:', error)
// Update status to failed
await supabase
.from('contracts')
.update({
extraction_status: 'failed',
extracted_data: {
error: error instanceof Error ? error.message : 'Unknown error',
},
})
.eq('id', id)
const message = error instanceof Error ? error.message : 'Extraction failed'
console.error('[Extract] Returning error:', message)
return NextResponse.json({ error: message }, { status: 500 })
}
}
/**
* GET /api/contracts/[id]/extract
* Get extraction status and result
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data: contract, error } = await supabase
.from('contracts')
.select('extraction_status, extracted_data')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
// If completed, also return customer matches
if (contract.extraction_status === 'completed' && contract.extracted_data) {
const customers = await fetchUserCustomers(supabase, user.id)
const extraction = contract.extracted_data as ContractExtractionResult
const matches = matchParties(
extraction.parties.brand,
extraction.parties.agency,
customers
)
return NextResponse.json({
data: {
status: contract.extraction_status,
extraction: contract.extracted_data,
customerMatches: matches,
},
})
}
return NextResponse.json({
data: {
status: contract.extraction_status,
extraction: contract.extracted_data,
},
})
}
// Helper to fetch user's customers
async function fetchUserCustomers(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string
): Promise<Customer[]> {
const { data } = await supabase
.from('customers')
.select('*')
.eq('user_id', userId)
.order('name')
return data || []
}
+163
View File
@@ -0,0 +1,163 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/contracts/[id]
* Get a single contract
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('contracts')
.select('*, campaign:campaigns(id, name)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* PATCH /api/contracts/[id]
* Update contract metadata
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: {
signing_date?: string
is_primary?: boolean
notes?: string
} = await request.json()
// Verify contract exists and belongs to user
const { data: existing, error: fetchError } = await supabase
.from('contracts')
.select('*, campaign_id')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// If setting as primary, unset other primary contracts
if (body.is_primary === true) {
await supabase
.from('contracts')
.update({ is_primary: false })
.eq('campaign_id', existing.campaign_id)
.eq('is_primary', true)
.neq('id', id)
}
// Build update object
const updateData: Record<string, unknown> = {}
if (body.signing_date !== undefined) updateData.signing_date = body.signing_date
if (body.is_primary !== undefined) updateData.is_primary = body.is_primary
if (body.notes !== undefined) updateData.notes = body.notes
// Update the contract
const { data, error } = await supabase
.from('contracts')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* DELETE /api/contracts/[id]
* Delete a contract and its file
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get contract to find file path
const { data: contract, error: fetchError } = await supabase
.from('contracts')
.select('file_path')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Delete file from storage
if (contract.file_path) {
await supabase.storage.from('contracts').remove([contract.file_path])
}
// Delete contract record
const { error } = await supabase
.from('contracts')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
+133
View File
@@ -0,0 +1,133 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateCustomerInput } from '@/types'
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('customers')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Fetch related campaigns
const { data: campaigns } = await supabase
.from('campaigns')
.select('id, name, status, total_value, currency, publication_date, brand_name')
.eq('customer_id', id)
.eq('user_id', user.id)
.order('created_at', { ascending: false })
// Fetch related invoices
const { data: invoices } = await supabase
.from('invoices')
.select('id, invoice_number, invoice_date, due_date, status, total, currency, payment_status')
.eq('customer_id', id)
.eq('user_id', user.id)
.order('invoice_date', { ascending: false })
return NextResponse.json({
data: {
...data,
campaigns: campaigns || [],
invoices: invoices || [],
},
})
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: Partial<CreateCustomerInput> = await request.json()
const updateData: Record<string, unknown> = {}
if (body.name !== undefined) updateData.name = body.name
if (body.customer_type !== undefined) updateData.customer_type = body.customer_type
if (body.email !== undefined) updateData.email = body.email
if (body.phone !== undefined) updateData.phone = body.phone
if (body.address_line1 !== undefined) updateData.address_line1 = body.address_line1
if (body.address_line2 !== undefined) updateData.address_line2 = body.address_line2
if (body.postal_code !== undefined) updateData.postal_code = body.postal_code
if (body.city !== undefined) updateData.city = body.city
if (body.country !== undefined) updateData.country = body.country
if (body.org_number !== undefined) updateData.org_number = body.org_number
if (body.vat_number !== undefined) updateData.vat_number = body.vat_number
if (body.default_payment_terms !== undefined) updateData.default_payment_terms = body.default_payment_terms
if (body.notes !== undefined) updateData.notes = body.notes
const { data, error } = await supabase
.from('customers')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { error } = await supabase
.from('customers')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
+64
View File
@@ -0,0 +1,64 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateCustomerInput } from '@/types'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('customers')
.select('*')
.eq('user_id', user.id)
.order('name', { ascending: true })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: CreateCustomerInput = await request.json()
const { data, error } = await supabase
.from('customers')
.insert({
user_id: user.id,
name: body.name,
customer_type: body.customer_type,
email: body.email,
phone: body.phone,
address_line1: body.address_line1,
address_line2: body.address_line2,
postal_code: body.postal_code,
city: body.city,
country: body.country || 'Sweden',
org_number: body.org_number,
vat_number: body.vat_number,
default_payment_terms: body.default_payment_terms || 30,
notes: body.notes,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
+56
View File
@@ -0,0 +1,56 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* POST /api/deadlines/[id]/complete
* Toggle completion status of a deadline
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// First, get current deadline state
const { data: existing, error: fetchError } = await supabase
.from('deadlines')
.select('is_completed')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Toggle completion
const newCompletedState = !existing.is_completed
const { data, error } = await supabase
.from('deadlines')
.update({
is_completed: newCompletedState,
completed_at: newCompletedState ? new Date().toISOString() : null,
})
.eq('id', id)
.eq('user_id', user.id)
.select('*, customer:customers(id, name)')
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
+133
View File
@@ -0,0 +1,133 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateDeadlineInput } from '@/types'
/**
* GET /api/deadlines/[id]
* Get a single deadline by ID
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('deadlines')
.select('*, customer:customers(id, name)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* PUT /api/deadlines/[id]
* Update a deadline
*/
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: Partial<CreateDeadlineInput> = await request.json()
// First, get existing deadline to verify ownership
const { data: existing, error: fetchError } = await supabase
.from('deadlines')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Build update object
const updateData: Record<string, unknown> = {}
if (body.title !== undefined) updateData.title = body.title
if (body.due_date !== undefined) updateData.due_date = body.due_date
if (body.due_time !== undefined) updateData.due_time = body.due_time
if (body.deadline_type !== undefined) updateData.deadline_type = body.deadline_type
if (body.priority !== undefined) updateData.priority = body.priority
if (body.customer_id !== undefined) updateData.customer_id = body.customer_id || null
if (body.notes !== undefined) updateData.notes = body.notes
// Update the deadline
const { data, error } = await supabase
.from('deadlines')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.select('*, customer:customers(id, name)')
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* DELETE /api/deadlines/[id]
* Delete a deadline
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { error } = await supabase
.from('deadlines')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
+105
View File
@@ -0,0 +1,105 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { updateDeadlineStatus, isValidTransition } from '@/lib/deadlines/status-engine'
import type { DeadlineStatus } from '@/types'
/**
* PATCH /api/deadlines/[id]/status
* Manually update a deadline's status
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const body = await request.json()
const newStatus = body.status as DeadlineStatus
if (!newStatus) {
return NextResponse.json({ error: 'Status is required' }, { status: 400 })
}
const validStatuses: DeadlineStatus[] = [
'upcoming',
'action_needed',
'in_progress',
'submitted',
'confirmed',
'overdue',
]
if (!validStatuses.includes(newStatus)) {
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
}
const result = await updateDeadlineStatus(supabase, id, user.id, newStatus)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
return NextResponse.json({ success: true })
}
/**
* GET /api/deadlines/[id]/status
* Get current status and valid transitions
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const { data: deadline, error } = await supabase
.from('deadlines')
.select('status, is_completed, due_date')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error || !deadline) {
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
}
// Calculate valid transitions from current status
const validTransitions: DeadlineStatus[] = []
const allStatuses: DeadlineStatus[] = [
'upcoming',
'action_needed',
'in_progress',
'submitted',
'confirmed',
'overdue',
]
for (const status of allStatuses) {
if (isValidTransition(deadline.status, status)) {
validTransitions.push(status)
}
}
return NextResponse.json({
currentStatus: deadline.status,
isCompleted: deadline.is_completed,
dueDate: deadline.due_date,
validTransitions,
})
}
+109
View File
@@ -0,0 +1,109 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateDeadlineInput } from '@/types'
/**
* GET /api/deadlines
* List deadlines for the authenticated user
* Query params:
* - status: 'all' | 'pending' | 'completed' (default: 'all')
* - type: DeadlineType (optional)
* - from: ISO date string (optional)
* - to: ISO date string (optional)
*/
export async function GET(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Parse query params
const { searchParams } = new URL(request.url)
const status = searchParams.get('status') || 'all'
const type = searchParams.get('type')
const from = searchParams.get('from')
const to = searchParams.get('to')
// Build query
let query = supabase
.from('deadlines')
.select('*, customer:customers(id, name)')
.eq('user_id', user.id)
// Apply filters
if (status === 'pending') {
query = query.eq('is_completed', false)
} else if (status === 'completed') {
query = query.eq('is_completed', true)
}
if (type) {
query = query.eq('deadline_type', type)
}
if (from) {
query = query.gte('due_date', from)
}
if (to) {
query = query.lte('due_date', to)
}
const { data, error } = await query.order('due_date', { ascending: true })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* POST /api/deadlines
* Create a new deadline
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: CreateDeadlineInput = await request.json()
// Validate required fields
if (!body.title || !body.due_date || !body.deadline_type) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
// Insert the deadline
const { data, error } = await supabase
.from('deadlines')
.insert({
user_id: user.id,
title: body.title,
due_date: body.due_date,
due_time: body.due_time || null,
deadline_type: body.deadline_type,
priority: body.priority || 'normal',
customer_id: body.customer_id || null,
notes: body.notes || null,
})
.select('*, customer:customers(id, name)')
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
+55
View File
@@ -0,0 +1,55 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { updateDeadlineStatuses } from '@/lib/deadlines/status-engine'
/**
* GET /api/deadlines/status/cron
* Daily cron job to update deadline statuses
* Runs at 06:00 every day
*
* Vercel Cron: "0 6 * * *"
*/
export async function GET(request: Request) {
// Verify cron secret for security
const authHeader = request.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Create a service role client for accessing all user data
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return NextResponse.json(
{ error: 'Missing Supabase configuration' },
{ status: 500 }
)
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
try {
const result = await updateDeadlineStatuses(supabase)
console.log(
`Deadline status cron completed: ${result.updated} updated, ` +
`${result.newlyOverdue} newly overdue, ${result.newlyActionNeeded} newly action_needed`
)
return NextResponse.json({
success: true,
updated: result.updated,
newlyOverdue: result.newlyOverdue,
newlyActionNeeded: result.newlyActionNeeded,
})
} catch (error) {
console.error('Error in deadline status cron:', error)
return NextResponse.json(
{ error: 'Failed to update deadline statuses' },
{ status: 500 }
)
}
}
+153
View File
@@ -0,0 +1,153 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateDeliverableInput } from '@/types'
/**
* GET /api/deliverables/[id]
* Get a single deliverable
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('deliverables')
.select('*, campaign:campaigns(id, name, customer_id)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Deliverable not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* PATCH /api/deliverables/[id]
* Update a deliverable
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: Partial<Omit<CreateDeliverableInput, 'campaign_id'>> = await request.json()
// Verify deliverable exists and belongs to user
const { data: existing, error: fetchError } = await supabase
.from('deliverables')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Deliverable not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Build update object
const updateData: Record<string, unknown> = {}
if (body.title !== undefined) updateData.title = body.title
if (body.deliverable_type !== undefined) updateData.deliverable_type = body.deliverable_type
if (body.platform !== undefined) updateData.platform = body.platform
if (body.account_handle !== undefined) updateData.account_handle = body.account_handle
if (body.quantity !== undefined) updateData.quantity = body.quantity
if (body.description !== undefined) updateData.description = body.description
if (body.specifications !== undefined) updateData.specifications = body.specifications
if (body.due_date !== undefined) updateData.due_date = body.due_date
if (body.notes !== undefined) updateData.notes = body.notes
// Update the deliverable
const { data, error } = await supabase
.from('deliverables')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Update auto-generated deadline if due_date changed
if (body.due_date !== undefined) {
await supabase
.from('deadlines')
.update({ due_date: body.due_date })
.eq('deliverable_id', id)
.eq('is_auto_generated', true)
}
return NextResponse.json({ data })
}
/**
* DELETE /api/deliverables/[id]
* Delete a deliverable
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Delete auto-generated deadlines first
await supabase
.from('deadlines')
.delete()
.eq('deliverable_id', id)
.eq('is_auto_generated', true)
// Delete the deliverable
const { error } = await supabase
.from('deliverables')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
+126
View File
@@ -0,0 +1,126 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { DeliverableStatus } from '@/types'
const VALID_STATUSES: DeliverableStatus[] = [
'pending',
'in_progress',
'submitted',
'revision',
'approved',
'published'
]
/**
* PATCH /api/deliverables/[id]/status
* Update deliverable status with automatic timestamp tracking
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: { status: DeliverableStatus } = await request.json()
// Validate status
if (!body.status || !VALID_STATUSES.includes(body.status)) {
return NextResponse.json({
error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}`
}, { status: 400 })
}
// Verify deliverable exists and belongs to user
const { data: existing, error: fetchError } = await supabase
.from('deliverables')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Deliverable not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Build update object with automatic timestamps
const now = new Date().toISOString()
const updateData: Record<string, unknown> = {
status: body.status
}
// Set appropriate timestamp based on status
if (body.status === 'submitted' && !existing.submitted_at) {
updateData.submitted_at = now
} else if (body.status === 'approved' && !existing.approved_at) {
updateData.approved_at = now
} else if (body.status === 'published' && !existing.published_at) {
updateData.published_at = now
}
// Update the deliverable
const { data, error } = await supabase
.from('deliverables')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// If status is now 'approved' or 'published', mark related deadline as completed
if (body.status === 'approved' || body.status === 'published') {
await supabase
.from('deadlines')
.update({
is_completed: true,
completed_at: now
})
.eq('deliverable_id', id)
.eq('is_completed', false)
}
// Check if all deliverables are completed - if so, maybe update campaign status
if (body.status === 'published' || body.status === 'approved') {
const { data: campaign } = await supabase
.from('campaigns')
.select('id, status')
.eq('id', existing.campaign_id)
.single()
if (campaign && campaign.status === 'active') {
// Check if all deliverables are done
const { data: allDeliverables } = await supabase
.from('deliverables')
.select('status')
.eq('campaign_id', existing.campaign_id)
const allDone = allDeliverables?.every(d =>
d.status === 'approved' || d.status === 'published'
)
if (allDone) {
await supabase
.from('campaigns')
.update({ status: 'delivered' })
.eq('id', existing.campaign_id)
}
}
}
return NextResponse.json({ data })
}
+145
View File
@@ -0,0 +1,145 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateExclusivityInput } from '@/types'
/**
* GET /api/exclusivities/[id]
* Get a single exclusivity
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('exclusivities')
.select('*, campaign:campaigns(id, name, customer_id)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Exclusivity not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* PATCH /api/exclusivities/[id]
* Update an exclusivity
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: Partial<Omit<CreateExclusivityInput, 'campaign_id'>> = await request.json()
// Verify exclusivity exists and belongs to user
const { data: existing, error: fetchError } = await supabase
.from('exclusivities')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Exclusivity not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Build update object
const updateData: Record<string, unknown> = {}
if (body.categories !== undefined) updateData.categories = body.categories
if (body.excluded_brands !== undefined) updateData.excluded_brands = body.excluded_brands
if (body.start_date !== undefined) updateData.start_date = body.start_date
if (body.end_date !== undefined) updateData.end_date = body.end_date
if (body.start_calculation_type !== undefined) updateData.start_calculation_type = body.start_calculation_type
if (body.end_calculation_type !== undefined) updateData.end_calculation_type = body.end_calculation_type
if (body.start_reference !== undefined) updateData.start_reference = body.start_reference
if (body.end_reference !== undefined) updateData.end_reference = body.end_reference
if (body.start_offset_days !== undefined) updateData.start_offset_days = body.start_offset_days
if (body.end_offset_days !== undefined) updateData.end_offset_days = body.end_offset_days
if (body.notes !== undefined) updateData.notes = body.notes
// Validate date range if dates are being updated
const startDate = body.start_date ?? existing.start_date
const endDate = body.end_date ?? existing.end_date
if (new Date(endDate) < new Date(startDate)) {
return NextResponse.json({ error: 'End date must be after start date' }, { status: 400 })
}
// Update the exclusivity
const { data, error } = await supabase
.from('exclusivities')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* DELETE /api/exclusivities/[id]
* Delete an exclusivity
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { error } = await supabase
.from('exclusivities')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
+102
View File
@@ -0,0 +1,102 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/exclusivities/conflicts
* Check for exclusivity conflicts
* Query params:
* - categories: comma-separated list of categories to check
* - start_date: ISO date string
* - end_date: ISO date string
* - exclude_campaign_id: campaign to exclude from check (optional)
*/
export async function GET(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const categoriesParam = searchParams.get('categories')
const startDate = searchParams.get('start_date')
const endDate = searchParams.get('end_date')
const excludeCampaignId = searchParams.get('exclude_campaign_id')
if (!categoriesParam || !startDate || !endDate) {
return NextResponse.json({
error: 'Missing required parameters: categories, start_date, end_date'
}, { status: 400 })
}
const categories = categoriesParam.split(',').map(c => c.trim().toLowerCase())
// Find overlapping exclusivities
let query = supabase
.from('exclusivities')
.select(`
*,
campaign:campaigns(id, name, customer_id, customer:customers(id, name))
`)
.eq('user_id', user.id)
.lte('start_date', endDate)
.gte('end_date', startDate)
if (excludeCampaignId) {
query = query.neq('campaign_id', excludeCampaignId)
}
const { data: existingExclusivities, error } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Find conflicts
const conflicts = []
if (existingExclusivities) {
for (const existing of existingExclusivities) {
const overlappingCategories = categories.filter(cat =>
existing.categories.some((existingCat: string) =>
existingCat.toLowerCase() === cat
)
)
if (overlappingCategories.length > 0) {
// Calculate exact overlap period
const overlapStart = startDate > existing.start_date ? startDate : existing.start_date
const overlapEnd = endDate < existing.end_date ? endDate : existing.end_date
conflicts.push({
exclusivity_id: existing.id,
campaign_id: existing.campaign?.id,
campaign_name: existing.campaign?.name,
customer_name: existing.campaign?.customer?.name,
categories: existing.categories,
overlapping_categories: overlappingCategories,
exclusivity_start: existing.start_date,
exclusivity_end: existing.end_date,
overlap_start: overlapStart,
overlap_end: overlapEnd,
overlap_days: Math.ceil(
(new Date(overlapEnd).getTime() - new Date(overlapStart).getTime()) / (1000 * 60 * 60 * 24)
) + 1
})
}
}
}
return NextResponse.json({
has_conflicts: conflicts.length > 0,
conflicts,
checked: {
categories,
start_date: startDate,
end_date: endDate
}
})
}
+168
View File
@@ -0,0 +1,168 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { classifyGift } from '@/lib/benefits/gift-classifier'
import { createGiftJournalEntry } from '@/lib/benefits/gift-booking'
import type { CreateGiftInput, GiftInput, Gift } from '@/types'
/**
* GET /api/gifts/[id]
* Get a single gift by ID
*/
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('gifts')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Gift not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* PUT /api/gifts/[id]
* Update a gift with re-classification
*/
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: Partial<CreateGiftInput> = await request.json()
// First, get existing gift to merge with updates
const { data: existing, error: fetchError } = await supabase
.from('gifts')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Gift not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Merge existing with updates
const merged = {
date: body.date ?? existing.date,
brand_name: body.brand_name ?? existing.brand_name,
description: body.description ?? existing.description,
estimated_value: body.estimated_value ?? existing.estimated_value,
has_motprestation: body.has_motprestation ?? existing.has_motprestation,
used_in_business: body.used_in_business ?? existing.used_in_business,
used_privately: body.used_privately ?? existing.used_privately,
is_simple_promo: body.is_simple_promo ?? existing.is_simple_promo,
}
// Re-classify with updated values
const classificationInput: GiftInput = {
estimatedValue: merged.estimated_value,
hasMotprestation: merged.has_motprestation,
usedInBusiness: merged.used_in_business,
usedPrivately: merged.used_privately,
isSimplePromoItem: merged.is_simple_promo,
}
const classification = classifyGift(classificationInput)
// Update the gift
const { data, error } = await supabase
.from('gifts')
.update({
...merged,
classification,
})
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Handle journal entry update
// If classification changed to/from taxable, we may need to create/update entry
const wasBookable = existing.classification?.taxable && existing.journal_entry_id
const isBookable = classification.taxable
if (isBookable && !existing.journal_entry_id) {
// Need to create a new journal entry
try {
const journalEntry = await createGiftJournalEntry(user.id, data as Gift)
if (journalEntry) {
await supabase
.from('gifts')
.update({ journal_entry_id: journalEntry.id })
.eq('id', id)
data.journal_entry_id = journalEntry.id
}
} catch (bookingError) {
console.error('Failed to create gift journal entry:', bookingError)
return NextResponse.json({
data,
warning: 'Gåvan uppdaterades men bokföring kunde inte skapas.',
})
}
} else if (isBookable && existing.journal_entry_id) {
// Classification changed but still taxable - add warning that old entry may need reversal
return NextResponse.json({
data,
warning: 'Klassificeringen ändrades. Den befintliga verifikationen kan behöva makuleras manuellt.',
})
}
return NextResponse.json({ data })
}
/**
* DELETE /api/gifts/[id]
* Delete a gift
*/
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { error } = await supabase.from('gifts').delete().eq('id', id).eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
+51
View File
@@ -0,0 +1,51 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { estimateProductValue } from '@/lib/receipts/receipt-analyzer'
/**
* POST /api/gifts/estimate
* Lightweight endpoint: accepts an image, returns AI price estimate.
* Does NOT upload to storage, classify, or write to DB.
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const formData = await request.formData()
const imageFile = formData.get('image') as File | null
if (!imageFile) {
return NextResponse.json({ error: 'Image is required' }, { status: 400 })
}
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
if (!validTypes.includes(imageFile.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Supported: JPEG, PNG, WebP, GIF' },
{ status: 400 }
)
}
const arrayBuffer = await imageFile.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
const mimeType = imageFile.type as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
const estimation = await estimateProductValue(base64, mimeType)
return NextResponse.json({ data: estimation })
} catch (error) {
console.error('Gift estimation error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Estimation failed' },
{ status: 500 }
)
}
}
+170
View File
@@ -0,0 +1,170 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { classifyGift, classifyGiftForEntity } from '@/lib/benefits/gift-classifier'
import { createGiftJournalEntry } from '@/lib/benefits/gift-booking'
import { calculateGiftVirtualTaxDebt } from '@/lib/tax/light-calculator'
import type { CreateGiftInput, GiftInput, Gift, EntityType } from '@/types'
/**
* GET /api/gifts
* List gifts for the authenticated user
* Query params: year (optional, defaults to current year)
*/
export async function GET(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Parse query params
const { searchParams } = new URL(request.url)
const year = searchParams.get('year') || new Date().getFullYear().toString()
// Build date range for the year
const startDate = `${year}-01-01`
const endDate = `${year}-12-31`
const { data, error } = await supabase
.from('gifts')
.select('*')
.eq('user_id', user.id)
.gte('date', startDate)
.lte('date', endDate)
.order('date', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* POST /api/gifts
* Create a new gift with auto-classification
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: CreateGiftInput = await request.json()
// Validate required fields
if (!body.date || !body.brand_name || !body.description || body.estimated_value === undefined) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
// Fetch entity type and tax settings
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type, municipal_tax_rate, church_tax, church_tax_rate')
.eq('user_id', user.id)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Build classification input
const classificationInput: GiftInput = {
estimatedValue: body.estimated_value,
hasMotprestation: body.has_motprestation,
usedInBusiness: body.used_in_business,
usedPrivately: body.used_privately,
isSimplePromoItem: body.is_simple_promo || false,
}
// Classify the gift using entity-type-aware classifier
const classification = classifyGiftForEntity(classificationInput, entityType)
// Insert the gift with classification
const { data, error } = await supabase
.from('gifts')
.insert({
user_id: user.id,
date: body.date,
brand_name: body.brand_name,
description: body.description,
estimated_value: body.estimated_value,
has_motprestation: body.has_motprestation,
used_in_business: body.used_in_business,
used_privately: body.used_privately,
is_simple_promo: body.is_simple_promo || false,
classification,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
if (entityType === 'light') {
// Light mode: create shadow_ledger_entry instead of journal entry
if (classification.taxable && data) {
try {
const municipalRate = Number(settings?.municipal_tax_rate) || 0.3238
const churchRate = settings?.church_tax ? (Number(settings?.church_tax_rate) || 0.01) : 0
const virtualTaxDebt = calculateGiftVirtualTaxDebt(
body.estimated_value,
municipalRate,
churchRate
)
await supabase
.from('shadow_ledger_entries')
.insert({
user_id: user.id,
date: body.date,
type: 'gift',
source: 'manual',
gross_amount: body.estimated_value,
net_amount: body.estimated_value,
description: `Gåva: ${body.description} (${body.brand_name})`,
gift_id: data.id,
virtual_tax_debt: virtualTaxDebt,
})
} catch (err) {
console.error('Failed to create shadow ledger entry for gift:', err)
}
}
} else {
// EF/AB mode: create journal entry for taxable gifts
let journalEntryId: string | null = null
if (classification.taxable && data) {
try {
const journalEntry = await createGiftJournalEntry(user.id, data as Gift)
if (journalEntry) {
journalEntryId = journalEntry.id
// Update gift with journal entry reference
await supabase
.from('gifts')
.update({ journal_entry_id: journalEntryId })
.eq('id', data.id)
// Update the returned data
data.journal_entry_id = journalEntryId
}
} catch (bookingError) {
console.error('Failed to create gift journal entry:', bookingError)
return NextResponse.json({
data,
warning: 'Gåvan sparades men bokföring kunde inte skapas. Kontrollera att räkenskapsår finns.',
})
}
}
}
return NextResponse.json({ data })
}
+81
View File
@@ -0,0 +1,81 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { Gift, GiftSummary } from '@/types'
/**
* GET /api/gifts/summary
* Get gift summary for a year (used in dashboard and reports)
* Query params: year (optional, defaults to current year)
*/
export async function GET(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Parse query params
const { searchParams } = new URL(request.url)
const yearParam = searchParams.get('year')
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear()
// Build date range for the year
const startDate = `${year}-01-01`
const endDate = `${year}-12-31`
const { data: gifts, error } = await supabase
.from('gifts')
.select('estimated_value, classification')
.eq('user_id', user.id)
.gte('date', startDate)
.lte('date', endDate)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Calculate summary
const summary: GiftSummary = {
year,
total_count: gifts.length,
total_value: 0,
taxable_count: 0,
taxable_value: 0,
tax_free_count: 0,
tax_free_value: 0,
deductible_count: 0,
deductible_value: 0,
}
for (const gift of gifts as Pick<Gift, 'estimated_value' | 'classification'>[]) {
const value = Number(gift.estimated_value)
const classification = gift.classification
summary.total_value += value
if (classification?.taxable) {
summary.taxable_count++
summary.taxable_value += value
} else {
summary.tax_free_count++
summary.tax_free_value += value
}
if (classification?.deductibleAsExpense) {
summary.deductible_count++
summary.deductible_value += value
}
}
// Round values to 2 decimal places
summary.total_value = Math.round(summary.total_value * 100) / 100
summary.taxable_value = Math.round(summary.taxable_value * 100) / 100
summary.tax_free_value = Math.round(summary.tax_free_value * 100) / 100
summary.deductible_value = Math.round(summary.deductible_value * 100) / 100
return NextResponse.json({ data: summary })
}
+71
View File
@@ -0,0 +1,71 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/import/sie/[id]
* Get details of a specific SIE import
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('sie_imports')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
if (!data) {
return NextResponse.json({ error: 'Import not found' }, { status: 404 })
}
return NextResponse.json({ data })
}
/**
* DELETE /api/import/sie/[id]
* Delete an import record (does not delete created journal entries)
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { error } = await supabase
.from('sie_imports')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
+150
View File
@@ -0,0 +1,150 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { SIEAccount } from '@/lib/import/types'
/**
* Determine account type based on account class (first digit)
*/
function getAccountType(accountNumber: string): 'asset' | 'equity' | 'liability' | 'revenue' | 'expense' {
const firstDigit = parseInt(accountNumber.charAt(0), 10)
switch (firstDigit) {
case 1:
return 'asset'
case 2:
// 20xx-20xx is equity, 21xx-29xx is liability
const group = parseInt(accountNumber.substring(0, 2), 10)
return group <= 20 ? 'equity' : 'liability'
case 3:
return 'revenue'
case 4:
case 5:
case 6:
case 7:
return 'expense'
case 8:
// 8xxx can be either revenue (83xx interest income) or expense
const subGroup = parseInt(accountNumber.substring(0, 2), 10)
return subGroup >= 83 && subGroup <= 84 ? 'revenue' : 'expense'
default:
return 'expense'
}
}
/**
* Determine normal balance based on account type
*/
function getNormalBalance(accountType: string): 'debit' | 'credit' {
switch (accountType) {
case 'asset':
case 'expense':
return 'debit'
case 'equity':
case 'liability':
case 'revenue':
return 'credit'
default:
return 'debit'
}
}
/**
* POST /api/import/sie/create-accounts
* Create missing accounts from SIE file definitions
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const accounts: SIEAccount[] = body.accounts
if (!accounts || !Array.isArray(accounts) || accounts.length === 0) {
return NextResponse.json({ error: 'No accounts provided' }, { status: 400 })
}
// Fetch existing accounts to avoid duplicates
const { data: existingAccounts } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('user_id', user.id)
const existingNumbers = new Set(existingAccounts?.map(a => a.account_number) || [])
// Filter to only accounts that don't exist
const newAccounts = accounts.filter(a => !existingNumbers.has(a.number))
if (newAccounts.length === 0) {
return NextResponse.json({
success: true,
created: 0,
message: 'All accounts already exist'
})
}
// Prepare accounts for insertion
const accountsToInsert = newAccounts.map(account => {
const accountClass = parseInt(account.number.charAt(0), 10) || 1
const accountGroup = account.number.substring(0, 2)
const accountType = getAccountType(account.number)
const normalBalance = getNormalBalance(accountType)
return {
user_id: user.id,
account_number: account.number,
account_name: account.name,
account_class: accountClass,
account_group: accountGroup,
account_type: accountType,
normal_balance: normalBalance,
plan_type: 'full_bas',
is_active: true,
is_system_account: false, // User-created via import
sort_order: parseInt(account.number, 10) || 0,
}
})
// Insert in batches of 100 to avoid timeout
const batchSize = 100
let totalCreated = 0
for (let i = 0; i < accountsToInsert.length; i += batchSize) {
const batch = accountsToInsert.slice(i, i + batchSize)
const { error } = await supabase
.from('chart_of_accounts')
.insert(batch)
if (error) {
console.error('Error inserting accounts batch:', error)
return NextResponse.json({
error: `Failed to create accounts: ${error.message}`,
created: totalCreated,
}, { status: 500 })
}
totalCreated += batch.length
}
return NextResponse.json({
success: true,
created: totalCreated,
message: `Created ${totalCreated} new accounts`,
})
} catch (error) {
console.error('Create accounts error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to create accounts' },
{ status: 500 }
)
}
}
+130
View File
@@ -0,0 +1,130 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
import { suggestMappings } from '@/lib/import/account-mapper'
import { executeSIEImport } from '@/lib/import/sie-import'
import type { AccountMapping, SIEAccountMappingRecord } from '@/lib/import/types'
/**
* POST /api/import/sie/execute
* Execute the SIE import
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
// Get form data with file and options
const formData = await request.formData()
const file = formData.get('file') as File | null
const mappingsJson = formData.get('mappings') as string | null
const optionsJson = formData.get('options') as string | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
// Parse options
const options = optionsJson ? JSON.parse(optionsJson) : {
createFiscalPeriod: true,
importOpeningBalances: true,
importTransactions: true,
voucherSeries: 'B',
}
// Read and decode file
const arrayBuffer = await file.arrayBuffer()
const encoding = detectEncoding(arrayBuffer)
const content = decodeBuffer(arrayBuffer, encoding)
// Parse the SIE file
const parsed = parseSIEFile(content)
// Get mappings - either from request or generate new ones
let mappings: AccountMapping[]
if (mappingsJson) {
mappings = JSON.parse(mappingsJson)
} else {
// Fetch user's chart of accounts and generate mappings
const { data: basAccounts } = await supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.eq('is_active', true)
.order('account_number')
if (!basAccounts || basAccounts.length === 0) {
return NextResponse.json({
error: 'No chart of accounts found. Please complete onboarding first.',
}, { status: 400 })
}
// Load stored mappings
const { data: storedMappings } = await supabase
.from('sie_account_mappings')
.select('*')
.eq('user_id', user.id)
mappings = suggestMappings(
parsed.accounts,
basAccounts,
(storedMappings as SIEAccountMappingRecord[]) || undefined
)
}
// Validate all accounts are mapped
const unmapped = mappings.filter((m) => !m.targetAccount)
if (unmapped.length > 0) {
return NextResponse.json({
error: 'validation',
message: `${unmapped.length} account(s) are not mapped`,
unmappedAccounts: unmapped.map((m) => ({
account: m.sourceAccount,
name: m.sourceName,
})),
}, { status: 400 })
}
// Execute the import
const result = await executeSIEImport(
user.id,
parsed,
mappings,
{
filename: file.name,
fileContent: content,
createFiscalPeriod: options.createFiscalPeriod,
importOpeningBalances: options.importOpeningBalances,
importTransactions: options.importTransactions,
voucherSeries: options.voucherSeries || 'B',
}
)
if (!result.success) {
return NextResponse.json({
error: 'import',
message: 'Import completed with errors',
result,
}, { status: 400 })
}
return NextResponse.json({
success: true,
result,
})
} catch (error) {
console.error('SIE import error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to import SIE file' },
{ status: 500 }
)
}
}
+155
View File
@@ -0,0 +1,155 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { saveMappings } from '@/lib/import/sie-import'
import type { AccountMapping } from '@/lib/import/types'
/**
* GET /api/import/sie/mappings
* Get all saved account mappings for the user
*/
export async function GET() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('sie_account_mappings')
.select('*')
.eq('user_id', user.id)
.order('source_account')
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* POST /api/import/sie/mappings
* Save account mappings (bulk upsert)
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const mappings: AccountMapping[] = body.mappings
if (!mappings || !Array.isArray(mappings)) {
return NextResponse.json({ error: 'Invalid mappings data' }, { status: 400 })
}
try {
await saveMappings(user.id, mappings)
return NextResponse.json({ success: true })
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to save mappings' },
{ status: 500 }
)
}
}
/**
* PUT /api/import/sie/mappings
* Update a single mapping
*/
export async function PUT(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { sourceAccount, targetAccount } = body
if (!sourceAccount || !targetAccount) {
return NextResponse.json(
{ error: 'sourceAccount and targetAccount are required' },
{ status: 400 }
)
}
const { data, error } = await supabase
.from('sie_account_mappings')
.upsert({
user_id: user.id,
source_account: sourceAccount,
target_account: targetAccount,
confidence: 1.0,
match_type: 'manual',
}, {
onConflict: 'user_id,source_account',
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* DELETE /api/import/sie/mappings
* Delete a specific mapping or all mappings
*/
export async function DELETE(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const sourceAccount = searchParams.get('sourceAccount')
if (sourceAccount) {
// Delete specific mapping
const { error } = await supabase
.from('sie_account_mappings')
.delete()
.eq('user_id', user.id)
.eq('source_account', sourceAccount)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
} else {
// Delete all mappings
const { error } = await supabase
.from('sie_account_mappings')
.delete()
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
return NextResponse.json({ success: true })
}
+139
View File
@@ -0,0 +1,139 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import {
parseSIEFile,
validateSIEFile,
detectEncoding,
decodeBuffer,
calculateFileHash,
} from '@/lib/import/sie-parser'
import { suggestMappings, getMappingStats } from '@/lib/import/account-mapper'
import { generateImportPreview, checkDuplicateImport } from '@/lib/import/sie-import'
import type { SIEAccountMappingRecord, SIEAccount } from '@/lib/import/types'
/**
* POST /api/import/sie/parse
* Parse an uploaded SIE file and return preview data
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
// Get form data with file
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
// Validate file type
const filename = file.name.toLowerCase()
if (!filename.endsWith('.sie') && !filename.endsWith('.se')) {
return NextResponse.json(
{ error: 'Invalid file type. Please upload a .sie file' },
{ status: 400 }
)
}
// Read file as ArrayBuffer for encoding detection
const arrayBuffer = await file.arrayBuffer()
const encoding = detectEncoding(arrayBuffer)
// Decode to string
const content = decodeBuffer(arrayBuffer, encoding)
// Check for duplicate import
const duplicate = await checkDuplicateImport(user.id, content)
if (duplicate) {
return NextResponse.json({
error: 'duplicate',
message: `This file has already been imported on ${new Date(duplicate.imported_at!).toLocaleDateString('sv-SE')}`,
importId: duplicate.id,
}, { status: 409 })
}
// Parse the SIE file
const parsed = parseSIEFile(content)
// Validate the parsed data
const validation = validateSIEFile(parsed)
// If there are critical errors, return them
if (!validation.valid) {
return NextResponse.json({
error: 'validation',
message: 'SIE file has validation errors',
errors: validation.errors,
warnings: validation.warnings,
}, { status: 400 })
}
// Fetch user's chart of accounts
const { data: basAccounts } = await supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.eq('is_active', true)
.order('account_number')
if (!basAccounts || basAccounts.length === 0) {
return NextResponse.json({
error: 'No chart of accounts found. Please complete onboarding first.',
}, { status: 400 })
}
// Fetch stored mappings from database
const { data: storedMappings } = await supabase
.from('sie_account_mappings')
.select('*')
.eq('user_id', user.id)
// Suggest account mappings
const mappings = suggestMappings(
parsed.accounts,
basAccounts,
(storedMappings as SIEAccountMappingRecord[]) || undefined
)
// Generate preview
const preview = generateImportPreview(parsed, mappings)
// Calculate file hash for storage
const fileHash = await calculateFileHash(content)
return NextResponse.json({
success: true,
encoding,
fileHash,
parsed: {
header: parsed.header,
accounts: parsed.accounts,
stats: parsed.stats,
issues: parsed.issues,
},
mappings,
mappingStats: getMappingStats(mappings),
preview,
validation: {
valid: validation.valid,
errors: validation.errors,
warnings: validation.warnings,
},
})
} catch (error) {
console.error('SIE parse error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to parse SIE file' },
{ status: 500 }
)
}
}
+48
View File
@@ -0,0 +1,48 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/import/sie
* List all SIE imports for the user
*/
export async function GET(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Parse query params
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get('limit') || '20', 10)
const offset = parseInt(searchParams.get('offset') || '0', 10)
const status = searchParams.get('status')
let query = supabase
.from('sie_imports')
.select('*', { count: 'exact' })
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (status) {
query = query.eq('status', status)
}
const { data, error, count } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({
data,
count,
limit,
offset,
})
}
+100
View File
@@ -0,0 +1,100 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoice/pdf-template'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch invoice with customer and items
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select(`
*,
customer:customers(*),
items:invoice_items(*)
`)
.eq('id', id)
.eq('user_id', user.id)
.single()
if (invoiceError || !invoice) {
return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
}
// Fetch company settings
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.single()
if (companyError || !company) {
return NextResponse.json({ error: 'Company settings not found' }, { status: 404 })
}
// Sort items by sort_order
const items = (invoice.items as InvoiceItem[]).sort((a, b) => a.sort_order - b.sort_order)
// If this is a credit note, fetch the original invoice number
let originalInvoiceNumber: string | undefined
if (invoice.credited_invoice_id) {
const { data: originalInvoice } = await supabase
.from('invoices')
.select('invoice_number')
.eq('id', invoice.credited_invoice_id)
.single()
if (originalInvoice) {
originalInvoiceNumber = originalInvoice.invoice_number
}
}
try {
// Generate PDF
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: invoice as Invoice,
customer: invoice.customer as Customer,
items,
company: company as CompanySettings,
originalInvoiceNumber,
})
)
// Convert Node.js Buffer to Uint8Array for Response
const uint8Array = new Uint8Array(pdfBuffer)
// Return PDF as response
const isCreditNote = !!invoice.credited_invoice_id
const filename = isCreditNote
? `kreditfaktura-${invoice.invoice_number}.pdf`
: `faktura-${invoice.invoice_number}.pdf`
return new NextResponse(uint8Array, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': pdfBuffer.length.toString(),
},
})
} catch (error) {
console.error('PDF generation error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'PDF generation failed' },
{ status: 500 }
)
}
}
+169
View File
@@ -0,0 +1,169 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoice/pdf-template'
import { sendEmail, isResendConfigured } from '@/lib/email/resend'
import {
generateInvoiceEmailHtml,
generateInvoiceEmailText,
generateInvoiceEmailSubject
} from '@/lib/email/invoice-templates'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if Resend is configured
if (!isResendConfigured()) {
return NextResponse.json(
{ error: 'E-posttjänsten är inte konfigurerad. Kontakta support.' },
{ status: 503 }
)
}
// Fetch invoice with customer and items
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select(`
*,
customer:customers(*),
items:invoice_items(*)
`)
.eq('id', id)
.eq('user_id', user.id)
.single()
if (invoiceError || !invoice) {
return NextResponse.json({ error: 'Fakturan hittades inte' }, { status: 404 })
}
// Verify customer has email
const customer = invoice.customer as Customer
if (!customer.email) {
return NextResponse.json(
{ error: 'Kunden saknar e-postadress. Uppdatera kunduppgifterna först.' },
{ status: 400 }
)
}
// Fetch company settings
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.single()
if (companyError || !company) {
return NextResponse.json(
{ error: 'Företagsinställningar saknas' },
{ status: 404 }
)
}
// Sort items by sort_order
const items = (invoice.items as InvoiceItem[]).sort(
(a, b) => a.sort_order - b.sort_order
)
// If this is a credit note, fetch the original invoice number
let originalInvoiceNumber: string | undefined
if (invoice.credited_invoice_id) {
const { data: originalInvoice } = await supabase
.from('invoices')
.select('invoice_number')
.eq('id', invoice.credited_invoice_id)
.single()
if (originalInvoice) {
originalInvoiceNumber = originalInvoice.invoice_number
}
}
try {
// Generate PDF
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: invoice as Invoice,
customer,
items,
company: company as CompanySettings,
originalInvoiceNumber,
})
)
// Prepare email data
const emailData = {
invoice: invoice as Invoice,
customer,
company: company as CompanySettings
}
// Determine filename
const isCreditNote = !!invoice.credited_invoice_id
const filename = isCreditNote
? `kreditfaktura-${invoice.invoice_number}.pdf`
: `faktura-${invoice.invoice_number}.pdf`
// Send email
const result = await sendEmail({
to: customer.email,
subject: generateInvoiceEmailSubject(emailData),
html: generateInvoiceEmailHtml(emailData),
text: generateInvoiceEmailText(emailData),
replyTo: (company as CompanySettings & { email?: string }).email || undefined,
fromName: company.company_name,
attachments: [
{
filename,
content: pdfBuffer,
contentType: 'application/pdf'
}
]
})
if (!result.success) {
console.error('Failed to send invoice email:', result.error)
return NextResponse.json(
{ error: `Kunde inte skicka e-post: ${result.error}` },
{ status: 500 }
)
}
// Update invoice status to "sent" and set sent_at timestamp
const { error: updateError } = await supabase
.from('invoices')
.update({
status: 'sent',
sent_at: new Date().toISOString()
})
.eq('id', id)
.eq('user_id', user.id)
if (updateError) {
console.error('Failed to update invoice status:', updateError)
// Don't fail the request - the email was sent successfully
}
return NextResponse.json({
success: true,
message: `Fakturan har skickats till ${customer.email}`,
messageId: result.messageId
})
} catch (error) {
console.error('Send invoice error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Kunde inte skicka fakturan' },
{ status: 500 }
)
}
}
+173
View File
@@ -0,0 +1,173 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'
// Create a service client (no auth needed - public endpoint with token validation)
function createServiceClient() {
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
cookies: {
getAll() { return [] },
setAll() { }
}
}
)
}
export async function POST(request: Request) {
try {
const body = await request.json()
const { token, action } = body
if (!token) {
return NextResponse.json({ error: 'Token saknas' }, { status: 400 })
}
if (!action || !['marked_paid', 'disputed'].includes(action)) {
return NextResponse.json({ error: 'Ogiltig åtgärd' }, { status: 400 })
}
const supabase = createServiceClient()
// Find the reminder by action token
const { data: reminder, error: findError } = await supabase
.from('invoice_reminders')
.select(`
*,
invoice:invoices(
id,
invoice_number,
status,
user_id
)
`)
.eq('action_token', token)
.single()
if (findError || !reminder) {
return NextResponse.json(
{ error: 'Ogiltig eller utgången länk' },
{ status: 404 }
)
}
// Check if token was already used
if (reminder.action_token_used) {
return NextResponse.json(
{ error: 'Denna länk har redan använts' },
{ status: 400 }
)
}
// Update the reminder with the response
const { error: updateError } = await supabase
.from('invoice_reminders')
.update({
response_type: action,
response_at: new Date().toISOString(),
action_token_used: true
})
.eq('id', reminder.id)
if (updateError) {
console.error('Failed to update reminder:', updateError)
return NextResponse.json(
{ error: 'Kunde inte spara ditt svar' },
{ status: 500 }
)
}
// If customer marked as paid, we could optionally notify the business owner
// For now, we just log it - the business owner will see it in the UI
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const invoiceData = reminder.invoice as any
const invoice = Array.isArray(invoiceData) ? invoiceData[0] : invoiceData
console.log(`Customer responded to invoice ${invoice?.invoice_number}: ${action}`)
return NextResponse.json({
success: true,
message: action === 'marked_paid'
? 'Tack! Vi har noterat att du har betalat fakturan.'
: 'Tack! Vi har noterat din invändning och kommer att kontakta dig.'
})
} catch (error) {
console.error('Action handler error:', error)
return NextResponse.json(
{ error: 'Ett fel uppstod' },
{ status: 500 }
)
}
}
// GET endpoint to fetch reminder/invoice info by token (for the public page)
export async function GET(request: Request) {
const url = new URL(request.url)
const token = url.searchParams.get('token')
if (!token) {
return NextResponse.json({ error: 'Token saknas' }, { status: 400 })
}
const supabase = createServiceClient()
// Find the reminder by action token
const { data: reminder, error: findError } = await supabase
.from('invoice_reminders')
.select(`
id,
reminder_level,
sent_at,
response_type,
action_token_used,
invoice:invoices(
id,
invoice_number,
invoice_date,
due_date,
total,
currency,
status,
customer:customers(
name
)
)
`)
.eq('action_token', token)
.single()
if (findError || !reminder) {
return NextResponse.json(
{ error: 'Ogiltig eller utgången länk' },
{ status: 404 }
)
}
// Don't expose sensitive data, just what's needed for the public page
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const invoiceData = reminder.invoice as any
const invoice = Array.isArray(invoiceData) ? invoiceData[0] : invoiceData
if (!invoice) {
return NextResponse.json(
{ error: 'Faktura hittades inte' },
{ status: 404 }
)
}
// Handle nested customer which may also be an array
const customerData = invoice.customer
const customer = Array.isArray(customerData) ? customerData[0] : customerData
return NextResponse.json({
invoiceNumber: invoice.invoice_number,
invoiceDate: invoice.invoice_date,
dueDate: invoice.due_date,
total: invoice.total,
currency: invoice.currency,
customerName: customer?.name,
reminderLevel: reminder.reminder_level,
alreadyResponded: reminder.action_token_used,
previousResponse: reminder.response_type
})
}
+73
View File
@@ -0,0 +1,73 @@
import { NextResponse } from 'next/server'
import { processOverdueReminders } from '@/lib/invoices/reminder-processor'
import { isResendConfigured } from '@/lib/email/resend'
// Verify cron secret for security
function verifyCronSecret(request: Request): boolean {
const authHeader = request.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
if (!cronSecret) {
console.error('CRON_SECRET not configured')
return false
}
if (!authHeader) {
return false
}
// Support both "Bearer <token>" and just "<token>" formats
const token = authHeader.startsWith('Bearer ')
? authHeader.substring(7)
: authHeader
return token === cronSecret
}
export async function GET(request: Request) {
// Verify cron authentication
if (!verifyCronSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if email service is configured
if (!isResendConfigured()) {
console.error('Resend not configured, skipping reminder cron')
return NextResponse.json({
success: false,
error: 'Email service not configured'
}, { status: 503 })
}
try {
console.log('Starting invoice reminder cron job...')
const result = await processOverdueReminders()
console.log(`Reminder cron completed: ${result.sent} sent, ${result.failed} failed out of ${result.processed} processed`)
return NextResponse.json({
success: true,
processed: result.processed,
sent: result.sent,
failed: result.failed,
results: result.results.map(r => ({
invoiceNumber: r.invoiceNumber,
reminderLevel: r.reminderLevel,
success: r.success,
error: r.error
}))
})
} catch (error) {
console.error('Invoice reminder cron job error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Cron job failed' },
{ status: 500 }
)
}
}
// Also support POST for manual triggering via dashboard
export async function POST(request: Request) {
return GET(request)
}
+322
View File
@@ -0,0 +1,322 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateInvoiceInput, Invoice } from '@/types'
import { getVatRules, calculateVat, calculateTotal } from '@/lib/invoice/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import {
createInvoiceJournalEntry,
createCreditNoteJournalEntry,
} from '@/lib/bookkeeping/invoice-entries'
interface CreateCreditNoteInput {
credited_invoice_id: string
reason?: string
}
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const status = searchParams.get('status')
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
let query = supabase
.from('invoices')
.select('*, customer:customers(*)', { count: 'exact' })
.eq('user_id', user.id)
.order('invoice_date', { ascending: false })
.range(offset, offset + limit - 1)
if (status) {
query = query.eq('status', status)
}
const { data, error, count } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data, count })
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
// Check if this is a credit note creation request
if (body.credited_invoice_id) {
return createCreditNote(supabase, user.id, body as CreateCreditNoteInput)
}
const invoiceInput = body as CreateInvoiceInput
// Get customer for VAT calculation
const { data: customer, error: customerError } = await supabase
.from('customers')
.select('*')
.eq('id', invoiceInput.customer_id)
.eq('user_id', user.id)
.single()
if (customerError || !customer) {
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
}
// Calculate VAT rules
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
// Calculate subtotal from items
const subtotal = invoiceInput.items.reduce((sum, item) => {
return sum + item.quantity * item.unit_price
}, 0)
const vatAmount = calculateVat(subtotal, vatRules.rate)
const total = subtotal + vatAmount
// Handle currency conversion
let exchangeRate: number | null = null
let exchangeRateDate: string | null = null
let subtotalSek: number | null = null
let vatAmountSek: number | null = null
let totalSek: number | null = null
if (invoiceInput.currency !== 'SEK') {
const rateData = await fetchExchangeRate(invoiceInput.currency)
if (rateData) {
exchangeRate = rateData.rate
exchangeRateDate = rateData.date
subtotalSek = convertToSEK(subtotal, exchangeRate)
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
totalSek = convertToSEK(total, exchangeRate)
}
}
// Generate invoice number
const { data: invoiceNumber } = await supabase.rpc('generate_invoice_number', {
p_user_id: user.id,
})
// Create invoice
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.insert({
user_id: user.id,
customer_id: invoiceInput.customer_id,
invoice_number: invoiceNumber,
invoice_date: invoiceInput.invoice_date,
due_date: invoiceInput.due_date,
currency: invoiceInput.currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
subtotal,
subtotal_sek: subtotalSek,
vat_amount: vatAmount,
vat_amount_sek: vatAmountSek,
total,
total_sek: totalSek,
vat_treatment: vatRules.treatment,
vat_rate: vatRules.rate,
moms_ruta: vatRules.momsRuta,
reverse_charge_text: vatRules.reverseChargeText || null,
your_reference: invoiceInput.your_reference,
our_reference: invoiceInput.our_reference,
notes: invoiceInput.notes,
})
.select()
.single()
if (invoiceError) {
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
}
// Create invoice items
const items = invoiceInput.items.map((item, index) => ({
invoice_id: invoice.id,
sort_order: index,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: item.quantity * item.unit_price,
}))
const { error: itemsError } = await supabase
.from('invoice_items')
.insert(items)
if (itemsError) {
// Rollback invoice creation
await supabase.from('invoices').delete().eq('id', invoice.id)
return NextResponse.json({ error: itemsError.message }, { status: 500 })
}
// Fetch complete invoice with items
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
// Create journal entry for the invoice (non-blocking)
if (completeInvoice) {
try {
const journalEntry = await createInvoiceJournalEntry(
user.id,
completeInvoice as Invoice
)
if (journalEntry) {
await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', invoice.id)
}
} catch (err) {
console.error('Failed to create invoice journal entry:', err)
// Don't fail the invoice creation
}
}
return NextResponse.json({ data: completeInvoice })
}
// Create a credit note for an existing invoice
async function createCreditNote(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
input: CreateCreditNoteInput
) {
// Fetch the original invoice with items
const { data: originalInvoice, error: originalError } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
.eq('id', input.credited_invoice_id)
.eq('user_id', userId)
.single()
if (originalError || !originalInvoice) {
return NextResponse.json({ error: 'Original invoice not found' }, { status: 404 })
}
// Check if invoice is already credited
if (originalInvoice.status === 'credited') {
return NextResponse.json({ error: 'Invoice has already been credited' }, { status: 400 })
}
// Check if invoice can be credited (only sent, paid, or overdue invoices can be credited)
if (!['sent', 'paid', 'overdue'].includes(originalInvoice.status)) {
return NextResponse.json(
{ error: 'Only sent, paid, or overdue invoices can be credited' },
{ status: 400 }
)
}
// Generate credit note number
const creditNoteNumber = `KR-${originalInvoice.invoice_number}`
// Create the credit note with negated amounts
const { data: creditNote, error: creditNoteError } = await supabase
.from('invoices')
.insert({
user_id: userId,
customer_id: originalInvoice.customer_id,
invoice_number: creditNoteNumber,
invoice_date: new Date().toISOString().split('T')[0],
due_date: new Date().toISOString().split('T')[0],
currency: originalInvoice.currency,
exchange_rate: originalInvoice.exchange_rate,
exchange_rate_date: originalInvoice.exchange_rate_date,
// Negate all amounts
subtotal: -Math.abs(originalInvoice.subtotal),
subtotal_sek: originalInvoice.subtotal_sek ? -Math.abs(originalInvoice.subtotal_sek) : null,
vat_amount: -Math.abs(originalInvoice.vat_amount),
vat_amount_sek: originalInvoice.vat_amount_sek ? -Math.abs(originalInvoice.vat_amount_sek) : null,
total: -Math.abs(originalInvoice.total),
total_sek: originalInvoice.total_sek ? -Math.abs(originalInvoice.total_sek) : null,
// Same VAT treatment as original
vat_treatment: originalInvoice.vat_treatment,
vat_rate: originalInvoice.vat_rate,
moms_ruta: originalInvoice.moms_ruta,
reverse_charge_text: originalInvoice.reverse_charge_text,
// References
your_reference: originalInvoice.your_reference,
our_reference: originalInvoice.our_reference,
notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`,
credited_invoice_id: input.credited_invoice_id,
status: 'sent', // Credit notes are immediately "sent"
})
.select()
.single()
if (creditNoteError) {
return NextResponse.json({ error: creditNoteError.message }, { status: 500 })
}
// Create credit note items (negated from original)
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number }, index: number) => ({
invoice_id: creditNote.id,
sort_order: item.sort_order,
description: item.description,
quantity: -Math.abs(item.quantity),
unit: item.unit,
unit_price: item.unit_price,
line_total: -Math.abs(item.line_total),
}))
const { error: itemsError } = await supabase
.from('invoice_items')
.insert(creditNoteItems)
if (itemsError) {
// Rollback credit note creation
await supabase.from('invoices').delete().eq('id', creditNote.id)
return NextResponse.json({ error: itemsError.message }, { status: 500 })
}
// Update original invoice status to 'credited'
await supabase
.from('invoices')
.update({ status: 'credited' })
.eq('id', input.credited_invoice_id)
// Fetch complete credit note with items
const { data: completeCreditNote } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', creditNote.id)
.single()
// Create journal entry for the credit note (non-blocking)
if (completeCreditNote) {
try {
const journalEntry = await createCreditNoteJournalEntry(
userId,
completeCreditNote as Invoice
)
if (journalEntry) {
await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', creditNote.id)
}
} catch (err) {
console.error('Failed to create credit note journal entry:', err)
}
}
return NextResponse.json({ data: completeCreditNote })
}
+81
View File
@@ -0,0 +1,81 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import {
sendTaxDeadlineNotifications,
sendInvoiceNotifications,
sendCampaignNotifications,
} from '@/lib/push/notification-scheduler'
/**
* GET /api/push/cron
* Daily cron job to send push notifications
* Runs at 09:00 every day
*
* Vercel Cron: "0 9 * * *"
*/
export async function GET(request: Request) {
// Verify cron secret for security
const authHeader = request.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Create a service role client for accessing all user data
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return NextResponse.json(
{ error: 'Missing Supabase configuration' },
{ status: 500 }
)
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
try {
// Send all notification types in parallel
const [taxResult, invoiceResult, campaignResult] = await Promise.all([
sendTaxDeadlineNotifications(supabase),
sendInvoiceNotifications(supabase),
sendCampaignNotifications(supabase),
])
const totalSent =
taxResult.sent + invoiceResult.sent + campaignResult.sent
const totalSkipped =
taxResult.skipped + invoiceResult.skipped + campaignResult.skipped
console.log(
`Push notification cron completed: ${totalSent} sent, ${totalSkipped} skipped`
)
console.log(
` Tax: ${taxResult.sent} sent, ${taxResult.skipped} skipped`
)
console.log(
` Invoice: ${invoiceResult.sent} sent, ${invoiceResult.skipped} skipped`
)
console.log(
` Campaign: ${campaignResult.sent} sent, ${campaignResult.skipped} skipped`
)
return NextResponse.json({
success: true,
totalSent,
totalSkipped,
details: {
taxDeadlines: taxResult,
invoices: invoiceResult,
campaigns: campaignResult,
},
})
} catch (error) {
console.error('Error in push notification cron:', error)
return NextResponse.json(
{ error: 'Failed to send push notifications' },
{ status: 500 }
)
}
}
+136
View File
@@ -0,0 +1,136 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getVapidPublicKey } from '@/lib/push/web-push'
/**
* GET /api/push/subscribe
* Get the VAPID public key for client-side subscription
*/
export async function GET() {
const vapidKey = getVapidPublicKey()
if (!vapidKey) {
return NextResponse.json(
{ error: 'Push notifications not configured' },
{ status: 500 }
)
}
return NextResponse.json({ vapidPublicKey: vapidKey })
}
/**
* POST /api/push/subscribe
* Save a new push subscription
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { endpoint, keys } = body
if (!endpoint || !keys?.p256dh || !keys?.auth) {
return NextResponse.json(
{ error: 'Invalid subscription data' },
{ status: 400 }
)
}
// Get user agent for debugging
const userAgent = request.headers.get('user-agent') || null
// Upsert subscription (update if endpoint exists)
const { data, error } = await supabase
.from('push_subscriptions')
.upsert(
{
user_id: user.id,
endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
user_agent: userAgent,
is_active: true,
last_used_at: new Date().toISOString(),
},
{
onConflict: 'user_id,endpoint',
}
)
.select()
.single()
if (error) {
console.error('Error saving subscription:', error)
return NextResponse.json(
{ error: 'Failed to save subscription' },
{ status: 500 }
)
}
// Also ensure notification settings exist with defaults
await supabase
.from('notification_settings')
.upsert(
{
user_id: user.id,
tax_deadlines_enabled: true,
invoice_reminders_enabled: true,
campaign_deadlines_enabled: true,
push_enabled: true,
email_enabled: true,
quiet_start: '21:00',
quiet_end: '08:00',
},
{
onConflict: 'user_id',
ignoreDuplicates: true,
}
)
return NextResponse.json({ success: true, id: data.id })
}
/**
* DELETE /api/push/subscribe
* Remove a push subscription
*/
export async function DELETE(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { endpoint } = body
if (!endpoint) {
return NextResponse.json(
{ error: 'Endpoint is required' },
{ status: 400 }
)
}
const { error } = await supabase
.from('push_subscriptions')
.delete()
.eq('user_id', user.id)
.eq('endpoint', endpoint)
if (error) {
return NextResponse.json(
{ error: 'Failed to remove subscription' },
{ status: 500 }
)
}
return NextResponse.json({ success: true })
}
+108
View File
@@ -0,0 +1,108 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { ConfirmReceiptInput } from '@/types'
/**
* POST /api/receipts/[id]/confirm
* Confirm line item classifications and optionally link to a transaction
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify receipt ownership
const { data: receipt, error: fetchError } = await supabase
.from('receipts')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError || !receipt) {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
const body: ConfirmReceiptInput = await request.json()
// Update line items with classifications
if (body.line_items && body.line_items.length > 0) {
for (const item of body.line_items) {
const { error: updateError } = await supabase
.from('receipt_line_items')
.update({
is_business: item.is_business,
category: item.category || null,
bas_account: item.bas_account || null,
})
.eq('id', item.id)
.eq('receipt_id', id)
if (updateError) {
console.error('Line item update error:', updateError)
}
}
}
// Build receipt update
const receiptUpdate: Record<string, unknown> = {
status: 'confirmed',
}
// Add restaurant representation data if provided
if (body.representation_persons !== undefined) {
receiptUpdate.representation_persons = body.representation_persons
}
if (body.representation_purpose !== undefined) {
receiptUpdate.representation_purpose = body.representation_purpose
}
// Link to transaction if provided
if (body.matched_transaction_id) {
// Verify transaction ownership
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('id')
.eq('id', body.matched_transaction_id)
.eq('user_id', user.id)
.single()
if (!txError && transaction) {
receiptUpdate.matched_transaction_id = body.matched_transaction_id
// Also update the transaction with the receipt link
await supabase
.from('transactions')
.update({ receipt_id: id })
.eq('id', body.matched_transaction_id)
}
}
// Update the receipt
const { data: updatedReceipt, error: updateError } = await supabase
.from('receipts')
.update(receiptUpdate)
.eq('id', id)
.select(`
*,
line_items:receipt_line_items(*)
`)
.single()
if (updateError) {
console.error('Receipt update error:', updateError)
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
}
return NextResponse.json({ data: updatedReceipt })
}
+218
View File
@@ -0,0 +1,218 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { findTransactionMatches } from '@/lib/receipts/receipt-matcher'
import type { Receipt, Transaction } from '@/types'
/**
* POST /api/receipts/[id]/match
* Find potential transaction matches for a receipt
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch receipt
const { data: receipt, error: receiptError } = await supabase
.from('receipts')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (receiptError || !receipt) {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
// Get date range for transaction search (±7 days from receipt date)
const receiptDate = receipt.receipt_date ? new Date(receipt.receipt_date) : new Date()
const startDate = new Date(receiptDate)
startDate.setDate(startDate.getDate() - 7)
const endDate = new Date(receiptDate)
endDate.setDate(endDate.getDate() + 7)
// Fetch unmatched transactions in date range
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('user_id', user.id)
.is('receipt_id', null)
.lt('amount', 0) // Only expenses
.gte('date', startDate.toISOString().split('T')[0])
.lte('date', endDate.toISOString().split('T')[0])
.order('date', { ascending: false })
if (txError) {
console.error('Transaction fetch error:', txError)
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
}
// Find matches
const matches = findTransactionMatches(
receipt as unknown as Receipt,
transactions as Transaction[]
)
return NextResponse.json({
data: {
receipt_id: id,
matches: matches.slice(0, 5), // Return top 5 matches
},
})
}
/**
* PATCH /api/receipts/[id]/match
* Link a receipt to a specific transaction
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { transaction_id, match_confidence } = body
if (!transaction_id) {
return NextResponse.json({ error: 'transaction_id is required' }, { status: 400 })
}
// Verify receipt ownership
const { data: receipt, error: receiptError } = await supabase
.from('receipts')
.select('id')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (receiptError || !receipt) {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
// Verify transaction ownership
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('id')
.eq('id', transaction_id)
.eq('user_id', user.id)
.single()
if (txError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
// Update receipt with match
const { error: updateReceiptError } = await supabase
.from('receipts')
.update({
matched_transaction_id: transaction_id,
match_confidence: match_confidence || null,
})
.eq('id', id)
if (updateReceiptError) {
console.error('Receipt update error:', updateReceiptError)
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
}
// Update transaction with receipt link
const { error: updateTxError } = await supabase
.from('transactions')
.update({ receipt_id: id })
.eq('id', transaction_id)
if (updateTxError) {
console.error('Transaction update error:', updateTxError)
}
return NextResponse.json({
data: {
receipt_id: id,
transaction_id,
matched: true,
},
})
}
/**
* DELETE /api/receipts/[id]/match
* Unlink a receipt from its matched transaction
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify receipt ownership and get current transaction link
const { data: receipt, error: receiptError } = await supabase
.from('receipts')
.select('id, matched_transaction_id')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (receiptError || !receipt) {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
const transactionId = receipt.matched_transaction_id
// Remove match from receipt
const { error: updateReceiptError } = await supabase
.from('receipts')
.update({
matched_transaction_id: null,
match_confidence: null,
})
.eq('id', id)
if (updateReceiptError) {
console.error('Receipt update error:', updateReceiptError)
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
}
// Remove receipt link from transaction
if (transactionId) {
await supabase
.from('transactions')
.update({ receipt_id: null })
.eq('id', transactionId)
}
return NextResponse.json({
data: {
receipt_id: id,
unmatched: true,
},
})
}
+176
View File
@@ -0,0 +1,176 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/receipts/[id]
* Get a single receipt with line items
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('receipts')
.select(`
*,
line_items:receipt_line_items(*),
matched_transaction:transactions(*)
`)
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* PATCH /api/receipts/[id]
* Update a receipt
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
// Allowed update fields
const allowedFields = [
'merchant_name',
'receipt_date',
'receipt_time',
'total_amount',
'currency',
'vat_amount',
'is_restaurant',
'is_systembolaget',
'is_foreign_merchant',
'representation_persons',
'representation_purpose',
'status',
]
const updates: Record<string, unknown> = {}
for (const field of allowedFields) {
if (body[field] !== undefined) {
updates[field] = body[field]
}
}
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
}
const { data, error } = await supabase
.from('receipts')
.update(updates)
.eq('id', id)
.eq('user_id', user.id)
.select(`
*,
line_items:receipt_line_items(*)
`)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* DELETE /api/receipts/[id]
* Delete a receipt and its line items
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get receipt to find image URL for cleanup
const { data: receipt } = await supabase
.from('receipts')
.select('image_url, matched_transaction_id')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (!receipt) {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
// Unlink from transaction if matched
if (receipt.matched_transaction_id) {
await supabase
.from('transactions')
.update({ receipt_id: null })
.eq('id', receipt.matched_transaction_id)
}
// Delete receipt (line items are cascade deleted)
const { error } = await supabase
.from('receipts')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Optionally delete image from storage
if (receipt.image_url) {
try {
const urlParts = receipt.image_url.split('/receipts/')
if (urlParts[1]) {
await supabase.storage.from('receipts').remove([urlParts[1]])
}
} catch {
// Ignore storage cleanup errors
}
}
return NextResponse.json({ success: true })
}
+156
View File
@@ -0,0 +1,156 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { estimateProductValue } from '@/lib/receipts/receipt-analyzer'
import { classifyGift } from '@/lib/benefits/gift-classifier'
import type { GiftInput } from '@/types'
/**
* POST /api/receipts/product
* Register a product/gift without a receipt
* Uses AI to estimate value, then routes to gift classification
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const formData = await request.formData()
const imageFile = formData.get('image') as File | null
const manualValue = formData.get('estimated_value') as string | null
const brandName = formData.get('brand_name') as string | null
const description = formData.get('description') as string | null
// Gift classification inputs
const hasMotprestation = formData.get('has_motprestation') === 'true'
const usedInBusiness = formData.get('used_in_business') === 'true'
const usedPrivately = formData.get('used_privately') === 'true'
const isSimplePromo = formData.get('is_simple_promo') === 'true'
let estimatedValue = manualValue ? parseFloat(manualValue) : null
let productDescription = description || ''
let productBrand = brandName || null
let imageUrl: string | null = null
// If image provided, analyze and estimate value
if (imageFile) {
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
if (!validTypes.includes(imageFile.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Supported: JPEG, PNG, WebP, GIF' },
{ status: 400 }
)
}
// Convert to base64
const arrayBuffer = await imageFile.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
// Upload image to storage
const ext = imageFile.type.split('/')[1]
const filename = `${user.id}/products/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
const { error: uploadError } = await supabase.storage
.from('receipts')
.upload(filename, arrayBuffer, {
contentType: imageFile.type,
cacheControl: '3600',
})
if (uploadError) {
console.error('Storage upload error:', uploadError)
} else {
const { data: urlData } = supabase.storage.from('receipts').getPublicUrl(filename)
imageUrl = urlData.publicUrl
}
// Estimate value if not manually provided
if (!estimatedValue) {
try {
const mimeType = imageFile.type as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
const estimation = await estimateProductValue(base64, mimeType)
estimatedValue = estimation.estimatedValue
if (!productDescription) {
productDescription = estimation.description
}
if (!productBrand && estimation.brand) {
productBrand = estimation.brand
}
} catch (error) {
console.error('Value estimation error:', error)
// Continue without AI estimation
}
}
}
// Validate required data
if (!estimatedValue || estimatedValue <= 0) {
return NextResponse.json(
{ error: 'Estimated value is required' },
{ status: 400 }
)
}
if (!productDescription) {
return NextResponse.json(
{ error: 'Product description is required' },
{ status: 400 }
)
}
// Classify the gift
const giftInput: GiftInput = {
estimatedValue,
hasMotprestation,
usedInBusiness,
usedPrivately,
isSimplePromoItem: isSimplePromo,
}
const classification = classifyGift(giftInput)
// Create gift record
const { data: gift, error: giftError } = await supabase
.from('gifts')
.insert({
user_id: user.id,
date: new Date().toISOString().split('T')[0],
brand_name: productBrand || 'Okänt varumärke',
description: productDescription,
estimated_value: estimatedValue,
has_motprestation: hasMotprestation,
used_in_business: usedInBusiness,
used_privately: usedPrivately,
is_simple_promo: isSimplePromo,
classification,
})
.select()
.single()
if (giftError) {
console.error('Gift insert error:', giftError)
return NextResponse.json({ error: 'Failed to create gift record' }, { status: 500 })
}
return NextResponse.json({
data: {
gift,
classification,
imageUrl,
},
})
} catch (error) {
console.error('Product registration error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Registration failed' },
{ status: 500 }
)
}
}
+106
View File
@@ -0,0 +1,106 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/receipts/queue
* Get unmatched receipts and queue statistics
*/
export async function GET(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Parse query params
const { searchParams } = new URL(request.url)
const status = searchParams.get('status') // 'pending', 'extracted', 'confirmed', or null for all
const unmatched = searchParams.get('unmatched') === 'true'
// Build query
let query = supabase
.from('receipts')
.select(`
*,
line_items:receipt_line_items(*)
`)
.eq('user_id', user.id)
.order('created_at', { ascending: false })
if (status) {
query = query.eq('status', status)
}
if (unmatched) {
query = query.is('matched_transaction_id', null)
}
const { data: receipts, error: receiptsError } = await query
if (receiptsError) {
console.error('Receipts fetch error:', receiptsError)
return NextResponse.json({ error: 'Failed to fetch receipts' }, { status: 500 })
}
// Get counts for queue summary
const { count: unmatchedReceiptsCount } = await supabase
.from('receipts')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('status', 'confirmed')
.is('matched_transaction_id', null)
const { count: pendingReviewCount } = await supabase
.from('receipts')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('status', 'extracted')
const { count: unmatchedTransactionsCount } = await supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.lt('amount', 0)
.is('receipt_id', null)
// Calculate streak (days with at least one categorized transaction)
const { data: recentActivity } = await supabase
.from('receipts')
.select('created_at')
.eq('user_id', user.id)
.eq('status', 'confirmed')
.order('created_at', { ascending: false })
.limit(30)
let streakCount = 0
if (recentActivity && recentActivity.length > 0) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const activityDates = new Set(
recentActivity.map((r) => new Date(r.created_at).toISOString().split('T')[0])
)
let checkDate = new Date(today)
while (activityDates.has(checkDate.toISOString().split('T')[0])) {
streakCount++
checkDate.setDate(checkDate.getDate() - 1)
}
}
return NextResponse.json({
data: {
receipts,
summary: {
unmatched_receipts_count: unmatchedReceiptsCount || 0,
unmatched_transactions_count: unmatchedTransactionsCount || 0,
pending_review_count: pendingReviewCount || 0,
streak_count: streakCount,
},
},
})
}
+55
View File
@@ -0,0 +1,55 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/receipts
* List receipts for the authenticated user
* Query params:
* - status: filter by status
* - limit: max results (default 50)
* - offset: pagination offset
*/
export async function GET(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const status = searchParams.get('status')
const limit = parseInt(searchParams.get('limit') || '50', 10)
const offset = parseInt(searchParams.get('offset') || '0', 10)
let query = supabase
.from('receipts')
.select(`
*,
line_items:receipt_line_items(*)
`, { count: 'exact' })
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (status) {
query = query.eq('status', status)
}
const { data, error, count } = await query
if (error) {
console.error('Receipts fetch error:', error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({
data,
count,
limit,
offset,
})
}
+190
View File
@@ -0,0 +1,190 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { analyzeReceipt } from '@/lib/receipts/receipt-analyzer'
import { processLineItems } from '@/lib/receipts/receipt-categorizer'
/**
* POST /api/receipts/upload
* Upload a receipt image and extract data using Claude Vision
*
* Accepts multipart/form-data with:
* - image: The receipt image file
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const formData = await request.formData()
const imageFile = formData.get('image') as File | null
if (!imageFile) {
return NextResponse.json({ error: 'No image file provided' }, { status: 400 })
}
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
if (!validTypes.includes(imageFile.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Supported: JPEG, PNG, WebP, GIF' },
{ status: 400 }
)
}
// Convert file to base64
const arrayBuffer = await imageFile.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
// Generate unique filename
const ext = imageFile.type.split('/')[1]
const filename = `${user.id}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
// Upload to Supabase Storage
const { data: uploadData, error: uploadError } = await supabase.storage
.from('receipts')
.upload(filename, arrayBuffer, {
contentType: imageFile.type,
cacheControl: '3600',
})
if (uploadError) {
console.error('Storage upload error:', uploadError)
return NextResponse.json({ error: 'Failed to upload image' }, { status: 500 })
}
// Get public URL
const { data: urlData } = supabase.storage.from('receipts').getPublicUrl(filename)
const imageUrl = urlData.publicUrl
// Create receipt record with pending status
const { data: receipt, error: insertError } = await supabase
.from('receipts')
.insert({
user_id: user.id,
image_url: imageUrl,
status: 'processing',
})
.select()
.single()
if (insertError) {
console.error('Receipt insert error:', insertError)
return NextResponse.json({ error: 'Failed to create receipt record' }, { status: 500 })
}
// Analyze receipt with Claude Vision
try {
const mimeType = imageFile.type as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
const extraction = await analyzeReceipt(base64, mimeType)
// Process and categorize line items
const processedLineItems = processLineItems(extraction.lineItems)
// Update receipt with extracted data
const { error: updateError } = await supabase
.from('receipts')
.update({
status: 'extracted',
extraction_confidence: extraction.confidence,
merchant_name: extraction.merchant.name,
merchant_org_number: extraction.merchant.orgNumber,
merchant_vat_number: extraction.merchant.vatNumber,
receipt_date: extraction.receipt.date,
receipt_time: extraction.receipt.time,
total_amount: extraction.totals.total,
currency: extraction.receipt.currency,
vat_amount: extraction.totals.vatAmount,
is_restaurant: extraction.flags.isRestaurant,
is_systembolaget: extraction.flags.isSystembolaget,
is_foreign_merchant: extraction.flags.isForeignMerchant,
raw_extraction: extraction,
})
.eq('id', receipt.id)
if (updateError) {
console.error('Receipt update error:', updateError)
}
// Insert line items
if (processedLineItems.length > 0) {
const lineItemsToInsert = processedLineItems.map((item, index) => ({
receipt_id: receipt.id,
description: item.description,
quantity: item.quantity,
unit_price: item.unitPrice,
line_total: item.lineTotal,
vat_rate: item.vatRate,
vat_amount: item.vatRate && item.lineTotal ? (item.lineTotal * item.vatRate) / (100 + item.vatRate) : null,
extraction_confidence: item.confidence,
suggested_category: item.suggestedCategory,
sort_order: index,
}))
const { error: lineItemsError } = await supabase
.from('receipt_line_items')
.insert(lineItemsToInsert)
if (lineItemsError) {
console.error('Line items insert error:', lineItemsError)
}
}
// Fetch the complete receipt with line items
const { data: completeReceipt, error: fetchError } = await supabase
.from('receipts')
.select(`
*,
line_items:receipt_line_items(*)
`)
.eq('id', receipt.id)
.single()
if (fetchError) {
console.error('Fetch error:', fetchError)
return NextResponse.json({
data: {
id: receipt.id,
status: 'extracted',
extraction,
},
})
}
return NextResponse.json({ data: completeReceipt })
} catch (analysisError) {
console.error('Receipt analysis error:', analysisError)
// Update receipt with error status
await supabase
.from('receipts')
.update({
status: 'error',
raw_extraction: {
error: analysisError instanceof Error ? analysisError.message : 'Unknown error',
},
})
.eq('id', receipt.id)
return NextResponse.json(
{
error: 'Failed to analyze receipt',
receiptId: receipt.id,
},
{ status: 500 }
)
}
} catch (error) {
console.error('Upload error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Upload failed' },
{ status: 500 }
)
}
}
+44
View File
@@ -0,0 +1,44 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
if (!periodId) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', periodId)
.eq('user_id', user.id)
.single()
try {
const result = await generateBalanceSheet(user.id, periodId)
if (period) {
result.period = {
start: period.period_start,
end: period.period_end,
}
}
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to generate balance sheet' },
{ status: 500 }
)
}
}
+45
View File
@@ -0,0 +1,45 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
if (!periodId) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
// Get period dates
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', periodId)
.eq('user_id', user.id)
.single()
try {
const result = await generateIncomeStatement(user.id, periodId)
if (period) {
result.period = {
start: period.period_start,
end: period.period_end,
}
}
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to generate income statement' },
{ status: 500 }
)
}
}
+69
View File
@@ -0,0 +1,69 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateNEDeclaration } from '@/lib/reports/ne-declaration'
import {
generateSRUFile,
sruFileToString,
getSRUFilename,
} from '@/lib/reports/sru-generator'
/**
* GET /api/reports/ne-declaration
*
* Generate NE declaration (NE-bilaga) for enskild firma.
*
* Query parameters:
* - period_id: Fiscal period ID (required)
* - format: 'json' (default) or 'sru' for SRU file download
*
* Returns:
* - JSON: NE declaration with rutor R1-R11 and breakdown
* - SRU: Downloadable SRU file for Skatteverket submission
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const format = searchParams.get('format') || 'json'
if (!periodId) {
return NextResponse.json(
{ error: 'period_id is required' },
{ status: 400 }
)
}
try {
const declaration = await generateNEDeclaration(user.id, periodId)
if (format === 'sru') {
// Generate and return SRU file
const sruFile = generateSRUFile(declaration)
const sruContent = sruFileToString(sruFile)
const filename = getSRUFilename(declaration)
return new NextResponse(sruContent, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
},
})
}
// Default: return JSON
return NextResponse.json({ data: declaration })
} catch (err) {
console.error('Error generating NE declaration:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to generate NE declaration' },
{ status: 500 }
)
}
}
+53
View File
@@ -0,0 +1,53 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateSIEExport } from '@/lib/reports/sie-export'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
if (!periodId) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
// Get company settings for SIE metadata
const { data: company } = await supabase
.from('company_settings')
.select('company_name, org_number')
.eq('user_id', user.id)
.single()
if (!company) {
return NextResponse.json({ error: 'Company settings not found' }, { status: 404 })
}
try {
const sieContent = await generateSIEExport(user.id, {
fiscal_period_id: periodId,
company_name: company.company_name || 'Unknown',
org_number: company.org_number,
program_name: 'InfluencerBiz',
})
// Return as downloadable file
return new NextResponse(sieContent, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': `attachment; filename="export_${periodId}.se"`,
},
})
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to generate SIE export' },
{ status: 500 }
)
}
}
+29
View File
@@ -0,0 +1,29 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
if (!periodId) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
try {
const result = await generateTrialBalance(user.id, periodId)
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to generate trial balance' },
{ status: 500 }
)
}
}
+114
View File
@@ -0,0 +1,114 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import {
calculateVatDeclaration,
formatPeriodLabel,
} from '@/lib/reports/vat-declaration'
import type { VatPeriodType } from '@/types'
/**
* GET /api/reports/vat-declaration
*
* Calculate VAT declaration (momsdeklaration) for a given period.
*
* Query parameters:
* - periodType: 'monthly' | 'quarterly' | 'yearly'
* - year: number (e.g., 2025)
* - period: number (1-12 for monthly, 1-4 for quarterly, 1 for yearly)
*
* Returns:
* - VAT rutor (boxes) according to Swedish tax authority format
* - Period information
* - Breakdown by source (invoices, transactions, receipts)
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const periodType = searchParams.get('periodType') as VatPeriodType | null
const yearStr = searchParams.get('year')
const periodStr = searchParams.get('period')
// Validate required parameters
if (!periodType || !yearStr || !periodStr) {
return NextResponse.json(
{ error: 'Missing required parameters: periodType, year, period' },
{ status: 400 }
)
}
// Validate periodType
if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) {
return NextResponse.json(
{ error: 'Invalid periodType. Must be: monthly, quarterly, or yearly' },
{ status: 400 }
)
}
const year = parseInt(yearStr, 10)
const period = parseInt(periodStr, 10)
// Validate year
if (isNaN(year) || year < 2000 || year > 2100) {
return NextResponse.json(
{ error: 'Invalid year. Must be between 2000 and 2100' },
{ status: 400 }
)
}
// Validate period based on type
if (isNaN(period)) {
return NextResponse.json(
{ error: 'Invalid period' },
{ status: 400 }
)
}
if (periodType === 'monthly' && (period < 1 || period > 12)) {
return NextResponse.json(
{ error: 'Invalid period for monthly. Must be 1-12' },
{ status: 400 }
)
}
if (periodType === 'quarterly' && (period < 1 || period > 4)) {
return NextResponse.json(
{ error: 'Invalid period for quarterly. Must be 1-4' },
{ status: 400 }
)
}
if (periodType === 'yearly' && period !== 1) {
return NextResponse.json(
{ error: 'Invalid period for yearly. Must be 1' },
{ status: 400 }
)
}
try {
const declaration = await calculateVatDeclaration(
user.id,
periodType,
year,
period
)
return NextResponse.json({
data: {
...declaration,
periodLabel: formatPeriodLabel(periodType, year, period),
},
})
} catch (err) {
console.error('Error calculating VAT declaration:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to calculate VAT declaration' },
{ status: 500 }
)
}
}
+75
View File
@@ -0,0 +1,75 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { didTaxFieldsChange, regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function PUT(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch current settings to check for tax-relevant changes
const { data: oldSettings } = await supabase
.from('company_settings')
.select('entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month')
.eq('user_id', user.id)
.single()
const body = await request.json()
const { data, error } = await supabase
.from('company_settings')
.update(body)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Check if tax-relevant fields changed and regenerate deadlines (skip for light mode)
if (data.entity_type !== 'light' && oldSettings && didTaxFieldsChange(oldSettings, data)) {
try {
await regenerateTaxDeadlinesForUser(supabase, user.id, {
entity_type: data.entity_type,
moms_period: data.moms_period,
f_skatt: data.f_skatt,
vat_registered: data.vat_registered,
pays_salaries: data.pays_salaries ?? false,
fiscal_year_start_month: data.fiscal_year_start_month,
})
console.log('Tax deadlines regenerated after settings change')
} catch (err) {
console.error('Failed to regenerate tax deadlines:', err)
// Don't fail the settings update if deadline generation fails
}
}
return NextResponse.json({ data })
}
+85
View File
@@ -0,0 +1,85 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('shadow_ledger_entries')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 404 })
}
return NextResponse.json({ data })
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
// Remove fields that shouldn't be updated directly
const { id: _id, user_id: _uid, created_at: _ca, updated_at: _ua, ...updates } = body
const { data, error } = await supabase
.from('shadow_ledger_entries')
.update(updates)
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { error } = await supabase
.from('shadow_ledger_entries')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
+121
View File
@@ -0,0 +1,121 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { calculateGiftVirtualTaxDebt } from '@/lib/tax/light-calculator'
import type { CreateShadowLedgerEntryInput } from '@/types'
/**
* GET /api/shadow-ledger
* List shadow ledger entries for the authenticated user
* Query params: year (optional, defaults to current year)
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const year = searchParams.get('year') || new Date().getFullYear().toString()
const startDate = `${year}-01-01`
const endDate = `${year}-12-31`
const { data, error } = await supabase
.from('shadow_ledger_entries')
.select('*')
.eq('user_id', user.id)
.gte('date', startDate)
.lte('date', endDate)
.order('date', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* POST /api/shadow-ledger
* Create a new shadow ledger entry
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: CreateShadowLedgerEntryInput = await request.json()
if (!body.date || !body.type || body.gross_amount === undefined || body.net_amount === undefined) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
// Fetch settings for umbrella config and tax rates
const { data: settings } = await supabase
.from('company_settings')
.select('umbrella_provider, umbrella_fee_percent, umbrella_pension_percent, municipal_tax_rate, church_tax, church_tax_rate')
.eq('user_id', user.id)
.single()
// For payout type: auto-populate service_fee and pension_deduction from settings if not provided
let serviceFee = body.service_fee ?? 0
let pensionDeduction = body.pension_deduction ?? 0
let provider = body.provider ?? null
if (body.type === 'payout' && settings) {
if (!body.provider && settings.umbrella_provider) {
provider = settings.umbrella_provider
}
if (body.service_fee === undefined && settings.umbrella_fee_percent) {
serviceFee = Math.round(body.gross_amount * (Number(settings.umbrella_fee_percent) / 100) * 100) / 100
}
if (body.pension_deduction === undefined && settings.umbrella_pension_percent) {
pensionDeduction = Math.round(body.gross_amount * (Number(settings.umbrella_pension_percent) / 100) * 100) / 100
}
}
// Calculate virtual tax debt for gift entries
let virtualTaxDebt = 0
if (body.type === 'gift' && settings) {
const municipalRate = Number(settings.municipal_tax_rate) || 0.3238
const churchRate = settings.church_tax ? (Number(settings.church_tax_rate) || 0.01) : 0
virtualTaxDebt = calculateGiftVirtualTaxDebt(body.gross_amount, municipalRate, churchRate)
}
const { data, error } = await supabase
.from('shadow_ledger_entries')
.insert({
user_id: user.id,
date: body.date,
type: body.type,
source: body.source || 'manual',
provider,
gross_amount: body.gross_amount,
platform_fee: body.platform_fee ?? 0,
service_fee: serviceFee,
pension_deduction: pensionDeduction,
social_fees: body.social_fees ?? 0,
income_tax_withheld: body.income_tax_withheld ?? 0,
net_amount: body.net_amount,
currency: body.currency || 'SEK',
description: body.description || null,
bank_transaction_id: body.bank_transaction_id || null,
gift_id: body.gift_id || null,
campaign_id: body.campaign_id || null,
metadata: body.metadata || {},
virtual_tax_debt: virtualTaxDebt,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
+85
View File
@@ -0,0 +1,85 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { ShadowLedgerSummary, ShadowLedgerEntryType } from '@/types'
/**
* GET /api/shadow-ledger/summary
* Aggregated summary of shadow ledger entries for a year
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const year = searchParams.get('year') || new Date().getFullYear().toString()
const startDate = `${year}-01-01`
const endDate = `${year}-12-31`
const { data: entries, error } = await supabase
.from('shadow_ledger_entries')
.select('type, gross_amount, net_amount, service_fee, income_tax_withheld, pension_deduction, social_fees, platform_fee, virtual_tax_debt')
.eq('user_id', user.id)
.gte('date', startDate)
.lte('date', endDate)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const types: ShadowLedgerEntryType[] = ['payout', 'gift', 'expense', 'hobby_income', 'hobby_expense']
const byType = {} as Record<ShadowLedgerEntryType, { count: number; gross: number; net: number }>
for (const t of types) {
byType[t] = { count: 0, gross: 0, net: 0 }
}
let totalGross = 0
let totalNet = 0
let totalFees = 0
let totalTaxWithheld = 0
let totalPension = 0
let totalSocialFees = 0
let totalPlatformFees = 0
let virtualTaxDebt = 0
for (const entry of entries || []) {
const type = entry.type as ShadowLedgerEntryType
const gross = Number(entry.gross_amount)
const net = Number(entry.net_amount)
totalGross += gross
totalNet += net
totalFees += Number(entry.service_fee) || 0
totalTaxWithheld += Number(entry.income_tax_withheld) || 0
totalPension += Number(entry.pension_deduction) || 0
totalSocialFees += Number(entry.social_fees) || 0
totalPlatformFees += Number(entry.platform_fee) || 0
virtualTaxDebt += Number(entry.virtual_tax_debt) || 0
if (byType[type]) {
byType[type].count++
byType[type].gross += gross
byType[type].net += net
}
}
const summary: ShadowLedgerSummary = {
year: parseInt(year),
total_gross: Math.round(totalGross * 100) / 100,
total_net: Math.round(totalNet * 100) / 100,
total_fees: Math.round(totalFees * 100) / 100,
total_tax_withheld: Math.round(totalTaxWithheld * 100) / 100,
total_pension: Math.round(totalPension * 100) / 100,
total_social_fees: Math.round(totalSocialFees * 100) / 100,
total_platform_fees: Math.round(totalPlatformFees * 100) / 100,
virtual_tax_debt: Math.round(virtualTaxDebt * 100) / 100,
entry_count: (entries || []).length,
by_type: byType,
}
return NextResponse.json({ data: summary })
}
+51
View File
@@ -0,0 +1,51 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { generateNewYearDeadlines } from '@/lib/tax/deadline-generator'
/**
* GET /api/tax-deadlines/cron
* Annual cron job to generate tax deadlines for the new year
* Runs on January 2nd
*
* Vercel Cron: "0 0 2 1 *" (midnight on January 2nd)
*/
export async function GET(request: Request) {
// Verify cron secret for security
const authHeader = request.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Create a service role client for accessing all user data
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return NextResponse.json(
{ error: 'Missing Supabase configuration' },
{ status: 500 }
)
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
try {
const result = await generateNewYearDeadlines(supabase)
console.log(`Tax deadlines cron completed: ${result.usersProcessed} users, ${result.totalCreated} deadlines created`)
return NextResponse.json({
success: true,
usersProcessed: result.usersProcessed,
totalCreated: result.totalCreated,
})
} catch (error) {
console.error('Error in tax deadlines cron:', error)
return NextResponse.json(
{ error: 'Failed to generate tax deadlines' },
{ status: 500 }
)
}
}
+54
View File
@@ -0,0 +1,54 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator'
/**
* POST /api/tax-deadlines/generate
* Manually trigger tax deadline generation for the current user
*/
export async function POST() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch company settings
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month')
.eq('user_id', user.id)
.single()
if (settingsError || !settings) {
return NextResponse.json(
{ error: 'Company settings not found' },
{ status: 404 }
)
}
try {
const result = await regenerateTaxDeadlinesForUser(supabase, user.id, {
entity_type: settings.entity_type,
moms_period: settings.moms_period,
f_skatt: settings.f_skatt,
vat_registered: settings.vat_registered,
pays_salaries: settings.pays_salaries ?? false,
fiscal_year_start_month: settings.fiscal_year_start_month,
})
return NextResponse.json({
success: true,
created: result.created,
deleted: result.deleted,
})
} catch (error) {
console.error('Error generating tax deadlines:', error)
return NextResponse.json(
{ error: 'Failed to generate tax deadlines' },
{ status: 500 }
)
}
}
+49
View File
@@ -0,0 +1,49 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
// Get user's TikTok accounts (exclude encrypted tokens)
const { data: accounts, error } = await supabase
.from('tiktok_accounts')
.select(`
id,
tiktok_user_id,
username,
display_name,
avatar_url,
token_expires_at,
refresh_token_expires_at,
status,
last_synced_at,
last_stats_sync_at,
last_video_sync_at,
last_error,
error_count,
created_at,
updated_at
`)
.eq('user_id', user.id)
.order('created_at', { ascending: false })
if (error) {
throw error
}
return NextResponse.json({ accounts: accounts || [] })
} catch (error) {
console.error('Error fetching TikTok accounts:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch accounts' },
{ status: 500 }
)
}
}
+117
View File
@@ -0,0 +1,117 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateOAuthState, exchangeCodeForTokens, calculateTokenExpiration } from '@/lib/tiktok/oauth'
import { encryptTokens } from '@/lib/tiktok/encryption'
import { getUserInfo } from '@/lib/tiktok/api'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/settings`
// Handle OAuth errors
if (error) {
console.error('TikTok OAuth error:', error, errorDescription)
return NextResponse.redirect(
`${redirectUrl}?tiktok_error=${encodeURIComponent(errorDescription || error)}`
)
}
// Validate required parameters
if (!code || !state) {
return NextResponse.redirect(
`${redirectUrl}?tiktok_error=${encodeURIComponent('Missing authorization code or state')}`
)
}
// Validate state (CSRF protection) and get PKCE code verifier
const stateData = await validateOAuthState(state)
if (!stateData) {
return NextResponse.redirect(
`${redirectUrl}?tiktok_error=${encodeURIComponent('Invalid or expired state parameter')}`
)
}
const { userId, codeVerifier } = stateData
const supabase = await createClient()
try {
// Exchange code for tokens (with PKCE verifier)
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/tiktok/callback`
const tokens = await exchangeCodeForTokens(code, redirectUri, codeVerifier)
// Encrypt tokens
const encryptedTokens = encryptTokens(tokens.access_token, tokens.refresh_token)
// Calculate expiration dates
const { tokenExpiresAt, refreshTokenExpiresAt } = calculateTokenExpiration(tokens)
// Get user info from TikTok
const userInfo = await getUserInfo({
accessToken: tokens.access_token,
userId,
})
// Check if account already exists (maybe in revoked/expired state)
const { data: existingAccount } = await supabase
.from('tiktok_accounts')
.select('id')
.eq('user_id', userId)
.eq('tiktok_user_id', tokens.open_id)
.single()
if (existingAccount) {
// Update existing account
const { error: updateError } = await supabase
.from('tiktok_accounts')
.update({
username: userInfo.username,
display_name: userInfo.display_name,
avatar_url: userInfo.avatar_url,
...encryptedTokens,
token_expires_at: tokenExpiresAt.toISOString(),
refresh_token_expires_at: refreshTokenExpiresAt.toISOString(),
status: 'active',
error_count: 0,
last_error: null,
})
.eq('id', existingAccount.id)
if (updateError) {
throw updateError
}
} else {
// Create new account
const { error: insertError } = await supabase
.from('tiktok_accounts')
.insert({
user_id: userId,
tiktok_user_id: tokens.open_id,
username: userInfo.username,
display_name: userInfo.display_name,
avatar_url: userInfo.avatar_url,
...encryptedTokens,
token_expires_at: tokenExpiresAt.toISOString(),
refresh_token_expires_at: refreshTokenExpiresAt.toISOString(),
status: 'active',
})
if (insertError) {
throw insertError
}
}
return NextResponse.redirect(`${redirectUrl}?tiktok_connected=true`)
} catch (error) {
console.error('TikTok callback error:', error)
return NextResponse.redirect(
`${redirectUrl}?tiktok_error=${encodeURIComponent(
error instanceof Error ? error.message : 'Failed to connect TikTok account'
)}`
)
}
}
+47
View File
@@ -0,0 +1,47 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateOAuthState, getAuthorizationUrl } from '@/lib/tiktok/oauth'
export async function POST() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
// Check if user already has an active TikTok account
const { data: existingAccount } = await supabase
.from('tiktok_accounts')
.select('id, status')
.eq('user_id', user.id)
.eq('status', 'active')
.single()
if (existingAccount) {
return NextResponse.json(
{ error: 'TikTok account already connected' },
{ status: 400 }
)
}
// Generate OAuth state for CSRF protection and PKCE
const { state, codeChallenge } = await generateOAuthState(user.id)
// Get redirect URL
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/tiktok/callback`
// Generate authorization URL with PKCE
const authorizationUrl = getAuthorizationUrl(redirectUri, state, codeChallenge)
return NextResponse.json({ authorization_url: authorizationUrl })
} catch (error) {
console.error('TikTok connect error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Connection failed' },
{ status: 500 }
)
}
}
+62
View File
@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server'
import { syncAllAccounts, refreshExpiringTokens } from '@/lib/tiktok/sync'
// Verify cron secret for security
function verifyCronSecret(request: Request): boolean {
const authHeader = request.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
if (!cronSecret) {
console.error('CRON_SECRET not configured')
return false
}
if (!authHeader) {
return false
}
// Support both "Bearer <token>" and just "<token>" formats
const token = authHeader.startsWith('Bearer ')
? authHeader.substring(7)
: authHeader
return token === cronSecret
}
export async function GET(request: Request) {
// Verify cron authentication
if (!verifyCronSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
console.log('Starting TikTok cron job...')
// First, refresh tokens that are expiring soon
const tokensRefreshed = await refreshExpiringTokens()
console.log(`Refreshed ${tokensRefreshed} expiring tokens`)
// Then sync all active accounts
const syncResults = await syncAllAccounts()
console.log(`Sync completed: ${syncResults.successful}/${syncResults.total} successful, ${syncResults.failed} failed`)
return NextResponse.json({
success: true,
tokens_refreshed: tokensRefreshed,
accounts_total: syncResults.total,
accounts_successful: syncResults.successful,
accounts_failed: syncResults.failed,
})
} catch (error) {
console.error('TikTok cron job error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Cron job failed' },
{ status: 500 }
)
}
}
// Also support POST for manual triggering via dashboard
export async function POST(request: Request) {
return GET(request)
}
+61
View File
@@ -0,0 +1,61 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { revokeToken } from '@/lib/tiktok/oauth'
import { decryptToken } from '@/lib/tiktok/encryption'
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { account_id } = await request.json()
if (!account_id) {
return NextResponse.json({ error: 'account_id is required' }, { status: 400 })
}
try {
// Get account with encrypted token
const { data: account, error: fetchError } = await supabase
.from('tiktok_accounts')
.select('id, user_id, access_token_encrypted')
.eq('id', account_id)
.eq('user_id', user.id)
.single()
if (fetchError || !account) {
return NextResponse.json({ error: 'Account not found' }, { status: 404 })
}
// Try to revoke token with TikTok (best effort)
try {
const accessToken = decryptToken(account.access_token_encrypted)
await revokeToken(accessToken)
} catch (revokeError) {
// Log but don't fail - we'll still mark as revoked locally
console.error('Failed to revoke TikTok token:', revokeError)
}
// Update account status to revoked
const { error: updateError } = await supabase
.from('tiktok_accounts')
.update({ status: 'revoked' })
.eq('id', account_id)
if (updateError) {
throw updateError
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('TikTok disconnect error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Disconnect failed' },
{ status: 500 }
)
}
}
+60
View File
@@ -0,0 +1,60 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getStatsSummary, calculateFollowerGrowth, getFollowerHistory } from '@/lib/tiktok/metrics'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const accountId = searchParams.get('account_id')
const period = searchParams.get('period') as '7d' | '30d' | '90d' | '1y' | null
try {
// If no account_id, get summary for user's active account
if (!accountId) {
const summary = await getStatsSummary(user.id)
if (!summary) {
return NextResponse.json({ error: 'No connected TikTok account found' }, { status: 404 })
}
return NextResponse.json({ summary })
}
// Verify account belongs to user
const { data: account, error: fetchError } = await supabase
.from('tiktok_accounts')
.select('id')
.eq('id', accountId)
.eq('user_id', user.id)
.single()
if (fetchError || !account) {
return NextResponse.json({ error: 'Account not found' }, { status: 404 })
}
// Get growth data for specified period
if (period) {
const growth = await calculateFollowerGrowth(accountId, period)
return NextResponse.json({ growth })
}
// Get follower history (default 30 days)
const days = parseInt(searchParams.get('days') || '30', 10)
const history = await getFollowerHistory(accountId, days)
return NextResponse.json({ history })
} catch (error) {
console.error('Error fetching TikTok stats:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch stats' },
{ status: 500 }
)
}
}
+78
View File
@@ -0,0 +1,78 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { syncAccount } from '@/lib/tiktok/sync'
import type { TikTokAccount, TikTokSyncType } from '@/types'
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { account_id, sync_type = 'full' } = await request.json()
if (!account_id) {
return NextResponse.json({ error: 'account_id is required' }, { status: 400 })
}
// Validate sync_type
const validSyncTypes: TikTokSyncType[] = ['stats', 'videos', 'full']
if (!validSyncTypes.includes(sync_type)) {
return NextResponse.json(
{ error: 'Invalid sync_type. Must be one of: stats, videos, full' },
{ status: 400 }
)
}
try {
// Get account
const { data: account, error: fetchError } = await supabase
.from('tiktok_accounts')
.select('*')
.eq('id', account_id)
.eq('user_id', user.id)
.single()
if (fetchError || !account) {
return NextResponse.json({ error: 'Account not found' }, { status: 404 })
}
// Check if account is in a valid state for syncing
if (account.status === 'revoked') {
return NextResponse.json(
{ error: 'Account has been disconnected' },
{ status: 400 }
)
}
// Perform sync
const result = await syncAccount(account as TikTokAccount, sync_type)
if (!result.success) {
return NextResponse.json(
{
error: result.error || 'Sync failed',
errorCode: result.errorCode,
partial: result.statsSynced || result.videosSynced > 0,
},
{ status: 500 }
)
}
return NextResponse.json({
success: true,
stats_synced: result.statsSynced,
videos_synced: result.videosSynced,
new_videos: result.newVideos,
})
} catch (error) {
console.error('TikTok sync error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Sync failed' },
{ status: 500 }
)
}
}
+153
View File
@@ -0,0 +1,153 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
interface RouteContext {
params: Promise<{ id: string }>
}
export async function POST(request: Request, context: RouteContext) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: videoId } = await context.params
const { campaign_id, deliverable_id } = await request.json()
if (!campaign_id && !deliverable_id) {
return NextResponse.json(
{ error: 'campaign_id or deliverable_id is required' },
{ status: 400 }
)
}
try {
// Verify video belongs to user
const { data: video, error: fetchError } = await supabase
.from('tiktok_videos')
.select('id, user_id')
.eq('id', videoId)
.eq('user_id', user.id)
.single()
if (fetchError || !video) {
return NextResponse.json({ error: 'Video not found' }, { status: 404 })
}
// Verify campaign belongs to user (if provided)
if (campaign_id) {
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id')
.eq('id', campaign_id)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
}
// Verify deliverable belongs to user (if provided)
if (deliverable_id) {
const { data: deliverable, error: deliverableError } = await supabase
.from('deliverables')
.select('id, campaign_id')
.eq('id', deliverable_id)
.eq('user_id', user.id)
.single()
if (deliverableError || !deliverable) {
return NextResponse.json({ error: 'Deliverable not found' }, { status: 404 })
}
// If deliverable is provided but campaign_id is not, use deliverable's campaign
if (!campaign_id && deliverable.campaign_id) {
const updateData = {
campaign_id: deliverable.campaign_id,
deliverable_id,
}
const { error: updateError } = await supabase
.from('tiktok_videos')
.update(updateData)
.eq('id', videoId)
if (updateError) {
throw updateError
}
return NextResponse.json({ success: true, ...updateData })
}
}
// Update video with campaign and/or deliverable link
const updateData: { campaign_id?: string; deliverable_id?: string } = {}
if (campaign_id) updateData.campaign_id = campaign_id
if (deliverable_id) updateData.deliverable_id = deliverable_id
const { error: updateError } = await supabase
.from('tiktok_videos')
.update(updateData)
.eq('id', videoId)
if (updateError) {
throw updateError
}
return NextResponse.json({ success: true, ...updateData })
} catch (error) {
console.error('Error linking video:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to link video' },
{ status: 500 }
)
}
}
export async function DELETE(request: Request, context: RouteContext) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: videoId } = await context.params
try {
// Verify video belongs to user
const { data: video, error: fetchError } = await supabase
.from('tiktok_videos')
.select('id, user_id')
.eq('id', videoId)
.eq('user_id', user.id)
.single()
if (fetchError || !video) {
return NextResponse.json({ error: 'Video not found' }, { status: 404 })
}
// Remove campaign and deliverable links
const { error: updateError } = await supabase
.from('tiktok_videos')
.update({ campaign_id: null, deliverable_id: null })
.eq('id', videoId)
if (updateError) {
throw updateError
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error unlinking video:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to unlink video' },
{ status: 500 }
)
}
}
+63
View File
@@ -0,0 +1,63 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const accountId = searchParams.get('account_id')
const campaignId = searchParams.get('campaign_id')
const limit = parseInt(searchParams.get('limit') || '20', 10)
const offset = parseInt(searchParams.get('offset') || '0', 10)
const unlinkedOnly = searchParams.get('unlinked_only') === 'true'
try {
let query = supabase
.from('tiktok_videos')
.select(`
*,
campaign:campaigns(id, name),
deliverable:deliverables(id, title)
`, { count: 'exact' })
.eq('user_id', user.id)
.order('published_at', { ascending: false })
.range(offset, offset + limit - 1)
if (accountId) {
query = query.eq('tiktok_account_id', accountId)
}
if (campaignId) {
query = query.eq('campaign_id', campaignId)
}
if (unlinkedOnly) {
query = query.is('campaign_id', null)
}
const { data: videos, error, count } = await query
if (error) {
throw error
}
return NextResponse.json({
videos: videos || [],
total: count || 0,
limit,
offset,
})
} catch (error) {
console.error('Error fetching TikTok videos:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch videos' },
{ status: 500 }
)
}
}
@@ -0,0 +1,236 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
import type { Transaction, TransactionCategory, EntityType } from '@/types'
interface CategorizeRequest {
is_business: boolean
category?: TransactionCategory
}
/**
* Ensure a fiscal period exists for the given date, create one if needed
*/
async function ensureFiscalPeriod(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
date: string
): Promise<boolean> {
// Check if a fiscal period already covers this date
const { data: existing } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.lte('period_start', date)
.gte('period_end', date)
.eq('is_closed', false)
.single()
if (existing) {
return true
}
// No fiscal period exists - create one for the year of the transaction
const transactionDate = new Date(date)
const year = transactionDate.getFullYear()
const periodStart = `${year}-01-01`
const periodEnd = `${year}-12-31`
const { error } = await supabase
.from('fiscal_periods')
.upsert({
user_id: userId,
name: `Räkenskapsår ${year}`,
period_start: periodStart,
period_end: periodEnd,
}, {
onConflict: 'user_id,period_start,period_end',
})
if (error) {
console.error('Failed to create fiscal period:', error)
return false
}
return true
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Parse request body
const body: CategorizeRequest = await request.json()
const { is_business, category } = body
// Fetch the transaction (validates ownership)
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
// If already has a journal entry, just update category and is_business (skip journal entry creation)
if (transaction.journal_entry_id) {
const finalCat: TransactionCategory = is_business
? (category || 'uncategorized')
: 'private'
const { error: updateErr } = await supabase
.from('transactions')
.update({
is_business,
category: finalCat,
})
.eq('id', id)
if (updateErr) {
return NextResponse.json(
{ error: 'Failed to update transaction' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
journal_entry_created: false,
journal_entry_id: transaction.journal_entry_id,
journal_entry_error: null,
category: finalCat,
already_had_journal_entry: true,
})
}
// Fetch company settings to get entity type
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('user_id', user.id)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Determine the category to use
const finalCategory: TransactionCategory = is_business
? (category || 'uncategorized')
: 'private'
// Light mode: skip journal entry creation, just update category
if (entityType === 'light') {
const { error: updateError } = await supabase
.from('transactions')
.update({
is_business,
category: finalCategory,
})
.eq('id', id)
if (updateError) {
console.error('Failed to update transaction:', updateError)
return NextResponse.json(
{ error: 'Failed to update transaction' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
journal_entry_created: false,
journal_entry_id: null,
journal_entry_error: null,
category: finalCategory,
})
}
// Build mapping result from category
const mappingResult = buildMappingResultFromCategory(
finalCategory,
transaction as Transaction,
is_business,
entityType
)
// Ensure fiscal period exists for the transaction date
await ensureFiscalPeriod(supabase, user.id, transaction.date)
// Try to create journal entry
let journalEntryCreated = false
let journalEntryId: string | null = null
let journalEntryError: string | null = null
try {
const journalEntry = await createTransactionJournalEntry(
user.id,
transaction as Transaction,
mappingResult
)
if (journalEntry) {
journalEntryCreated = true
journalEntryId = journalEntry.id
}
} catch (err) {
console.error('Failed to create journal entry:', err)
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
// Continue - we still want to save the categorization
}
// Save mapping rule for future auto-categorization (only for business expenses with merchant)
if (is_business && transaction.merchant_name) {
try {
await saveUserMappingRule(
user.id,
transaction.merchant_name,
mappingResult.debit_account,
mappingResult.credit_account,
!is_business
)
} catch (err) {
console.error('Failed to save mapping rule:', err)
// Non-critical, continue
}
}
// Update the transaction
const { error: updateError } = await supabase
.from('transactions')
.update({
is_business,
category: finalCategory,
journal_entry_id: journalEntryId,
})
.eq('id', id)
if (updateError) {
console.error('Failed to update transaction:', updateError)
return NextResponse.json(
{ error: 'Failed to update transaction' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
journal_entry_created: journalEntryCreated,
journal_entry_id: journalEntryId,
journal_entry_error: journalEntryError,
category: finalCategory,
})
}
@@ -0,0 +1,220 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import type { Transaction, Invoice, CreateJournalEntryInput } from '@/types'
interface MatchInvoiceRequest {
invoice_id: string
}
/**
* Ensure a fiscal period exists for the given date, create one if needed
*/
async function ensureFiscalPeriod(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
date: string
): Promise<string | null> {
// Check if a fiscal period already covers this date
const existingPeriodId = await findFiscalPeriod(userId, date)
if (existingPeriodId) {
return existingPeriodId
}
// No fiscal period exists - create one for the year of the transaction
const transactionDate = new Date(date)
const year = transactionDate.getFullYear()
const periodStart = `${year}-01-01`
const periodEnd = `${year}-12-31`
const { data, error } = await supabase
.from('fiscal_periods')
.upsert({
user_id: userId,
name: `Räkenskapsår ${year}`,
period_start: periodStart,
period_end: periodEnd,
}, {
onConflict: 'user_id,period_start,period_end',
})
.select('id')
.single()
if (error) {
console.error('Failed to create fiscal period:', error)
return null
}
return data?.id || null
}
/**
* POST /api/transactions/[id]/match-invoice
*
* Confirms an invoice match for a transaction:
* 1. Links transaction to invoice (sets invoice_id)
* 2. Updates invoice status to 'paid'
* 3. Sets paid_at and paid_amount on invoice
* 4. Creates journal entry for payment receipt
* - Debit 1930 Företagskonto (Bank)
* - Credit 1510 Kundfordringar (Accounts Receivable)
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: transactionId } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Parse request body
const body: MatchInvoiceRequest = await request.json()
const { invoice_id } = body
if (!invoice_id) {
return NextResponse.json({ error: 'invoice_id is required' }, { status: 400 })
}
// Fetch the transaction (validates ownership)
const { data: transaction, error: fetchTxError } = await supabase
.from('transactions')
.select('*')
.eq('id', transactionId)
.eq('user_id', user.id)
.single()
if (fetchTxError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
// Verify transaction is income (amount > 0)
if (transaction.amount <= 0) {
return NextResponse.json(
{ error: 'Only income transactions can be matched to invoices' },
{ status: 400 }
)
}
// Check if transaction is already linked to an invoice
if (transaction.invoice_id) {
return NextResponse.json(
{ error: 'Transaction is already linked to an invoice' },
{ status: 400 }
)
}
// Fetch the invoice (validates ownership)
const { data: invoice, error: fetchInvError } = await supabase
.from('invoices')
.select('*, customer:customers(*)')
.eq('id', invoice_id)
.eq('user_id', user.id)
.single()
if (fetchInvError || !invoice) {
return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
}
// Verify invoice is unpaid
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
return NextResponse.json(
{ error: 'Invoice is not in an unpaid state' },
{ status: 400 }
)
}
const now = new Date().toISOString()
const paidAmount = transaction.amount
// Create journal entry for payment receipt
let journalEntryId: string | null = null
let journalEntryError: string | null = null
try {
const fiscalPeriodId = await ensureFiscalPeriod(supabase, user.id, transaction.date)
if (fiscalPeriodId) {
const journalInput: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: `Betalning mottagen: Faktura ${invoice.invoice_number}`,
source_type: 'invoice_paid',
source_id: invoice.id,
lines: [
{
account_number: '1930', // Företagskonto (Bank)
debit_amount: paidAmount,
credit_amount: 0,
line_description: `Inbetalning faktura ${invoice.invoice_number}`,
},
{
account_number: '1510', // Kundfordringar (Accounts Receivable)
debit_amount: 0,
credit_amount: paidAmount,
line_description: `Faktura ${invoice.invoice_number} betald`,
},
],
}
const journalEntry = await createJournalEntry(user.id, journalInput)
journalEntryId = journalEntry.id
}
} catch (err) {
console.error('Failed to create payment journal entry:', err)
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
// Continue - we still want to update the invoice and transaction
}
// Update invoice to paid status
const { error: updateInvError } = await supabase
.from('invoices')
.update({
status: 'paid',
paid_at: now,
paid_amount: paidAmount,
})
.eq('id', invoice_id)
if (updateInvError) {
console.error('Failed to update invoice:', updateInvError)
return NextResponse.json(
{ error: 'Failed to update invoice status' },
{ status: 500 }
)
}
// Update transaction to link to invoice and clear potential match
const { error: updateTxError } = await supabase
.from('transactions')
.update({
invoice_id: invoice_id,
potential_invoice_id: null,
journal_entry_id: journalEntryId,
is_business: true,
category: 'income_services', // Default to services income
})
.eq('id', transactionId)
if (updateTxError) {
console.error('Failed to update transaction:', updateTxError)
return NextResponse.json(
{ error: 'Failed to link transaction to invoice' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
invoice_status: 'paid',
paid_at: now,
paid_amount: paidAmount,
journal_entry_id: journalEntryId,
journal_entry_error: journalEntryError,
})
}
@@ -0,0 +1,62 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getBestInvoiceMatch } from '@/lib/invoice/invoice-matching'
import type { Transaction } from '@/types'
/**
* POST /api/transactions/batch-match-invoices
* Run invoice matching for all uncategorized income transactions without potential_invoice_id
*/
export async function POST() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch uncategorized income transactions without a potential match
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('user_id', user.id)
.is('is_business', null)
.gt('amount', 0)
.is('potential_invoice_id', null)
.is('invoice_id', null)
.order('date', { ascending: false })
.limit(50)
if (txError || !transactions) {
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
}
let matched = 0
for (const tx of transactions) {
try {
const bestMatch = await getBestInvoiceMatch(
user.id,
tx as Transaction,
0.50
)
if (bestMatch) {
await supabase
.from('transactions')
.update({ potential_invoice_id: bestMatch.invoice.id })
.eq('id', tx.id)
matched++
}
} catch {
// Continue with other transactions
}
}
return NextResponse.json({
processed: transactions.length,
matched,
})
}
@@ -0,0 +1,76 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSuggestedCategories, type SuggestedCategory } from '@/lib/transactions/category-suggestions'
import type { Transaction, TransactionCategory } from '@/types'
/**
* POST /api/transactions/suggest-categories
* Batch endpoint for getting category suggestions for multiple transactions
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { transaction_ids } = await request.json()
if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) {
return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 })
}
// Limit batch size
const ids = transaction_ids.slice(0, 50)
// Fetch transactions
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('user_id', user.id)
.in('id', ids)
if (txError || !transactions) {
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
}
// Fetch user's mapping rules (once, for all transactions)
const { data: mappingRules } = await supabase
.from('mapping_rules')
.select('*')
.or(`user_id.eq.${user.id},user_id.is.null`)
.eq('is_active', true)
.order('priority', { ascending: false })
// Build category history from user's past categorized transactions
const { data: historicalTxns } = await supabase
.from('transactions')
.select('category')
.eq('user_id', user.id)
.not('is_business', 'is', null)
.neq('category', 'uncategorized')
.neq('category', 'private')
.limit(200)
const categoryHistory: Record<string, number> = {}
if (historicalTxns) {
for (const tx of historicalTxns) {
categoryHistory[tx.category] = (categoryHistory[tx.category] || 0) + 1
}
}
// Generate suggestions for each transaction
const suggestions: Record<string, SuggestedCategory[]> = {}
for (const tx of transactions) {
suggestions[tx.id] = getSuggestedCategories(
tx as Transaction,
mappingRules || [],
categoryHistory
)
}
return NextResponse.json({ suggestions })
}
+128
View File
@@ -0,0 +1,128 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* Validate EU VAT number using VIES (VAT Information Exchange System)
*
* The EU provides a SOAP-based API, but we'll use a REST wrapper
* In production, you might want to use the official SOAP API or a dedicated service
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { vat_number, customer_id } = await request.json()
if (!vat_number) {
return NextResponse.json({ error: 'VAT number is required' }, { status: 400 })
}
// Extract country code and number
const countryCode = vat_number.substring(0, 2).toUpperCase()
const vatNumber = vat_number.substring(2).replace(/\s/g, '')
try {
// Use the EU VIES validation API
// Note: In production, you should use the official SOAP API or a reliable service
const response = await fetch(
`https://ec.europa.eu/taxation_customs/vies/rest-api/ms/${countryCode}/vat/${vatNumber}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
},
}
)
if (!response.ok) {
// If VIES is unavailable, return a soft error
return NextResponse.json({
valid: false,
error: 'VAT validation service unavailable. Please try again later.',
})
}
const data = await response.json()
const isValid = data.isValid === true
// Update customer if customer_id provided
if (customer_id && isValid) {
await supabase
.from('customers')
.update({
vat_number: vat_number.toUpperCase(),
vat_number_validated: true,
vat_number_validated_at: new Date().toISOString(),
})
.eq('id', customer_id)
.eq('user_id', user.id)
}
return NextResponse.json({
valid: isValid,
name: data.name || null,
address: data.address || null,
country_code: countryCode,
vat_number: vat_number.toUpperCase(),
})
} catch (error) {
console.error('VAT validation error:', error)
// Fallback: basic format validation
const isValidFormat = validateVatNumberFormat(countryCode, vatNumber)
return NextResponse.json({
valid: false,
error: 'Could not verify VAT number. Service temporarily unavailable.',
format_valid: isValidFormat,
})
}
}
/**
* Basic VAT number format validation by country
*/
function validateVatNumberFormat(countryCode: string, vatNumber: string): boolean {
const patterns: Record<string, RegExp> = {
AT: /^U\d{8}$/,
BE: /^0\d{9}$/,
BG: /^\d{9,10}$/,
CY: /^\d{8}[A-Z]$/,
CZ: /^\d{8,10}$/,
DE: /^\d{9}$/,
DK: /^\d{8}$/,
EE: /^\d{9}$/,
EL: /^\d{9}$/, // Greece
ES: /^[A-Z0-9]\d{7}[A-Z0-9]$/,
FI: /^\d{8}$/,
FR: /^[A-Z0-9]{2}\d{9}$/,
HR: /^\d{11}$/,
HU: /^\d{8}$/,
IE: /^[0-9A-Z]{8,9}$/,
IT: /^\d{11}$/,
LT: /^\d{9,12}$/,
LU: /^\d{8}$/,
LV: /^\d{11}$/,
MT: /^\d{8}$/,
NL: /^\d{9}B\d{2}$/,
PL: /^\d{10}$/,
PT: /^\d{9}$/,
RO: /^\d{2,10}$/,
SE: /^\d{12}$/,
SI: /^\d{8}$/,
SK: /^\d{10}$/,
}
const pattern = patterns[countryCode]
if (!pattern) {
return false
}
return pattern.test(vatNumber)
}