Files
accounted/extensions/general/skatteverket/lib/kvittens-notification.ts
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

177 lines
7.2 KiB
TypeScript

/**
* 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'
import { resolveMemberEmail } from '@/lib/notifications/member-email'
import { escapeHtml } from '@/lib/email/user-text'
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 = [
`<p>Din ${labelLower} för ${escapeHtml(input.period)} har signerats och lämnats in hos Skatteverket.</p>`,
`<p>Kvittensnummer: <strong>${escapeHtml(input.kvittensnummer)}</strong></p>`,
'<p>Ingen åtgärd behövs. Kvittensen finns sparad i Accounted.</p>',
].join('')
let result: Awaited<ReturnType<typeof email.sendEmail>>
try {
result = await email.sendEmail({ to: recipient, subject, text, html })
} catch (sendErr) {
// Release the claim so a later observation can retry the send.
await releaseClaim(supabase, input.userId, referenceUuid)
throw sendErr
}
if (!result.success) {
log.warn('kvittens email send failed', { companyId: input.companyId, error: result.error })
await releaseClaim(supabase, input.userId, referenceUuid)
return { sent: false, reason: 'send_failed' }
}
return { sent: true }
} catch (err) {
log.warn('kvittens notification failed', {
companyId: input.companyId,
referenceId: input.referenceId,
error: err instanceof Error ? err.message : String(err),
})
return { sent: false, reason: 'error' }
}
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/**
* notification_log.reference_id is a uuid column. The AGI cron passes the
* declaration row id (already a uuid); the VAT cron has no declaration row
* and passes a composite string key, which Postgres would reject (22P02)
* before dedup could even happen. Map non-uuid keys to a deterministic
* uuid-shaped SHA-256 digest so the same kvittens always resolves to the
* same claim row.
*/
function toReferenceUuid(referenceId: string): string {
if (UUID_PATTERN.test(referenceId)) return referenceId
const hex = createHash('sha256').update(referenceId).digest('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
}
/** Remove a claim whose email never went out, so a later run can retry. */
async function releaseClaim(
supabase: SupabaseClient,
userId: string,
referenceUuid: string
): Promise<void> {
try {
await supabase
.from('notification_log')
.delete()
.eq('user_id', userId)
.eq('notification_type', 'skv_kvittens')
.eq('reference_id', referenceUuid)
} catch (err) {
// A stuck claim only suppresses a retry of this one email: log and move on.
log.warn('failed to release kvittens claim', {
userId,
referenceUuid,
error: err instanceof Error ? err.message : String(err),
})
}
}