Files
accounted/lib/calendar/ics-generator.ts
T
Jakob Wennberg 425674ff35 chore(deadlines): legacy-type cleanup + ICS feed user-deadline fix (#1060)
* 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>
2026-07-17 17:09:31 +02:00

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
}