* feat(deadlines): gate F-skatt reminders on debited preliminary tax, add durable dismissal The f_skatt deadline was gated on the F-skatt approval flag (DB default true), giving nearly every company 12 monthly payment reminders for a tax Skatteverket may not have debited at all (64% of all system deadline rows, one lifetime completion). Approval carries no recurring obligation; the monthly duty is payment of debiterad preliminarskatt and exists only while the debited amount is > 0 (SFL 62 kap. 4-5 par., 55 kap. 2 par.). - Gate the f_skatt deadline on preliminary_tax_monthly > 0 (field already collected at onboarding, previously unread) and retitle it as a payment. - Storforetag keep the 12th in August (January-only 17th, 62 kap. 3 par.). - Declare the prod-only preliminary_tax_monthly column in a migration so installs built purely from migrations stop failing tax-settings saves. - Add deadlines.dismissed_at: DELETE on a system deadline now soft-dismisses it durably (hard deletes were resurrected by the nightly backfill within 24h); generator, backfill, and every read surface respect it. - Prune upcoming f_skatt rows for companies with no debited amount. Closes part of #1028. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(deadlines): gate AGI on employer registration, stop completing AGI deadline at XML generation The arbetsgivardeklaration deadline was gated on pays_salaries, which is wrong in both directions: a registered employer must file AGI every month including nil months (SFL 26 kap. 3 par.), and companies actively running payroll with the flag off got no AGI reminders at all (each missed monthly filing risks a forseningsavgift). - New company_settings.employer_registered (nullable, no default) gates AGI and the storforetag skatteinbetalning row; pays_salaries remains a fallback for rows saved before the flag existed and keeps its UI meaning. - Migration backfills employer_registered=true from pays_salaries=true and from actual payroll activity (salary_runs). - New employer_seasonal flag: sasongsregistrerade file only for payment months plus a December nil declaration, so only the December-period row is generated. - Settings UI: registration + seasonal checkboxes (sv/en strings). - AGI XML generation no longer auto-completes the deadline as submitted: SFL 26 kap. deems the obligation satisfied only when the declaration has come in to Skatteverket. The Skatteverket extension's kvittens reconcile remains the confirming path; manual filers tick the deadline themselves. Part of #1028. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(deadlines): statutory arsstamma replaces bokslut, moms_yearly auto-complete, EU-sales suggestion - Replace the non-statutory 'bokslut' deadline (3 months after FY end, no legal basis, off-by-one month math for broken FYs) with the statutory arsstamma deadline: within 6 months of FY end per ABL 7 kap. 10 par., the corporate act that gates the arsredovisning filing chain. Migration deletes pending bokslut rows; the backfill cron generates arsstamma rows. - Complete moms_yearly on Skatteverket submission/kvittens: the yearly branch previously returned null with a stale comment claiming annual VAT has no deadline type, leaving yearly filers with an eternally open row. The fiscal-year tax_period label is derived from company settings. - Add /api/settings/eu-trade-signal + a tax-settings callout: companies with booked EU sales (3108/3308/3107, last 15 months) but EU-trade/PS flags off are prompted to confirm the periodisk sammanstallning obligation (SFL 35 kap., 1 250 kr late fee per report). Suggestion only, never auto-enables. Part of #1028. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(deadlines): clean up legacy deadline types, fix ICS feed hiding user deadlines - Migration deletes pending rows of the retired bare 'moms' and 'inkomstdeklaration' types (completed rows kept as history) and the sandbox seed route now inserts the current moms_quarterly / inkomstdeklaration_ef types so legacy rows stop reappearing. - The calendar feed's include_tax_deadlines flag now hides only system-generated deadlines: user-created deadlines always appear. The old nesting skipped the entire deadlines fetch and dropped the user's own rows from the feed when the flag was off. Part of #1028. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deadlines): include dismissed_at in DeadlineForm payload The Deadline type gained the required dismissed_at field; the form's submit payload literal must carry it for the Omit<Deadline, ...> shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retrigger Supabase preview check The initial preview-branch creation failed transiently; the subsequent migration run applied all four stack migrations (verified via list_migrations on the preview project), leaving a stale failed check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deadlines): make system-deadline dismissal atomic Constrain the dismiss update to source='system' and verify a row was actually updated: a concurrent regeneration can delete the row between lookup and update, and the route must not report a phantom success. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
151 lines
4.7 KiB
TypeScript
151 lines
4.7 KiB
TypeScript
import { createClient } from '@supabase/supabase-js'
|
|
import { NextResponse } from 'next/server'
|
|
import { generateCalendarFeed } from '@/lib/calendar/ics-generator'
|
|
import { createLogger } from '@/lib/logger'
|
|
|
|
const log = createLogger('api/calendar/feed-token')
|
|
|
|
// In-memory rate limiting: token -> { count, resetAt }
|
|
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
|
const RATE_LIMIT_WINDOW_MS = 60_000 // 1 minute
|
|
const RATE_LIMIT_MAX = 60 // 60 requests per minute per token
|
|
|
|
// Periodic cleanup to prevent memory leaks (every 5 minutes)
|
|
let lastCleanup = Date.now()
|
|
function cleanupRateLimitMap() {
|
|
const now = Date.now()
|
|
if (now - lastCleanup < 5 * 60_000) return
|
|
lastCleanup = now
|
|
for (const [key, value] of rateLimitMap) {
|
|
if (now > value.resetAt) rateLimitMap.delete(key)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 })
|
|
}
|
|
|
|
// Rate limiting per token
|
|
cleanupRateLimitMap()
|
|
const nowMs = Date.now()
|
|
const rateEntry = rateLimitMap.get(token)
|
|
if (rateEntry && nowMs < rateEntry.resetAt) {
|
|
if (rateEntry.count >= RATE_LIMIT_MAX) {
|
|
return new NextResponse('Too many requests', { status: 429 })
|
|
}
|
|
rateEntry.count++
|
|
} else {
|
|
rateLimitMap.set(token, { count: 1, resetAt: nowMs + RATE_LIMIT_WINDOW_MS })
|
|
}
|
|
|
|
// 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 })
|
|
}
|
|
|
|
// Check token expiry
|
|
if (feed.expires_at && new Date(feed.expires_at) < new Date()) {
|
|
return new NextResponse('Feed token has expired', { status: 410 })
|
|
}
|
|
|
|
// 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. Deadlines are always
|
|
// fetched: include_tax_deadlines only hides SYSTEM rows (the generator
|
|
// filters by source), while user-created deadlines always appear.
|
|
const [deadlinesResult, invoicesResult] = await Promise.all([
|
|
supabase
|
|
.from('deadlines')
|
|
.select('*')
|
|
.eq('company_id', feed.company_id)
|
|
.is('dismissed_at', null)
|
|
.gte('due_date', startStr)
|
|
.lte('due_date', endStr)
|
|
.order('due_date'),
|
|
|
|
// Invoices
|
|
feed.include_invoices
|
|
? supabase
|
|
.from('invoices')
|
|
.select('*, customer:customers(*)')
|
|
.eq('company_id', feed.company_id)
|
|
.gte('due_date', startStr)
|
|
.lte('due_date', endStr)
|
|
.order('due_date')
|
|
: { data: [] },
|
|
])
|
|
|
|
try {
|
|
const icsContent = await generateCalendarFeed(
|
|
{
|
|
deadlines: deadlinesResult.data || [],
|
|
invoices: invoicesResult.data || [],
|
|
},
|
|
{
|
|
includeTaxDeadlines: feed.include_tax_deadlines,
|
|
includeInvoices: feed.include_invoices,
|
|
}
|
|
)
|
|
|
|
return new NextResponse(icsContent, {
|
|
headers: {
|
|
'Content-Type': 'text/calendar; charset=utf-8',
|
|
'Content-Disposition': 'attachment; filename="erp-base.ics"',
|
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
'Pragma': 'no-cache',
|
|
'Expires': '0',
|
|
},
|
|
})
|
|
} catch (error) {
|
|
log.error('Error generating ICS feed', error as Error, { feedId: feed.id })
|
|
return new NextResponse('Failed to generate calendar feed', { status: 500 })
|
|
}
|
|
}
|