feat: production readiness — 3-extension deploy with security hardening and observability
- Strip extensions to enable-banking, ai-categorization, ai-chat only - Remove push-notifications cron from vercel.json - Add security headers (HSTS, CSP, X-Frame-Options, Permissions-Policy) - Add /api/health endpoint for uptime monitoring - Add env var validation in ensureInitialized() - Fix SIE4 #IB opening balance records from year-end closing entry - Replace in-memory ai-chat rate limiter with Supabase-backed distributed rate limiting - Add Sentry error tracking scaffolding (@sentry/nextjs, instrumentation hook) - Add AI token usage tracking (migration 047, usage-tracker, wired into both AI extensions) - Include pending enable-banking and dashboard improvements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
1a1c6ba40f
commit
13725ffc16
@@ -140,22 +140,42 @@ export default async function DashboardPage() {
|
|||||||
(inv) => inv.status === 'overdue'
|
(inv) => inv.status === 'overdue'
|
||||||
).length
|
).length
|
||||||
|
|
||||||
// Fetch bank balance (if connected)
|
// Fetch bank balance and consent info (if connected)
|
||||||
const { data: bankConnections } = await supabase
|
const { data: bankConnections } = await supabase
|
||||||
.from('bank_connections')
|
.from('bank_connections')
|
||||||
.select('accounts, status')
|
.select('id, accounts_data, status, consent_expires, bank_name')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
.eq('status', 'active')
|
.eq('status', 'active')
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
let bankBalance: number | null = null
|
let bankBalance: number | null = null
|
||||||
if (bankConnections && bankConnections.length > 0) {
|
if (bankConnections && bankConnections.length > 0) {
|
||||||
const accounts = bankConnections[0].accounts as { balance: number }[] | null
|
const allBalances = bankConnections.flatMap(conn => {
|
||||||
if (accounts && accounts.length > 0) {
|
const accounts = conn.accounts_data as { balance: number }[] | null
|
||||||
bankBalance = accounts.reduce((sum, acc) => sum + (acc.balance || 0), 0)
|
return accounts || []
|
||||||
|
})
|
||||||
|
if (allBalances.length > 0) {
|
||||||
|
bankBalance = allBalances.reduce((sum, acc) => sum + (acc.balance || 0), 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute expiring bank connections (consent expires within 14 days)
|
||||||
|
const nowMs = new Date().getTime()
|
||||||
|
const expiringBankConnections = (bankConnections || [])
|
||||||
|
.filter(conn => {
|
||||||
|
if (!conn.consent_expires) return false
|
||||||
|
const daysLeft = Math.ceil(
|
||||||
|
(new Date(conn.consent_expires).getTime() - nowMs) / (1000 * 60 * 60 * 24)
|
||||||
|
)
|
||||||
|
return daysLeft > 0 && daysLeft <= 14
|
||||||
|
})
|
||||||
|
.map(conn => ({
|
||||||
|
id: conn.id as string,
|
||||||
|
bank_name: conn.bank_name as string,
|
||||||
|
days_left: Math.ceil(
|
||||||
|
(new Date(conn.consent_expires!).getTime() - nowMs) / (1000 * 60 * 60 * 24)
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
// Fetch upcoming deadlines (next 7 days + overdue)
|
// Fetch upcoming deadlines (next 7 days + overdue)
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
const nextWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
const nextWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||||
@@ -274,6 +294,7 @@ export default async function DashboardPage() {
|
|||||||
unpaidVatTotal,
|
unpaidVatTotal,
|
||||||
overdueInvoicesCount: overdueCount,
|
overdueInvoicesCount: overdueCount,
|
||||||
bankBalance,
|
bankBalance,
|
||||||
|
expiringBankConnections,
|
||||||
deadlines: (deadlines || []) as Deadline[],
|
deadlines: (deadlines || []) as Deadline[],
|
||||||
receiptQueue,
|
receiptQueue,
|
||||||
missingUnderlagCount,
|
missingUnderlagCount,
|
||||||
|
|||||||
@@ -63,9 +63,30 @@ export default function SettingsPage() {
|
|||||||
if (bankConnected === 'true') {
|
if (bankConnected === 'true') {
|
||||||
toast({
|
toast({
|
||||||
title: 'Bank ansluten!',
|
title: 'Bank ansluten!',
|
||||||
description: 'Din bank är nu kopplad och transaktioner kan hämtas.',
|
description: 'Din bank är nu kopplad. Transaktioner hämtas...',
|
||||||
})
|
})
|
||||||
router.replace('/settings')
|
|
||||||
|
// Auto-sync transactions after connection
|
||||||
|
const connectionId = searchParams.get('connection_id')
|
||||||
|
if (connectionId) {
|
||||||
|
fetch('/api/extensions/ext/enable-banking/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ connection_id: connectionId, days_back: 90 }),
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.imported > 0) {
|
||||||
|
toast({
|
||||||
|
title: 'Transaktioner hämtade',
|
||||||
|
description: `${data.imported} transaktioner importerade`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace('/settings?tab=banking')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bankError) {
|
if (bankError) {
|
||||||
|
|||||||
@@ -72,9 +72,11 @@ export async function GET(request: Request) {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
|
let connectionId: string
|
||||||
|
|
||||||
if (findError || !pendingConnection) {
|
if (findError || !pendingConnection) {
|
||||||
console.error('Could not find pending connection:', findError)
|
console.error('Could not find pending connection:', findError)
|
||||||
const { error: insertError } = await supabase
|
const { data: inserted, error: insertError } = await supabase
|
||||||
.from('bank_connections')
|
.from('bank_connections')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: state,
|
user_id: state,
|
||||||
@@ -86,11 +88,14 @@ export async function GET(request: Request) {
|
|||||||
consent_expires: consentExpiresAt,
|
consent_expires: consentExpiresAt,
|
||||||
last_synced_at: new Date().toISOString(),
|
last_synced_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
.select('id')
|
||||||
|
.single()
|
||||||
|
|
||||||
if (insertError) {
|
if (insertError || !inserted) {
|
||||||
console.error('Insert error:', insertError)
|
console.error('Insert error:', insertError)
|
||||||
throw new Error('Failed to create connection')
|
throw new Error('Failed to create connection')
|
||||||
}
|
}
|
||||||
|
connectionId = inserted.id
|
||||||
} else {
|
} else {
|
||||||
const { error: updateError } = await supabase
|
const { error: updateError } = await supabase
|
||||||
.from('bank_connections')
|
.from('bank_connections')
|
||||||
@@ -106,6 +111,7 @@ export async function GET(request: Request) {
|
|||||||
if (updateError) {
|
if (updateError) {
|
||||||
throw new Error('Failed to update connection')
|
throw new Error('Failed to update connection')
|
||||||
}
|
}
|
||||||
|
connectionId = pendingConnection.id
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: userSettings } = await supabase
|
const { data: userSettings } = await supabase
|
||||||
@@ -115,8 +121,8 @@ export async function GET(request: Request) {
|
|||||||
.single()
|
.single()
|
||||||
|
|
||||||
const redirectTarget = userSettings?.onboarding_complete
|
const redirectTarget = userSettings?.onboarding_complete
|
||||||
? '/settings?bank_connected=true'
|
? `/settings?bank_connected=true&connection_id=${connectionId}`
|
||||||
: '/onboarding?bank_connected=true'
|
: `/onboarding?bank_connected=true&connection_id=${connectionId}`
|
||||||
|
|
||||||
return NextResponse.redirect(`${baseUrl}${redirectTarget}`)
|
return NextResponse.redirect(`${baseUrl}${redirectTarget}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -37,12 +37,25 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||||
|
|
||||||
|
// Clean up stale pending connections (older than 1 hour)
|
||||||
|
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString()
|
||||||
|
const { data: stalePending } = await supabase
|
||||||
|
.from('bank_connections')
|
||||||
|
.delete()
|
||||||
|
.eq('status', 'pending')
|
||||||
|
.lt('created_at', oneHourAgo)
|
||||||
|
.select('id')
|
||||||
|
|
||||||
|
if (stalePending?.length) {
|
||||||
|
console.log(`[bank-sync-cron] Cleaned up ${stalePending.length} stale pending connections`)
|
||||||
|
}
|
||||||
|
|
||||||
const { data: connections, error: connError } = await supabase
|
const { data: connections, error: connError } = await supabase
|
||||||
.from('bank_connections')
|
.from('bank_connections')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('status', 'active')
|
.eq('status', 'active')
|
||||||
.order('last_synced_at', { ascending: true, nullsFirst: true })
|
.order('last_synced_at', { ascending: true, nullsFirst: true })
|
||||||
.limit(10)
|
.limit(50)
|
||||||
|
|
||||||
if (connError) {
|
if (connError) {
|
||||||
console.error('Failed to fetch bank connections:', connError)
|
console.error('Failed to fetch bank connections:', connError)
|
||||||
@@ -53,6 +66,9 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ message: 'No active connections to sync', processed: 0 })
|
return NextResponse.json({ message: 'No active connections to sync', processed: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
const TIME_BUDGET_MS = 50_000 // 50s — leave 10s margin for Vercel timeout
|
||||||
|
|
||||||
const results: {
|
const results: {
|
||||||
connectionId: string
|
connectionId: string
|
||||||
userId: string
|
userId: string
|
||||||
@@ -65,6 +81,11 @@ export async function GET(request: Request) {
|
|||||||
}[] = []
|
}[] = []
|
||||||
|
|
||||||
for (const connection of connections) {
|
for (const connection of connections) {
|
||||||
|
if (Date.now() - startTime > TIME_BUDGET_MS) {
|
||||||
|
console.log(`[bank-sync-cron] Time budget reached after ${results.length} connections`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const daysLeft = getDaysUntilExpiry(connection.consent_expires)
|
const daysLeft = getDaysUntilExpiry(connection.consent_expires)
|
||||||
const isExpired = daysLeft !== null && daysLeft <= 0
|
const isExpired = daysLeft !== null && daysLeft <= 0
|
||||||
@@ -97,24 +118,20 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
|
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
|
||||||
|
|
||||||
let totalImported = 0
|
const syncResults = await Promise.all(
|
||||||
let totalDuplicates = 0
|
accounts.map(account => syncAccountTransactions(
|
||||||
let totalErrors = 0
|
|
||||||
|
|
||||||
for (const account of accounts) {
|
|
||||||
const result = await syncAccountTransactions(
|
|
||||||
supabase,
|
supabase,
|
||||||
connection.user_id,
|
connection.user_id,
|
||||||
connection.id,
|
connection.id,
|
||||||
account,
|
account,
|
||||||
fromDate,
|
fromDate,
|
||||||
toDate
|
toDate
|
||||||
)
|
))
|
||||||
|
)
|
||||||
|
|
||||||
totalImported += result.imported
|
const totalImported = syncResults.reduce((sum, r) => sum + r.imported, 0)
|
||||||
totalDuplicates += result.duplicates
|
const totalDuplicates = syncResults.reduce((sum, r) => sum + r.duplicates, 0)
|
||||||
totalErrors += result.errors
|
const totalErrors = syncResults.reduce((sum, r) => sum + r.errors, 0)
|
||||||
}
|
|
||||||
|
|
||||||
await supabase
|
await supabase
|
||||||
.from('bank_connections')
|
.from('bank_connections')
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/health
|
||||||
|
* Public health check endpoint (no auth required).
|
||||||
|
* Returns DB connectivity status for uptime monitoring.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||||
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseServiceKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0', error: 'Missing configuration' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('fiscal_periods')
|
||||||
|
.select('id', { count: 'exact', head: true })
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0', error: error.message },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
status: 'healthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
version: '1.0.0',
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0', error: err instanceof Error ? err.message : 'Unknown error' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ interface DashboardContentProps {
|
|||||||
unpaidVatTotal: number
|
unpaidVatTotal: number
|
||||||
overdueInvoicesCount: number
|
overdueInvoicesCount: number
|
||||||
bankBalance: number | null
|
bankBalance: number | null
|
||||||
|
expiringBankConnections?: { id: string; bank_name: string; days_left: number }[]
|
||||||
deadlines: Deadline[]
|
deadlines: Deadline[]
|
||||||
receiptQueue: ReceiptQueueSummary | null
|
receiptQueue: ReceiptQueueSummary | null
|
||||||
missingUnderlagCount: number
|
missingUnderlagCount: number
|
||||||
@@ -224,6 +225,30 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (summary.expiringBankConnections && summary.expiringBankConnections.length > 0) {
|
||||||
|
const conn = summary.expiringBankConnections[0]
|
||||||
|
alertItems.push(
|
||||||
|
<Link key="bank-expiry" href="/settings?tab=banking" className="group">
|
||||||
|
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Landmark className="h-4 w-4 text-warning-foreground flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Banksamtycke löper ut</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{conn.bank_name} — {conn.days_left} {conn.days_left === 1 ? 'dag' : 'dagar'} kvar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_VISIBLE_ALERTS = 3
|
const MAX_VISIBLE_ALERTS = 3
|
||||||
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
|
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
|
||||||
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
|
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"$schema":"./extensions.schema.json","extensions":["receipt-ocr","ai-categorization","ai-chat","push-notifications","invoice-inbox","calendar","enable-banking","email"]}
|
{"$schema":"./extensions.schema.json","extensions":["enable-banking","ai-categorization","ai-chat"]}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'server-only'
|
|||||||
import Anthropic from '@anthropic-ai/sdk'
|
import Anthropic from '@anthropic-ai/sdk'
|
||||||
import { BOOKING_TEMPLATES, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
import { BOOKING_TEMPLATES, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||||
import type { TransactionCategory, EntityType } from '@/types'
|
import type { TransactionCategory, EntityType } from '@/types'
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -72,10 +73,16 @@ export interface CategorizationSuggestion {
|
|||||||
templateId?: string
|
templateId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TrackingContext {
|
||||||
|
supabase: SupabaseClient
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface CategorizationProvider {
|
export interface CategorizationProvider {
|
||||||
categorize(
|
categorize(
|
||||||
transactions: TransactionForCategorization[],
|
transactions: TransactionForCategorization[],
|
||||||
context: CategorizationContext | EnrichedCategorizationContext
|
context: CategorizationContext | EnrichedCategorizationContext,
|
||||||
|
tracking?: TrackingContext
|
||||||
): Promise<CategorizationSuggestion[]>
|
): Promise<CategorizationSuggestion[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +205,8 @@ export class AnthropicCategorizationProvider implements CategorizationProvider {
|
|||||||
|
|
||||||
async categorize(
|
async categorize(
|
||||||
transactions: TransactionForCategorization[],
|
transactions: TransactionForCategorization[],
|
||||||
context: CategorizationContext | EnrichedCategorizationContext
|
context: CategorizationContext | EnrichedCategorizationContext,
|
||||||
|
tracking?: TrackingContext
|
||||||
): Promise<CategorizationSuggestion[]> {
|
): Promise<CategorizationSuggestion[]> {
|
||||||
// Cap batch size
|
// Cap batch size
|
||||||
const batch = transactions.slice(0, MAX_BATCH_SIZE)
|
const batch = transactions.slice(0, MAX_BATCH_SIZE)
|
||||||
@@ -318,6 +326,16 @@ ${transactionList}`
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Track token usage (fire-and-forget)
|
||||||
|
if (tracking && message.usage) {
|
||||||
|
const { trackTokenUsage } = await import('@/lib/ai/usage-tracker')
|
||||||
|
trackTokenUsage(tracking.supabase, tracking.userId, 'ai-categorization', {
|
||||||
|
inputTokens: message.usage.input_tokens,
|
||||||
|
outputTokens: message.usage.output_tokens,
|
||||||
|
model: this.model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Extract tool_use block from response
|
// Extract tool_use block from response
|
||||||
const toolUseBlock = message.content.find(
|
const toolUseBlock = message.content.find(
|
||||||
(block) => block.type === 'tool_use' && block.name === 'classify_transactions'
|
(block) => block.type === 'tool_use' && block.name === 'classify_transactions'
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type CategorizationSuggestion,
|
type CategorizationSuggestion,
|
||||||
type AccountUsageEntry,
|
type AccountUsageEntry,
|
||||||
type MerchantHistoryEntry,
|
type MerchantHistoryEntry,
|
||||||
|
type TrackingContext,
|
||||||
} from './categorizer'
|
} from './categorizer'
|
||||||
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||||
|
|
||||||
@@ -124,7 +125,7 @@ export async function categorizeTransactions(
|
|||||||
const context = await buildEnrichedContext(userId, supabase, batch)
|
const context = await buildEnrichedContext(userId, supabase, batch)
|
||||||
|
|
||||||
const aiProvider = getProvider(settings.providerModel)
|
const aiProvider = getProvider(settings.providerModel)
|
||||||
const suggestions = await aiProvider.categorize(batch, context)
|
const suggestions = await aiProvider.categorize(batch, context, { supabase, userId })
|
||||||
|
|
||||||
// Store suggestions
|
// Store suggestions
|
||||||
await storeSuggestions(userId, suggestions, supabase)
|
await storeSuggestions(userId, suggestions, supabase)
|
||||||
@@ -174,7 +175,7 @@ async function handleTransactionSynced(
|
|||||||
|
|
||||||
const context = await buildEnrichedContext(userId, supabase, batch)
|
const context = await buildEnrichedContext(userId, supabase, batch)
|
||||||
const aiProvider = getProvider(settings.providerModel)
|
const aiProvider = getProvider(settings.providerModel)
|
||||||
const suggestions = await aiProvider.categorize(batch, context)
|
const suggestions = await aiProvider.categorize(batch, context, { supabase, userId })
|
||||||
|
|
||||||
// Store only suggestions above confidence threshold
|
// Store only suggestions above confidence threshold
|
||||||
const qualifiedSuggestions = suggestions.filter(
|
const qualifiedSuggestions = suggestions.filter(
|
||||||
|
|||||||
@@ -4,26 +4,56 @@ import { generateChatResponse, streamChatResponse, streamRoutedResponse } from '
|
|||||||
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
|
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
|
||||||
import type { ChatMessage, ChatRequest, SourceReference, ArtifactSpec } from '@/types/chat'
|
import type { ChatMessage, ChatRequest, SourceReference, ArtifactSpec } from '@/types/chat'
|
||||||
|
|
||||||
// Simple in-memory rate limiting (per user)
|
// Distributed rate limiting backed by Supabase extension_data table
|
||||||
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
function checkRateLimit(userId: string): boolean {
|
interface RateLimitState {
|
||||||
|
count: number
|
||||||
|
window_start: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRateLimitDB(supabase: SupabaseClient, userId: string): Promise<boolean> {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const limit = rateLimitMap.get(userId)
|
const WINDOW_MS = 60000
|
||||||
|
|
||||||
if (!limit || now > limit.resetTime) {
|
const { data } = await supabase
|
||||||
rateLimitMap.set(userId, {
|
.from('extension_data')
|
||||||
count: 1,
|
.select('value')
|
||||||
resetTime: now + 60000, // 1 minute window
|
.eq('user_id', userId)
|
||||||
})
|
.eq('extension_id', 'ai-chat')
|
||||||
|
.eq('key', 'rate_limit')
|
||||||
|
.single()
|
||||||
|
|
||||||
|
const state = data?.value as RateLimitState | null
|
||||||
|
|
||||||
|
if (!state || now - state.window_start > WINDOW_MS) {
|
||||||
|
// New window
|
||||||
|
await supabase.from('extension_data').upsert(
|
||||||
|
{
|
||||||
|
user_id: userId,
|
||||||
|
extension_id: 'ai-chat',
|
||||||
|
key: 'rate_limit',
|
||||||
|
value: { count: 1, window_start: now },
|
||||||
|
},
|
||||||
|
{ onConflict: 'user_id,extension_id,key' }
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (limit.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
|
if (state.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
limit.count++
|
// Increment count
|
||||||
|
await supabase.from('extension_data').upsert(
|
||||||
|
{
|
||||||
|
user_id: userId,
|
||||||
|
extension_id: 'ai-chat',
|
||||||
|
key: 'rate_limit',
|
||||||
|
value: { count: state.count + 1, window_start: state.window_start },
|
||||||
|
},
|
||||||
|
{ onConflict: 'user_id,extension_id,key' }
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +70,7 @@ async function handlePostChat(
|
|||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
if (!checkRateLimit(userId)) {
|
if (!await checkRateLimitDB(supabase, userId)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Rate limit exceeded. Please wait a moment.' },
|
{ error: 'Rate limit exceeded. Please wait a moment.' },
|
||||||
{ status: 429 }
|
{ status: 429 }
|
||||||
@@ -129,7 +159,7 @@ async function handlePostChat(
|
|||||||
const conversationHistory = (history || []) as ChatMessage[]
|
const conversationHistory = (history || []) as ChatMessage[]
|
||||||
|
|
||||||
// Generate AI response
|
// Generate AI response
|
||||||
const result = await generateChatResponse(message.trim(), conversationHistory)
|
const result = await generateChatResponse(message.trim(), conversationHistory, { supabase, userId })
|
||||||
|
|
||||||
// Save assistant message
|
// Save assistant message
|
||||||
const { data: assistantMessage, error: assistantMsgError } = await supabase
|
const { data: assistantMessage, error: assistantMsgError } = await supabase
|
||||||
@@ -177,7 +207,7 @@ async function handlePostStream(
|
|||||||
const { createClient } = await import('@/lib/supabase/server')
|
const { createClient } = await import('@/lib/supabase/server')
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
|
|
||||||
if (!checkRateLimit(userId)) {
|
if (!await checkRateLimitDB(supabase, userId)) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
JSON.stringify({ error: 'Rate limit exceeded' }),
|
||||||
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { streamAgentResponse, type ToolResultEntry } from './agent'
|
|||||||
import { generateArtifact, type ArtifactSpec } from './artifacts'
|
import { generateArtifact, type ArtifactSpec } from './artifacts'
|
||||||
import type { ChatMessage, SourceReference } from '@/types/chat'
|
import type { ChatMessage, SourceReference } from '@/types/chat'
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
import { trackTokenUsage } from '@/lib/ai/usage-tracker'
|
||||||
|
|
||||||
// Initialize the LLM
|
// Initialize the LLM
|
||||||
function getChatModel() {
|
function getChatModel() {
|
||||||
@@ -35,7 +36,8 @@ export interface ChatResult {
|
|||||||
|
|
||||||
export async function generateChatResponse(
|
export async function generateChatResponse(
|
||||||
userMessage: string,
|
userMessage: string,
|
||||||
conversationHistory: ChatMessage[]
|
conversationHistory: ChatMessage[],
|
||||||
|
tracking?: { supabase: SupabaseClient; userId: string }
|
||||||
): Promise<ChatResult> {
|
): Promise<ChatResult> {
|
||||||
// 1. Retrieve relevant documents
|
// 1. Retrieve relevant documents
|
||||||
const relevantDocs = await retrieveRelevantDocuments(userMessage)
|
const relevantDocs = await retrieveRelevantDocuments(userMessage)
|
||||||
@@ -74,7 +76,16 @@ export async function generateChatResponse(
|
|||||||
new HumanMessage(userMessage),
|
new HumanMessage(userMessage),
|
||||||
])
|
])
|
||||||
|
|
||||||
// 6. Extract content and sources
|
// 6. Track token usage
|
||||||
|
if (tracking && response.usage_metadata) {
|
||||||
|
trackTokenUsage(tracking.supabase, tracking.userId, 'ai-chat', {
|
||||||
|
inputTokens: response.usage_metadata.input_tokens ?? 0,
|
||||||
|
outputTokens: response.usage_metadata.output_tokens ?? 0,
|
||||||
|
model: CHATBOT_CONFIG.model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Extract content and sources
|
||||||
const content =
|
const content =
|
||||||
typeof response.content === 'string'
|
typeof response.content === 'string'
|
||||||
? response.content
|
? response.content
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { formatDate } from '@/lib/utils'
|
import { formatDate } from '@/lib/utils'
|
||||||
import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client'
|
import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client'
|
||||||
@@ -67,8 +68,19 @@ export function BankConnectionStatus({
|
|||||||
name?: string
|
name?: string
|
||||||
currency: string
|
currency: string
|
||||||
balance?: number
|
balance?: number
|
||||||
|
balance_updated_at?: string
|
||||||
}>) || []
|
}>) || []
|
||||||
|
|
||||||
|
const [now] = useState(() => Date.now())
|
||||||
|
|
||||||
|
function formatBalanceAge(updatedAt: string): string {
|
||||||
|
const hoursAgo = Math.floor((now - new Date(updatedAt).getTime()) / (1000 * 60 * 60))
|
||||||
|
if (hoursAgo < 1) return 'Nyss uppdaterat'
|
||||||
|
if (hoursAgo < 24) return `${hoursAgo}h sedan`
|
||||||
|
const daysAgo = Math.floor(hoursAgo / 24)
|
||||||
|
return `${daysAgo}d sedan`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg p-4 space-y-4">
|
<div className="border rounded-lg p-4 space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -155,6 +167,11 @@ export function BankConnectionStatus({
|
|||||||
currency: account.currency,
|
currency: account.currency,
|
||||||
}).format(account.balance)}
|
}).format(account.balance)}
|
||||||
</p>
|
</p>
|
||||||
|
{account.balance_updated_at && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{formatBalanceAge(account.balance_updated_at)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
|
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||||
import { Loader2, Landmark } from 'lucide-react'
|
import { Loader2, Landmark } from 'lucide-react'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
import { createClient } from '@/lib/supabase/client'
|
||||||
import { BankSelector, type Bank } from './BankSelector'
|
import { BankSelector, type Bank } from './BankSelector'
|
||||||
@@ -18,8 +19,10 @@ export default function BankingSettingsPanel() {
|
|||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
|
|
||||||
|
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||||
|
|
||||||
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
|
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
|
||||||
const [isSyncing, setIsSyncing] = useState(false)
|
const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null)
|
||||||
const [isConnecting, setIsConnecting] = useState(false)
|
const [isConnecting, setIsConnecting] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [selectedBank, setSelectedBank] = useState<Bank | null>(null)
|
const [selectedBank, setSelectedBank] = useState<Bank | null>(null)
|
||||||
@@ -71,7 +74,7 @@ export default function BankingSettingsPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSyncTransactions(connectionId: string) {
|
async function handleSyncTransactions(connectionId: string) {
|
||||||
setIsSyncing(true)
|
setSyncingConnectionId(connectionId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/extensions/ext/enable-banking/sync', {
|
const response = await fetch('/api/extensions/ext/enable-banking/sync', {
|
||||||
@@ -100,27 +103,41 @@ export default function BankingSettingsPanel() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSyncing(false)
|
setSyncingConnectionId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDisconnectBank(connectionId: string) {
|
async function handleDisconnectBank(connectionId: string) {
|
||||||
const { error } = await supabase
|
const ok = await confirm({
|
||||||
.from('bank_connections')
|
title: 'Koppla bort bank?',
|
||||||
.update({ status: 'revoked' })
|
description: 'PSD2-samtycket kommer återkallas. Befintliga transaktioner påverkas inte.',
|
||||||
.eq('id', connectionId)
|
confirmLabel: 'Koppla bort',
|
||||||
|
variant: 'warning',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
if (error) {
|
try {
|
||||||
toast({
|
const response = await fetch('/api/extensions/ext/enable-banking/disconnect', {
|
||||||
title: 'Fel',
|
method: 'DELETE',
|
||||||
description: 'Kunde inte koppla bort bank',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
variant: 'destructive',
|
body: JSON.stringify({ connection_id: connectionId }),
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
throw new Error(data.error || 'Disconnect failed')
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Bank bortkopplad',
|
title: 'Bank bortkopplad',
|
||||||
description: 'Bankanslutningen har tagits bort',
|
description: 'Bankanslutningen och PSD2-samtycket har återkallats',
|
||||||
})
|
})
|
||||||
fetchConnections()
|
fetchConnections()
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Fel',
|
||||||
|
description: error instanceof Error ? error.message : 'Kunde inte koppla bort bank',
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +153,8 @@ export default function BankingSettingsPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<DestructiveConfirmDialog {...dialogProps} />
|
||||||
|
|
||||||
{/* Connected banks */}
|
{/* Connected banks */}
|
||||||
{activeConnections.length > 0 && (
|
{activeConnections.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -149,7 +168,7 @@ export default function BankingSettingsPanel() {
|
|||||||
connection={connection}
|
connection={connection}
|
||||||
onSync={handleSyncTransactions}
|
onSync={handleSyncTransactions}
|
||||||
onDisconnect={handleDisconnectBank}
|
onDisconnect={handleDisconnectBank}
|
||||||
isSyncing={isSyncing}
|
isSyncing={syncingConnectionId === connection.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
|||||||
import {
|
import {
|
||||||
startAuthorization,
|
startAuthorization,
|
||||||
getASPSPs,
|
getASPSPs,
|
||||||
|
deleteSession,
|
||||||
type ASPSP,
|
type ASPSP,
|
||||||
} from './lib/api-client'
|
} from './lib/api-client'
|
||||||
import { syncAccountTransactions } from './lib/sync'
|
import { syncAccountTransactions } from './lib/sync'
|
||||||
@@ -80,6 +81,15 @@ export const enableBankingExtension: Extension = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Determine PSU type from entity type
|
||||||
|
const { data: companySettings } = await supabase
|
||||||
|
.from('company_settings')
|
||||||
|
.select('entity_type')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
const psuType = companySettings?.entity_type === 'aktiebolag' ? 'business' : 'personal'
|
||||||
|
|
||||||
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/extensions/enable-banking/callback`
|
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/extensions/enable-banking/callback`
|
||||||
|
|
||||||
const { url, authorization_id } = await startAuthorization(
|
const { url, authorization_id } = await startAuthorization(
|
||||||
@@ -87,7 +97,7 @@ export const enableBankingExtension: Extension = {
|
|||||||
aspsp_country,
|
aspsp_country,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
user.id,
|
user.id,
|
||||||
'personal'
|
psuType
|
||||||
)
|
)
|
||||||
|
|
||||||
const { data: connection, error } = await supabase
|
const { data: connection, error } = await supabase
|
||||||
@@ -160,11 +170,8 @@ export const enableBankingExtension: Extension = {
|
|||||||
// Use ctx.services.ingestTransactions when available
|
// Use ctx.services.ingestTransactions when available
|
||||||
const ingestFn = ctx?.services.ingestTransactions
|
const ingestFn = ctx?.services.ingestTransactions
|
||||||
|
|
||||||
let totalImported = 0
|
const results = await Promise.all(
|
||||||
let totalDuplicates = 0
|
accounts.map(account => syncAccountTransactions(
|
||||||
|
|
||||||
for (const account of accounts) {
|
|
||||||
const result = await syncAccountTransactions(
|
|
||||||
supabase,
|
supabase,
|
||||||
user.id,
|
user.id,
|
||||||
connection.id,
|
connection.id,
|
||||||
@@ -172,11 +179,11 @@ export const enableBankingExtension: Extension = {
|
|||||||
fromDate,
|
fromDate,
|
||||||
toDate,
|
toDate,
|
||||||
ingestFn
|
ingestFn
|
||||||
)
|
))
|
||||||
|
)
|
||||||
|
|
||||||
totalImported += result.imported
|
const totalImported = results.reduce((sum, r) => sum + r.imported, 0)
|
||||||
totalDuplicates += result.duplicates
|
const totalDuplicates = results.reduce((sum, r) => sum + r.duplicates, 0)
|
||||||
}
|
|
||||||
|
|
||||||
const syncedAt = new Date().toISOString()
|
const syncedAt = new Date().toISOString()
|
||||||
await supabase
|
await supabase
|
||||||
@@ -220,6 +227,57 @@ export const enableBankingExtension: Extension = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/disconnect',
|
||||||
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||||
|
const log = ctx?.log ?? console
|
||||||
|
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { connection_id } = await request.json()
|
||||||
|
|
||||||
|
if (!connection_id) {
|
||||||
|
return NextResponse.json({ error: 'connection_id is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: connection, error: findError } = await supabase
|
||||||
|
.from('bank_connections')
|
||||||
|
.select('id, session_id, status')
|
||||||
|
.eq('id', connection_id)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (findError || !connection) {
|
||||||
|
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke PSD2 consent if session exists
|
||||||
|
if (connection.session_id) {
|
||||||
|
try {
|
||||||
|
await deleteSession(connection.session_id)
|
||||||
|
} catch (error) {
|
||||||
|
// Consent may already be expired — log and continue
|
||||||
|
log.error('Failed to revoke PSD2 session (may be expired):', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('bank_connections')
|
||||||
|
.update({ status: 'revoked', session_id: null })
|
||||||
|
.eq('id', connection.id)
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
return NextResponse.json({ error: 'Failed to disconnect' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
eventHandlers: [],
|
eventHandlers: [],
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ export async function syncAccountTransactions(
|
|||||||
try {
|
try {
|
||||||
const balance = await getAccountBalance(account.uid)
|
const balance = await getAccountBalance(account.uid)
|
||||||
account.balance = balance.amount
|
account.balance = balance.amount
|
||||||
|
account.balance_updated_at = new Date().toISOString()
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore balance fetch errors
|
// Keep previous balance, don't update timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface StoredAccount {
|
|||||||
name?: string
|
name?: string
|
||||||
currency: string
|
currency: string
|
||||||
balance?: number
|
balance?: number
|
||||||
|
balance_updated_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export API types from the client
|
// Re-export API types from the client
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
await import("./sentry.server.config");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NEXT_RUNTIME === "edge") {
|
||||||
|
await import("./sentry.edge.config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onRequestError = Sentry.captureRequestError;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
import { createLogger } from '@/lib/logger'
|
||||||
|
|
||||||
|
const log = createLogger('ai-usage')
|
||||||
|
|
||||||
|
interface TokenUsage {
|
||||||
|
inputTokens: number
|
||||||
|
outputTokens: number
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track AI token usage. Non-blocking — errors are logged, not thrown.
|
||||||
|
*/
|
||||||
|
export function trackTokenUsage(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
userId: string,
|
||||||
|
extensionId: string,
|
||||||
|
usage: TokenUsage
|
||||||
|
): void {
|
||||||
|
supabase
|
||||||
|
.from('ai_usage_tracking')
|
||||||
|
.insert({
|
||||||
|
user_id: userId,
|
||||||
|
extension_id: extensionId,
|
||||||
|
model: usage.model,
|
||||||
|
input_tokens: usage.inputTokens,
|
||||||
|
output_tokens: usage.outputTokens,
|
||||||
|
})
|
||||||
|
.then(({ error }) => {
|
||||||
|
if (error) {
|
||||||
|
log.error(`Failed to track usage for ${extensionId}:`, error.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
|
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
|
||||||
|
|
||||||
export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
|
export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
|
||||||
'receipt-ocr',
|
'enable-banking',
|
||||||
'ai-categorization',
|
'ai-categorization',
|
||||||
'ai-chat',
|
'ai-chat',
|
||||||
'push-notifications',
|
|
||||||
'invoice-inbox',
|
|
||||||
'calendar',
|
|
||||||
'enable-banking',
|
|
||||||
'email',
|
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,21 +1,11 @@
|
|||||||
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
|
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
|
||||||
import type { Extension } from '../types'
|
import type { Extension } from '../types'
|
||||||
import { receiptOcrExtension } from '@/extensions/general/receipt-ocr'
|
import { enableBankingExtension } from '@/extensions/general/enable-banking'
|
||||||
import { aiCategorizationExtension } from '@/extensions/general/ai-categorization'
|
import { aiCategorizationExtension } from '@/extensions/general/ai-categorization'
|
||||||
import { aiChatExtension } from '@/extensions/general/ai-chat'
|
import { aiChatExtension } from '@/extensions/general/ai-chat'
|
||||||
import { pushNotificationsExtension } from '@/extensions/general/push-notifications'
|
|
||||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
|
||||||
import { calendarExtension } from '@/extensions/general/calendar'
|
|
||||||
import { enableBankingExtension } from '@/extensions/general/enable-banking'
|
|
||||||
import { emailExtension } from '@/extensions/general/email'
|
|
||||||
|
|
||||||
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||||
receiptOcrExtension,
|
enableBankingExtension,
|
||||||
aiCategorizationExtension,
|
aiCategorizationExtension,
|
||||||
aiChatExtension,
|
aiChatExtension,
|
||||||
pushNotificationsExtension,
|
|
||||||
invoiceInboxExtension,
|
|
||||||
calendarExtension,
|
|
||||||
enableBankingExtension,
|
|
||||||
emailExtension,
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,21 +4,16 @@ import type { ExtensionDefinition } from '../types'
|
|||||||
export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||||
'general': [
|
'general': [
|
||||||
{
|
{
|
||||||
"slug": "receipt-ocr",
|
"slug": "enable-banking",
|
||||||
"name": "Kvittoscanning",
|
"name": "Bankintegration (PSD2)",
|
||||||
"sector": "general",
|
"sector": "general",
|
||||||
"category": "import",
|
"category": "import",
|
||||||
"icon": "Camera",
|
"icon": "Landmark",
|
||||||
"dataPattern": "manual",
|
"dataPattern": "manual",
|
||||||
"description": "Skanna kvitton och extrahera data automatiskt",
|
"description": "Automatisk banktransaktionssynk via PSD2",
|
||||||
"longDescription": "Ladda upp kvittofoton och låt systemet automatiskt extrahera leverantör, belopp, moms och datum. Sparar tid och minskar manuell inmatning.",
|
"longDescription": "Koppla ditt bankkonto direkt och synka transaktioner automatiskt via säker PSD2-bankintegration. Stöder de flesta svenska banker.",
|
||||||
"hasOwnData": true,
|
"hasOwnData": true,
|
||||||
"quickAction": {
|
"subscriptionNotice": "Denna integration kräver ett aktivt Enable Banking-abonnemang. Utan abonnemang kommer bankintegration inte att fungera."
|
||||||
"label": "Skanna kvitto",
|
|
||||||
"description": "Fotografera & spara",
|
|
||||||
"icon": "Camera",
|
|
||||||
"href": "/receipts/scan"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slug": "ai-categorization",
|
"slug": "ai-categorization",
|
||||||
@@ -62,79 +57,5 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
|||||||
"event": "open-ai-chat"
|
"event": "open-ai-chat"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"slug": "push-notifications",
|
|
||||||
"name": "Push-notiser",
|
|
||||||
"sector": "general",
|
|
||||||
"category": "operations",
|
|
||||||
"icon": "Bell",
|
|
||||||
"dataPattern": "core",
|
|
||||||
"description": "Händelsenotiser för bokföringsaktiviteter",
|
|
||||||
"longDescription": "Få push-notiser direkt i webbläsaren när viktiga händelser sker — nya fakturor, förfallna betalningar, slutförda bokföringar med mera.",
|
|
||||||
"readsCoreTables": [
|
|
||||||
"journal_entries",
|
|
||||||
"invoices",
|
|
||||||
"receipts"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"slug": "invoice-inbox",
|
|
||||||
"name": "Dokumentinkorg",
|
|
||||||
"sector": "general",
|
|
||||||
"category": "import",
|
|
||||||
"icon": "Inbox",
|
|
||||||
"dataPattern": "manual",
|
|
||||||
"description": "Ta emot alla dokument via e-post — fakturor, kvitton och myndighetspost",
|
|
||||||
"longDescription": "Skicka alla affärsdokument till en dedikerad e-postadress. AI klassificerar automatiskt dokumenttyp (faktura, kvitto, myndighetspost), extraherar data och matchar mot transaktioner. En inkorg för alla dokument.",
|
|
||||||
"hasOwnData": true,
|
|
||||||
"quickAction": {
|
|
||||||
"label": "Dokumentinkorg",
|
|
||||||
"description": "Granska inkommande dokument",
|
|
||||||
"icon": "Inbox",
|
|
||||||
"href": "/e/general/invoice-inbox"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"slug": "calendar",
|
|
||||||
"name": "Kalender",
|
|
||||||
"sector": "general",
|
|
||||||
"category": "operations",
|
|
||||||
"icon": "Calendar",
|
|
||||||
"dataPattern": "core",
|
|
||||||
"description": "Fullständig kalendervy med månads-, vecko- och dagsvisning",
|
|
||||||
"longDescription": "Se alla fakturadatum och deadlines i en interaktiv kalender med månads-, vecko- och dagsvy.",
|
|
||||||
"readsCoreTables": [
|
|
||||||
"invoices",
|
|
||||||
"deadlines",
|
|
||||||
"customers"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"slug": "enable-banking",
|
|
||||||
"name": "Bankintegration (PSD2)",
|
|
||||||
"sector": "general",
|
|
||||||
"category": "import",
|
|
||||||
"icon": "Landmark",
|
|
||||||
"dataPattern": "manual",
|
|
||||||
"description": "Automatisk banktransaktionssynk via PSD2",
|
|
||||||
"longDescription": "Koppla ditt bankkonto direkt och synka transaktioner automatiskt via säker PSD2-bankintegration. Stöder de flesta svenska banker.",
|
|
||||||
"hasOwnData": true,
|
|
||||||
"subscriptionNotice": "Denna integration kräver ett aktivt Enable Banking-abonnemang. Utan abonnemang kommer bankintegration inte att fungera."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"slug": "email",
|
|
||||||
"name": "E-post (Resend)",
|
|
||||||
"sector": "general",
|
|
||||||
"category": "operations",
|
|
||||||
"icon": "Mail",
|
|
||||||
"dataPattern": "core",
|
|
||||||
"description": "Skicka fakturor och påminnelser via e-post",
|
|
||||||
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser (15/30/45 dagar), och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän.",
|
|
||||||
"readsCoreTables": [
|
|
||||||
"invoices",
|
|
||||||
"customers",
|
|
||||||
"company_settings"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import type { ComponentType } from 'react'
|
|||||||
import type { WorkspaceComponentProps } from '../workspace-registry'
|
import type { WorkspaceComponentProps } from '../workspace-registry'
|
||||||
|
|
||||||
export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>> = {
|
export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>> = {
|
||||||
'general/receipt-ocr': dynamic(() => import('@/components/extensions/general/ReceiptOcrWorkspace')),
|
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
|
||||||
'general/ai-categorization': dynamic(() => import('@/components/extensions/general/AiCategorizationWorkspace')),
|
'general/ai-categorization': dynamic(() => import('@/components/extensions/general/AiCategorizationWorkspace')),
|
||||||
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
|
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
|
||||||
'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')),
|
|
||||||
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/DocumentInboxWorkspace')),
|
|
||||||
'general/calendar': dynamic(() => import('@/components/extensions/general/CalendarWorkspace')),
|
|
||||||
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
|
|
||||||
}
|
}
|
||||||
|
|||||||
+52
@@ -2,9 +2,60 @@ import { loadExtensions } from '@/lib/extensions/loader'
|
|||||||
import { setContextFactory } from '@/lib/extensions/registry'
|
import { setContextFactory } from '@/lib/extensions/registry'
|
||||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||||
import { registerSupplierInvoiceHandler } from '@/lib/bookkeeping/handlers/supplier-invoice-handler'
|
import { registerSupplierInvoiceHandler } from '@/lib/bookkeeping/handlers/supplier-invoice-handler'
|
||||||
|
import { createLogger } from '@/lib/logger'
|
||||||
|
|
||||||
|
const log = createLogger('init')
|
||||||
|
|
||||||
let initialized = false
|
let initialized = false
|
||||||
|
|
||||||
|
const REQUIRED_CORE_VARS = [
|
||||||
|
'NEXT_PUBLIC_SUPABASE_URL',
|
||||||
|
'NEXT_PUBLIC_SUPABASE_ANON_KEY',
|
||||||
|
'SUPABASE_SERVICE_ROLE_KEY',
|
||||||
|
'NEXT_PUBLIC_APP_URL',
|
||||||
|
'CRON_SECRET',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const REQUIRED_EXTENSION_VARS = [
|
||||||
|
'ENABLE_BANKING_APP_ID',
|
||||||
|
'ENABLE_BANKING_PRIVATE_KEY',
|
||||||
|
'ANTHROPIC_API_KEY',
|
||||||
|
'OPENAI_API_KEY',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const OPTIONAL_VARS = [
|
||||||
|
'SENTRY_DSN',
|
||||||
|
'LANGFUSE_SECRET_KEY',
|
||||||
|
'LANGFUSE_PUBLIC_KEY',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function validateEnvironment(): void {
|
||||||
|
const missing: string[] = []
|
||||||
|
|
||||||
|
for (const v of REQUIRED_CORE_VARS) {
|
||||||
|
if (!process.env[v]) missing.push(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`Missing required environment variables: ${missing.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingExt: string[] = []
|
||||||
|
for (const v of REQUIRED_EXTENSION_VARS) {
|
||||||
|
if (!process.env[v]) missingExt.push(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingExt.length > 0) {
|
||||||
|
throw new Error(`Missing required extension environment variables: ${missingExt.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const v of OPTIONAL_VARS) {
|
||||||
|
if (!process.env[v]) {
|
||||||
|
log.warn(`Optional environment variable ${v} is not set`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the system is initialized (extensions loaded, context factory wired,
|
* Ensure the system is initialized (extensions loaded, context factory wired,
|
||||||
* core event handlers registered).
|
* core event handlers registered).
|
||||||
@@ -15,6 +66,7 @@ export function ensureInitialized(): void {
|
|||||||
if (initialized) return
|
if (initialized) return
|
||||||
initialized = true
|
initialized = true
|
||||||
|
|
||||||
|
validateEnvironment()
|
||||||
setContextFactory(createExtensionContext)
|
setContextFactory(createExtensionContext)
|
||||||
registerSupplierInvoiceHandler()
|
registerSupplierInvoiceHandler()
|
||||||
loadExtensions()
|
loadExtensions()
|
||||||
|
|||||||
@@ -116,8 +116,21 @@ export async function generateSIEExport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === Opening balances (IB) ===
|
// === Opening balances (IB) ===
|
||||||
// For now, all zeros unless we have data from previous periods
|
if (period.opening_balance_entry_id) {
|
||||||
// #IB 0 accountNumber amount
|
const { data: obEntry } = await supabase
|
||||||
|
.from('journal_entries')
|
||||||
|
.select('*, lines:journal_entry_lines(*)')
|
||||||
|
.eq('id', period.opening_balance_entry_id)
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (obEntry?.lines) {
|
||||||
|
for (const line of (obEntry.lines as JournalEntryLine[])) {
|
||||||
|
const amount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
|
||||||
|
lines.push(`#IB 0 ${line.account_number} ${formatAmount(amount)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === Journal entries (VER + TRANS) ===
|
// === Journal entries (VER + TRANS) ===
|
||||||
for (const entry of (entries as JournalEntry[]) || []) {
|
for (const entry of (entries as JournalEntry[]) || []) {
|
||||||
|
|||||||
+54
-3
@@ -1,8 +1,59 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import { withSentryConfig } from "@sentry/nextjs";
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
|
||||||
|
|
||||||
|
const cspDirectives = [
|
||||||
|
"default-src 'self'",
|
||||||
|
`connect-src 'self' ${supabaseUrl} https://*.supabase.co wss://*.supabase.co https://*.ingest.sentry.io`,
|
||||||
|
`style-src 'self' 'unsafe-inline'`,
|
||||||
|
`script-src 'self'${isDev ? " 'unsafe-eval'" : ""}`,
|
||||||
|
"img-src 'self' data: blob:",
|
||||||
|
"font-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
].join("; ");
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// Add your ngrok/tunnel domain here for local dev, e.g.:
|
async headers() {
|
||||||
// allowedDevOrigins: ["your-subdomain.ngrok-free.dev"],
|
return [
|
||||||
|
{
|
||||||
|
source: "/(.*)",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "Strict-Transport-Security",
|
||||||
|
value: "max-age=63072000; includeSubDomains; preload",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "X-Frame-Options",
|
||||||
|
value: "DENY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "X-Content-Type-Options",
|
||||||
|
value: "nosniff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Referrer-Policy",
|
||||||
|
value: "strict-origin-when-cross-origin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Permissions-Policy",
|
||||||
|
value: "camera=(), microphone=(), geolocation=(), payment=()",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Content-Security-Policy",
|
||||||
|
value: cspDirectives,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withSentryConfig(nextConfig, {
|
||||||
|
silent: !process.env.SENTRY_AUTH_TOKEN,
|
||||||
|
org: process.env.SENTRY_ORG,
|
||||||
|
project: process.env.SENTRY_PROJECT,
|
||||||
|
...(process.env.SENTRY_AUTH_TOKEN ? {} : { sourcemaps: { disable: true } }),
|
||||||
|
});
|
||||||
|
|||||||
Generated
+2259
-83
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@
|
|||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
|
"@sentry/nextjs": "^10.40.0",
|
||||||
"@supabase/ssr": "^0.8.0",
|
"@supabase/ssr": "^0.8.0",
|
||||||
"@supabase/supabase-js": "^2.93.1",
|
"@supabase/supabase-js": "^2.93.1",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
tracesSampleRate: 0.1,
|
||||||
|
enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.SENTRY_DSN,
|
||||||
|
tracesSampleRate: 0.1,
|
||||||
|
enabled: !!process.env.SENTRY_DSN,
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.SENTRY_DSN,
|
||||||
|
tracesSampleRate: 0.1,
|
||||||
|
enabled: !!process.env.SENTRY_DSN,
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- AI usage tracking for token consumption monitoring
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_usage_tracking (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES auth.users ON DELETE CASCADE NOT NULL,
|
||||||
|
extension_id TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
usage_date DATE NOT NULL DEFAULT current_date,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE ai_usage_tracking ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Users can view own usage"
|
||||||
|
ON ai_usage_tracking FOR SELECT
|
||||||
|
USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can insert own usage"
|
||||||
|
ON ai_usage_tracking FOR INSERT
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Indexes for efficient querying
|
||||||
|
CREATE INDEX idx_ai_usage_tracking_user_date
|
||||||
|
ON ai_usage_tracking (user_id, usage_date);
|
||||||
|
|
||||||
|
CREATE INDEX idx_ai_usage_tracking_user_ext_date
|
||||||
|
ON ai_usage_tracking (user_id, extension_id, usage_date);
|
||||||
@@ -177,6 +177,7 @@ export interface BankAccount {
|
|||||||
name: string | null
|
name: string | null
|
||||||
currency: Currency
|
currency: Currency
|
||||||
balance: number | null
|
balance: number | null
|
||||||
|
balance_updated_at?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import source identifiers
|
// Import source identifiers
|
||||||
|
|||||||
@@ -8,10 +8,6 @@
|
|||||||
"path": "/api/invoices/reminders/cron",
|
"path": "/api/invoices/reminders/cron",
|
||||||
"schedule": "0 8 * * *"
|
"schedule": "0 8 * * *"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "/api/extensions/push-notifications/cron",
|
|
||||||
"schedule": "0 9 * * *"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "/api/tax-deadlines/cron",
|
"path": "/api/tax-deadlines/cron",
|
||||||
"schedule": "0 0 2 1 *"
|
"schedule": "0 0 2 1 *"
|
||||||
|
|||||||
Reference in New Issue
Block a user