/** * Kvittens confirmation notification (email). * * The signing happens on Skatteverket's site, and the kvittens is often * observed by a background cron long after the user closed the tab. Without * an outbound notification the filing confirmation is silent: the user only * learns the declaration went through if they come back and look. Both * kvittens crons (AGI and VAT) call this after flipping the local state. * * Email is the delivery channel: it is the one notification surface that is * always available (push-notifications is a separate, optional extension and * cross-extension imports are not allowed). Deduplication goes through * notification_log under type 'skv_kvittens' so a re-observed kvittens never * notifies twice: the log row is inserted FIRST as a claim, guarded by a * partial unique index on (user_id, reference_id) where notification_type = * 'skv_kvittens' (migration 20260712113000), and the email is only sent when * this invocation won the insert. A unique violation (23505) means another * overlapping cron run already claimed the kvittens. * * Best-effort by design: a notification failure must never fail the * reconciliation that observed the kvittens. The body carries the period and * kvittens number but no amounts (same data-minimization stance as the * skattekonto drift email). */ import { createHash } from 'crypto' import type { SupabaseClient } from '@supabase/supabase-js' import { getEmailService } from '@/lib/email/service' import { createLogger } from '@/lib/logger' const log = createLogger('skv-kvittens-notification') export interface KvittensNotificationInput { companyId: string /** The token-owning user: recipient candidate for the email. */ userId: string kind: 'vat' | 'agi' /** Human-readable period, e.g. "2026-06" or "202606". */ period: string kvittensnummer: string /** Dedup key for the notification log (e.g. declaration id or period key). */ referenceId: string } export async function sendKvittensNotification( supabase: SupabaseClient, input: KvittensNotificationInput ): Promise<{ sent: boolean; reason?: string }> { const referenceUuid = toReferenceUuid(input.referenceId) try { const email = getEmailService() if (!email.isConfigured()) return { sent: false, reason: 'email_not_configured' } // Dedup fast path: cheap read that skips the work below on re-observed // kvittenser. NOT the enforcement: the claim insert further down is. const { data: already } = await supabase .from('notification_log') .select('id') .eq('user_id', input.userId) .eq('notification_type', 'skv_kvittens') .eq('reference_id', referenceUuid) .maybeSingle() if (already) return { sent: false, reason: 'duplicate' } const recipient = await resolveMemberEmail(supabase, input.companyId, input.userId) if (!recipient) { log.info('no authorised recipient for kvittens email', { companyId: input.companyId }) return { sent: false, reason: 'no_recipient' } } // Claim before sending. The partial unique index on notification_log // (user_id, reference_id) where notification_type = 'skv_kvittens' makes // this atomic: of two overlapping cron invocations exactly one wins the // insert; the loser gets a unique violation and skips the send. const { error: claimError } = await supabase.from('notification_log').insert({ user_id: input.userId, company_id: input.companyId, notification_type: 'skv_kvittens', reference_id: referenceUuid, days_before: 0, delivery_status: 'sent', }) if (claimError) { if (claimError.code === '23505') return { sent: false, reason: 'duplicate' } // Without a claim we cannot guarantee single delivery: skip the send // (fail closed on the never-twice guarantee) and let a later // observation retry. log.warn('kvittens claim insert failed', { companyId: input.companyId, referenceId: input.referenceId, error: claimError.message, }) return { sent: false, reason: 'claim_failed' } } const label = input.kind === 'vat' ? 'Momsdeklarationen' : 'Arbetsgivardeklarationen' const labelLower = input.kind === 'vat' ? 'momsdeklarationen' : 'arbetsgivardeklarationen' const subject = `${label} för ${input.period} är inlämnad` const text = [ `Din ${labelLower} för ${input.period} har signerats och lämnats in hos Skatteverket.`, '', `Kvittensnummer: ${input.kvittensnummer}`, '', 'Ingen åtgärd behövs. Kvittensen finns sparad i Accounted.', ].join('\n') const html = [ `
Din ${labelLower} för ${escapeHtml(input.period)} har signerats och lämnats in hos Skatteverket.
`, `Kvittensnummer: ${escapeHtml(input.kvittensnummer)}
`, 'Ingen åtgärd behövs. Kvittensen finns sparad i Accounted.
', ].join('') let result: Awaited