425674ff35
* 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>
225 lines
6.2 KiB
TypeScript
225 lines
6.2 KiB
TypeScript
/**
|
|
* ICS calendar feed generator
|
|
* Generates .ics files for Apple Calendar / Google Calendar sync
|
|
*/
|
|
|
|
import { createEvents, type EventAttributes, type DateArray } from 'ics'
|
|
import type { Deadline, Invoice } from '@/types'
|
|
|
|
export interface FeedOptions {
|
|
includeTaxDeadlines: boolean
|
|
includeInvoices: boolean
|
|
}
|
|
|
|
export interface CalendarData {
|
|
deadlines: Deadline[]
|
|
invoices: Invoice[]
|
|
}
|
|
|
|
/**
|
|
* Generate a stable UID for calendar events
|
|
* This ensures updates to events are recognized by calendar apps
|
|
*/
|
|
function getEventUID(type: string, id: string, domain: string = 'erp-base.se'): string {
|
|
return `${type}-${id}@${domain}`
|
|
}
|
|
|
|
/**
|
|
* Convert date string (YYYY-MM-DD) to ICS DateArray
|
|
*/
|
|
function dateToArray(dateStr: string): DateArray {
|
|
const [year, month, day] = dateStr.split('-').map(Number)
|
|
return [year, month, day]
|
|
}
|
|
|
|
/**
|
|
* Convert date and optional time to ICS DateArray with hours/minutes
|
|
*/
|
|
function dateTimeToArray(dateStr: string, timeStr?: string | null): DateArray {
|
|
const [year, month, day] = dateStr.split('-').map(Number)
|
|
if (timeStr) {
|
|
const [hours, minutes] = timeStr.split(':').map(Number)
|
|
return [year, month, day, hours, minutes]
|
|
}
|
|
return [year, month, day, 9, 0] // Default to 9:00 AM
|
|
}
|
|
|
|
/**
|
|
* Create alarm arrays for an event (VALARM)
|
|
*/
|
|
function createAlarms(daysBefore: number[]): EventAttributes['alarms'] {
|
|
return daysBefore.map((days) => ({
|
|
action: 'display' as const,
|
|
trigger: { days, before: true },
|
|
description: 'Påminnelse',
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Generate deadline events
|
|
*/
|
|
function generateDeadlineEvents(deadlines: Deadline[]): EventAttributes[] {
|
|
return deadlines.map((deadline) => {
|
|
const event: EventAttributes = {
|
|
uid: getEventUID('deadline', deadline.id),
|
|
title: deadline.title,
|
|
start: dateTimeToArray(deadline.due_date, deadline.due_time),
|
|
duration: { hours: 1 },
|
|
description: formatDeadlineDescription(deadline),
|
|
categories: ['Deadline', deadline.deadline_type === 'tax' ? 'Skatt' : deadline.deadline_type],
|
|
status: deadline.is_completed ? 'CONFIRMED' : 'TENTATIVE',
|
|
alarms: createAlarms(deadline.reminder_offsets || [7, 1]),
|
|
}
|
|
|
|
return event
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Format deadline description for calendar
|
|
*/
|
|
function formatDeadlineDescription(deadline: Deadline): string {
|
|
const lines: string[] = []
|
|
|
|
if (deadline.tax_deadline_type) {
|
|
lines.push(`Typ: ${getSwedishTaxTypeLabel(deadline.tax_deadline_type)}`)
|
|
}
|
|
if (deadline.tax_period) {
|
|
lines.push(`Period: ${deadline.tax_period}`)
|
|
}
|
|
if (deadline.notes) {
|
|
lines.push(`\nNoteringar: ${deadline.notes}`)
|
|
}
|
|
if (deadline.status) {
|
|
lines.push(`\nStatus: ${getSwedishStatusLabel(deadline.status)}`)
|
|
}
|
|
|
|
return lines.join('\n')
|
|
}
|
|
|
|
/**
|
|
* Generate invoice events
|
|
*/
|
|
function generateInvoiceEvents(invoices: Invoice[]): EventAttributes[] {
|
|
return invoices.map((invoice) => {
|
|
const customerName = invoice.customer?.name || 'Okänd kund'
|
|
|
|
const event: EventAttributes = {
|
|
uid: getEventUID('invoice', invoice.id),
|
|
title: `Faktura #${invoice.invoice_number} - ${customerName}`,
|
|
start: dateToArray(invoice.due_date),
|
|
duration: { days: 1 },
|
|
description: formatInvoiceDescription(invoice),
|
|
categories: ['Faktura'],
|
|
status: invoice.status === 'paid' ? 'CONFIRMED' : 'TENTATIVE',
|
|
alarms: createAlarms([3, 1]),
|
|
}
|
|
|
|
return event
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Format invoice description for calendar
|
|
*/
|
|
function formatInvoiceDescription(invoice: Invoice): string {
|
|
const lines: string[] = []
|
|
|
|
lines.push(`Belopp: ${invoice.total.toLocaleString('sv-SE')} ${invoice.currency}`)
|
|
lines.push(`Status: ${getSwedishInvoiceStatusLabel(invoice.status)}`)
|
|
|
|
if (invoice.customer?.name) {
|
|
lines.push(`Kund: ${invoice.customer.name}`)
|
|
}
|
|
|
|
return lines.join('\n')
|
|
}
|
|
|
|
/**
|
|
* Generate complete calendar feed
|
|
*/
|
|
export function generateCalendarFeed(
|
|
data: CalendarData,
|
|
options: FeedOptions
|
|
): Promise<string> {
|
|
const events: EventAttributes[] = []
|
|
|
|
// The include_tax_deadlines flag governs SYSTEM-generated deadlines only:
|
|
// the user's own manual deadlines always appear in the feed. The previous
|
|
// nesting hid every deadline, including user-created ones, when the flag
|
|
// was off.
|
|
const visibleDeadlines = options.includeTaxDeadlines
|
|
? data.deadlines
|
|
: data.deadlines.filter((d) => d.source !== 'system')
|
|
|
|
const taxDeadlines = visibleDeadlines.filter((d) => d.deadline_type === 'tax')
|
|
events.push(...generateDeadlineEvents(taxDeadlines))
|
|
|
|
const otherDeadlines = visibleDeadlines.filter((d) => d.deadline_type !== 'tax')
|
|
events.push(...generateDeadlineEvents(otherDeadlines))
|
|
|
|
if (options.includeInvoices) {
|
|
events.push(...generateInvoiceEvents(data.invoices))
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
createEvents(events, (error, value) => {
|
|
if (error) {
|
|
reject(error)
|
|
} else {
|
|
resolve(value)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Swedish labels for tax deadline types
|
|
*/
|
|
function getSwedishTaxTypeLabel(type: string): string {
|
|
const labels: Record<string, string> = {
|
|
moms_monthly: 'Momsdeklaration (månad)',
|
|
moms_quarterly: 'Momsdeklaration (kvartal)',
|
|
moms_yearly: 'Momsdeklaration (år)',
|
|
f_skatt: 'Preliminärskatt (F-skatt)',
|
|
arbetsgivardeklaration: 'Arbetsgivardeklaration',
|
|
skatteinbetalning: 'Skatteinbetalning (storföretag)',
|
|
inkomstdeklaration_ef: 'Inkomstdeklaration EF',
|
|
inkomstdeklaration_ab: 'Inkomstdeklaration AB',
|
|
arsredovisning: 'Årsredovisning',
|
|
arsstamma: 'Årsstämma',
|
|
periodisk_sammanstallning: 'Periodisk sammanställning',
|
|
}
|
|
return labels[type] || type
|
|
}
|
|
|
|
/**
|
|
* Swedish labels for deadline status
|
|
*/
|
|
function getSwedishStatusLabel(status: string): string {
|
|
const labels: Record<string, string> = {
|
|
upcoming: 'Kommande',
|
|
action_needed: 'Åtgärd krävs',
|
|
in_progress: 'Pågår',
|
|
submitted: 'Inskickad',
|
|
confirmed: 'Bekräftad',
|
|
overdue: 'Försenad',
|
|
}
|
|
return labels[status] || status
|
|
}
|
|
|
|
/**
|
|
* Swedish labels for invoice status
|
|
*/
|
|
function getSwedishInvoiceStatusLabel(status: string): string {
|
|
const labels: Record<string, string> = {
|
|
draft: 'Utkast',
|
|
sent: 'Skickad',
|
|
paid: 'Betald',
|
|
overdue: 'Förfallen',
|
|
cancelled: 'Makulerad',
|
|
credited: 'Krediterad',
|
|
}
|
|
return labels[status] || status
|
|
}
|