Files
accounted/lib/calendar/ics-generator.ts
T
Jakob WennbergandClaude Fable 5 3c0bf3f584 feat(deadlines): gate F-skatt reminders on debited preliminary tax + durable dismissal (#1057)
* 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>

* 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>

* 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>
2026-07-17 15:48:25 +02:00

220 lines
5.9 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[] = []
if (options.includeTaxDeadlines) {
const taxDeadlines = data.deadlines.filter((d) => d.deadline_type === 'tax')
events.push(...generateDeadlineEvents(taxDeadlines))
// Include non-tax deadlines too
const otherDeadlines = data.deadlines.filter((d) => d.deadline_type !== 'tax')
events.push(...generateDeadlineEvents(otherDeadlines))
}
if (options.includeInvoices) {
events.push(...generateInvoiceEvents(data.invoices))
}
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',
periodisk_sammanstallning: 'Periodisk sammanställning',
bokslut: 'Bokslut',
}
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
}