* 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>
220 lines
5.9 KiB
TypeScript
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
|
|
}
|