Files
accounted/lib/notifications/bookkeeping-digest.ts
T
Jakob Wennberg 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

497 lines
17 KiB
TypeScript

/**
* Daily "nytt att bokföra" email digest.
*
* Users asked for an email when there is new work to book: bank transactions
* that synced overnight and documents that landed in the inbox. The digest is
* strictly opt-in (notification_settings.email_digest_enabled, default false)
* and runs daily after the 05:00 bank sync so the night's imports are counted
* the same morning.
*
* One email per user per company per day: dedup goes through notification_log
* under type 'bookkeeping_digest', guarded by a partial unique index on
* (user_id, reference_id) (migration 20260831100000). reference_id is a
* deterministic uuid derived from (company, digest date), so overlapping cron
* invocations race on the insert and exactly one wins. The claim is
* recoverable: it is inserted as delivery_status 'pending', flipped to 'sent'
* only after the provider accepted the mail, and a 'pending' claim older than
* STALE_CLAIM_MS (a run that died before sending, or a send that failed) can
* be atomically taken over by a later run, so an interruption never silently
* swallows that day's digest.
*
* The body carries counts only, never amounts or counterparties: the mere
* existence of company mail is sensitive financial signal, so the details
* live behind login (same data-minimization stance as the skattekonto drift
* and kvittens emails).
*
* Best-effort by design: a digest failure must never fail the cron run for
* other users or companies.
*/
import { createHash } from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { getEmailService } from '@/lib/email/service'
import { getSenderForBrand, getBaseUrlForBrand } from '@/lib/email/brand-sender'
import { resolveBrandForCompany } from '@/lib/branding/resolve'
import { createLogger } from '@/lib/logger'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { chunk } from '@/lib/utils'
import { escapeHtml } from '@/lib/email/user-text'
const log = createLogger('bookkeeping-digest')
/** The digest counts changes in the last 24h: one cron cadence back. */
const WINDOW_MS = 24 * 60 * 60 * 1000
/**
* A 'pending' claim older than this is considered abandoned (the run that
* inserted it died before sending) and may be taken over by a later run.
* The cron is daily, so anything measured in minutes is safely stale.
*/
const STALE_CLAIM_MS = 15 * 60 * 1000
/**
* Max ids per PostgREST .in() filter: ids travel in the GET query string,
* and an unchunked list 414s past proxy URL limits (same limit and
* rationale as lib/worklist/categories.ts).
*/
const IN_CLAUSE_CHUNK = 150
export interface DigestCounts {
newTransactions: number
newInboxItems: number
}
export interface DigestRunSummary {
optedInUsers: number
companiesConsidered: number
sent: number
skippedEmpty: number
skippedDuplicate: number
failed: number
}
/**
* Count what arrived in the window and is still unhandled, using the same
* anchors as the worklist counts (lib/worklist/categories.ts). Transactions:
* created in the window, not yet booked (journal_entry_id anchor only; rows
* junction-linked via samlingsverifikat are a rounding error a few hours
* after import), not marked private, and not ignored. Inbox items: created
* in the window and not yet terminal on any of the three markers
* (supplier invoice created, booked directly, or matched to a transaction;
* status stays 'received' in all three cases).
*/
export async function countNewToBook(
supabase: SupabaseClient,
companyId: string,
sinceIso: string
): Promise<DigestCounts> {
const [txHead, inboxHead] = await Promise.all([
supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.gte('created_at', sinceIso)
.is('journal_entry_id', null)
.not('is_business', 'is', false)
.eq('is_ignored', false),
supabase
.from('invoice_inbox_items')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.gte('created_at', sinceIso)
.in('status', ['received', 'processing'])
.is('created_supplier_invoice_id', null)
.is('created_journal_entry_id', null)
.is('matched_transaction_id', null),
])
if (txHead.error) throw new Error(`transactions count failed: ${txHead.error.message}`)
if (inboxHead.error) throw new Error(`inbox count failed: ${inboxHead.error.message}`)
return {
newTransactions: txHead.count ?? 0,
newInboxItems: inboxHead.count ?? 0,
}
}
/**
* Run the digest for every opted-in user. Requires a SERVICE-ROLE client:
* both the settings sweep and the profile email lookups cross user
* boundaries that RLS would silently empty out.
*/
export async function runBookkeepingDigest(
supabase: SupabaseClient,
now: Date
): Promise<DigestRunSummary> {
const summary: DigestRunSummary = {
optedInUsers: 0,
companiesConsidered: 0,
sent: 0,
skippedEmpty: 0,
skippedDuplicate: 0,
failed: 0,
}
const email = getEmailService()
if (!email.isConfigured()) {
log.info('bookkeeping digest skipped: email service not configured')
return summary
}
const optedIn = await fetchAllRows<{ user_id: string }>(({ from, to }) =>
supabase
.from('notification_settings')
.select('user_id')
.eq('email_digest_enabled', true)
.order('user_id', { ascending: true })
.range(from, to)
)
summary.optedInUsers = optedIn.length
if (optedIn.length === 0) return summary
const userIds = optedIn.map((r) => r.user_id)
const memberships: Array<{ user_id: string; company_id: string }> = []
for (const idChunk of chunk(userIds, IN_CLAUSE_CHUNK)) {
const page = await fetchAllRows<{ user_id: string; company_id: string }>(({ from, to }) =>
supabase
.from('company_members')
.select('user_id, company_id')
.in('user_id', idChunk)
// (company_id, user_id) is unique per membership: stable total order
// for range paging.
.order('company_id', { ascending: true })
.order('user_id', { ascending: true })
.range(from, to)
)
memberships.push(...page)
}
const usersByCompany = new Map<string, string[]>()
for (const m of memberships) {
if (!m.user_id || !m.company_id) continue
const list = usersByCompany.get(m.company_id) ?? []
list.push(m.user_id)
usersByCompany.set(m.company_id, list)
}
summary.companiesConsidered = usersByCompany.size
const sinceIso = new Date(now.getTime() - WINDOW_MS).toISOString()
const digestDate = now.toISOString().slice(0, 10)
for (const [companyId, companyUserIds] of usersByCompany) {
try {
const counts = await countNewToBook(supabase, companyId, sinceIso)
if (counts.newTransactions === 0 && counts.newInboxItems === 0) {
summary.skippedEmpty += companyUserIds.length
continue
}
const result = await sendDigestForCompany(supabase, {
companyId,
userIds: companyUserIds,
counts,
digestDate,
})
summary.sent += result.sent
summary.skippedDuplicate += result.skippedDuplicate
summary.failed += result.failed
} catch (err) {
summary.failed += companyUserIds.length
log.warn('bookkeeping digest failed for company', {
companyId,
error: err instanceof Error ? err.message : String(err),
})
}
}
return summary
}
interface CompanyDigestInput {
companyId: string
userIds: string[]
counts: DigestCounts
digestDate: string
}
async function sendDigestForCompany(
supabase: SupabaseClient,
input: CompanyDigestInput
): Promise<{ sent: number; skippedDuplicate: number; failed: number }> {
const out = { sent: 0, skippedDuplicate: 0, failed: 0 }
const email = getEmailService()
const { data: company } = await supabase
.from('companies')
.select('name')
.eq('id', input.companyId)
.maybeSingle()
const rawCompanyName = (company as { name?: string } | null)?.name ?? null
// The name reaches the Subject header: sanitize against header injection.
const companyName = rawCompanyName ? sanitizeHeaderText(rawCompanyName) || null : null
// One brand resolution per company; sender identity and link base follow
// the company's brand (white-label rule: mail goes out in the brand of the
// company it concerns).
const brand = await resolveBrandForCompany(input.companyId)
const sender = getSenderForBrand(brand)
const baseUrl = getBaseUrlForBrand(brand)
// The recipient allowlist is the set of active members: an opted-in user
// removed from the company must not keep receiving its digest.
const memberEmails = await resolveCompanyMemberEmails(supabase, input.companyId, input.userIds)
const referenceUuid = toReferenceUuid(
`bookkeeping_digest:${input.companyId}:${input.digestDate}`
)
for (const userId of input.userIds) {
const recipient = memberEmails.get(userId)
if (!recipient) continue
const claim = await acquireClaim(supabase, userId, input.companyId, referenceUuid)
if (claim === 'duplicate') {
out.skippedDuplicate++
continue
}
if (claim === 'error') {
// Without a claim we cannot guarantee once-per-day: fail closed.
out.failed++
continue
}
const message = buildDigestEmail({
companyName,
counts: input.counts,
baseUrl,
appName: brand?.appName ?? 'Accounted',
})
let sendResult: Awaited<ReturnType<typeof email.sendEmail>>
try {
sendResult = await email.sendEmail({
to: recipient,
subject: message.subject,
text: message.text,
html: message.html,
...(sender.fromName ? { fromName: sender.fromName } : {}),
...(sender.fromAddress ? { fromAddress: sender.fromAddress } : {}),
...(sender.replyTo ? { replyTo: sender.replyTo } : {}),
})
} catch (err) {
// The claim stays 'pending': a later run takes it over once stale.
out.failed++
log.warn('digest email send threw', {
companyId: input.companyId,
error: err instanceof Error ? err.message : String(err),
})
continue
}
if (!sendResult.success) {
out.failed++
log.warn('digest email send failed', {
companyId: input.companyId,
error: sendResult.error,
})
continue
}
await markClaimSent(supabase, userId, referenceUuid)
out.sent++
}
return out
}
type ClaimOutcome = 'acquired' | 'duplicate' | 'error'
/**
* Acquire the once-per-day send claim. The insert (delivery_status
* 'pending') is made atomic by the partial unique index on
* (user_id, reference_id) for this notification_type; the loser of two
* overlapping runs gets 23505. A 'pending' claim whose sent_at lease is
* older than STALE_CLAIM_MS belonged to a run that died before sending (or
* whose send failed): it is taken over by atomically renewing the lease,
* conditioned on the row still being the same stale 'pending', so exactly
* one contender wins.
*/
async function acquireClaim(
supabase: SupabaseClient,
userId: string,
companyId: string,
referenceUuid: string
): Promise<ClaimOutcome> {
const { error: claimError } = await supabase.from('notification_log').insert({
user_id: userId,
company_id: companyId,
notification_type: 'bookkeeping_digest',
reference_id: referenceUuid,
days_before: 0,
delivery_status: 'pending',
})
if (!claimError) return 'acquired'
if (claimError.code !== '23505') {
log.warn('digest claim insert failed', { companyId, error: claimError.message })
return 'error'
}
const { data: existing, error: readError } = await supabase
.from('notification_log')
.select('id, delivery_status, sent_at')
.eq('user_id', userId)
.eq('notification_type', 'bookkeeping_digest')
.eq('reference_id', referenceUuid)
.maybeSingle()
if (readError || !existing) return 'duplicate'
const row = existing as { id: string; delivery_status: string; sent_at: string | null }
if (row.delivery_status !== 'pending') return 'duplicate'
const staleCutoffIso = new Date(Date.now() - STALE_CLAIM_MS).toISOString()
if (row.sent_at && row.sent_at > staleCutoffIso) return 'duplicate'
const { data: taken, error: takeError } = await supabase
.from('notification_log')
.update({ sent_at: new Date().toISOString() })
.eq('id', row.id)
.eq('delivery_status', 'pending')
.lte('sent_at', staleCutoffIso)
.select('id')
if (takeError || !taken || taken.length === 0) return 'duplicate'
return 'acquired'
}
/**
* Flip the claim to 'sent' after the provider accepted the mail. Best-effort:
* if this update fails the claim stays 'pending' and a stale takeover could
* resend, which is the accepted trade against silently losing the digest.
*/
async function markClaimSent(
supabase: SupabaseClient,
userId: string,
referenceUuid: string
): Promise<void> {
const { error } = await supabase
.from('notification_log')
.update({ delivery_status: 'sent', sent_at: new Date().toISOString() })
.eq('user_id', userId)
.eq('notification_type', 'bookkeeping_digest')
.eq('reference_id', referenceUuid)
if (error) {
log.warn('could not mark digest claim sent', { userId, referenceUuid, error: error.message })
}
}
interface DigestEmailContent {
subject: string
text: string
html: string
}
/** Exported for tests: counts only, no amounts or counterparties. */
export function buildDigestEmail(input: {
companyName: string | null
counts: DigestCounts
baseUrl: string
appName: string
}): DigestEmailContent {
const inCompany = input.companyName ? ` i ${input.companyName}` : ''
const subject = `Nytt att bokföra${inCompany}`
const lines: string[] = []
if (input.counts.newTransactions > 0) {
lines.push(
input.counts.newTransactions === 1
? '1 ny banktransaktion att bokföra'
: `${input.counts.newTransactions} nya banktransaktioner att bokföra`
)
}
if (input.counts.newInboxItems > 0) {
lines.push(
input.counts.newInboxItems === 1
? '1 nytt underlag i inkorgen'
: `${input.counts.newInboxItems} nya underlag i inkorgen`
)
}
const intro = `Sedan igår har det kommit in nytt${inCompany ? ` till ${input.companyName}` : ''}:`
const text = [
intro,
'',
...lines.map((l) => `- ${l}`),
'',
`Logga in för att bokföra: ${input.baseUrl}`,
'',
`Du får det här mejlet för att du har slagit på daglig sammanfattning i ${input.appName}. Stäng av under Inställningar > Aviseringar.`,
].join('\n')
const html = [
`<p>${escapeHtml(intro)}</p>`,
`<ul>${lines.map((l) => `<li>${escapeHtml(l)}</li>`).join('')}</ul>`,
`<p><a href="${escapeHtml(input.baseUrl)}">Logga in för att bokföra</a></p>`,
`<p style="color:#6b7280;font-size:12px">Du får det här mejlet för att du har slagit på daglig sammanfattning i ${escapeHtml(input.appName)}. Stäng av under Inställningar &gt; Aviseringar.</p>`,
].join('')
return { subject, text, html }
}
/**
* Member allowlist + emails for the given users, batched (not per-user
* round-trips like resolveMemberEmail). Same two-step shape as
* lib/notifications/member-email.ts and for the same reason: there is no FK
* from company_members.user_id to profiles, so a PostgREST embed 400s.
*/
async function resolveCompanyMemberEmails(
supabase: SupabaseClient,
companyId: string,
userIds: string[]
): Promise<Map<string, string>> {
const emails = new Map<string, string>()
const { data: members, error: memberError } = await supabase
.from('company_members')
.select('user_id')
.eq('company_id', companyId)
.in('user_id', userIds)
if (memberError) {
log.warn('could not read company members for digest recipients', {
companyId,
error: memberError.message,
})
return emails
}
const memberIds = (members ?? [])
.map((m) => (m as { user_id: string | null }).user_id)
.filter((id): id is string => typeof id === 'string')
if (memberIds.length === 0) return emails
const { data: profiles, error: profileError } = await supabase
.from('profiles')
.select('id, email')
.in('id', memberIds)
if (profileError) {
log.warn('could not read profile emails for digest recipients', {
companyId,
error: profileError.message,
})
return emails
}
for (const row of (profiles ?? []) as Array<{ id: string; email: string | null }>) {
if (row.email) emails.set(row.id, row.email)
}
return emails
}
/**
* notification_log.reference_id is a uuid column, but the digest's natural
* key is (company, date). Same deterministic SHA-256-to-uuid mapping as the
* kvittens notification.
*/
function toReferenceUuid(referenceKey: string): string {
const hex = createHash('sha256').update(referenceKey).digest('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
}
/**
* Company names are user-controlled and reach the Subject header: strip
* CR/LF and other control characters so the value can never smuggle extra
* mail headers.
*/
function sanitizeHeaderText(input: string): string {
// eslint-disable-next-line no-control-regex
return input.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim()
}