feat(notifications): opt-in daily "nytt att bokföra" email digest (#2078)

* feat(notifications): opt-in daily "nytt att bokföra" email digest

Users asked for an email when new work arrives: bank transactions that
synced overnight and documents that landed in the inbox. Adds a daily
05:45 UTC cron (after the 05:00 bank sync) that emails opted-in users a
per-company summary with counts only, no amounts (data-minimization
stance of the kvittens/skattekonto mails).

- notification_settings.email_digest_enabled, NOT NULL DEFAULT false:
  strictly opt-in via a new toggle in the notification settings panel
- notification_log type 'bookkeeping_digest' with the claim-then-send
  partial unique index pattern: one mail per user per company per day
- counts unbooked transactions and unprocessed inbox items created in
  the last 24h; empty digests are never sent
- brand-aware sender + link base via lib/email/brand-sender
- docker crontabs regenerated from vercel.json

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122MznXxrLRyT96fGhfyzD4

* fix(notifications): digest review findings in one pass

Skeptic + bot findings on PR #2078, resolved together:

- Count queries now match the canonical worklist anchors: ignored
  transactions excluded; inbox items already booked directly or matched
  to a transaction (created_journal_entry_id / matched_transaction_id)
  no longer counted (skeptic: spurious digests).
- Memberships sweep and member-email lookups chunk .in() id lists at 150
  ids to stay under proxy URL limits (HTTP 414 at ~350 opted-in users).
- Recoverable claim lifecycle (CodeRabbit): claim inserts as 'pending',
  flips to 'sent' only after the provider accepted the mail; a stale
  pending claim is atomically taken over by a later run, so a worker
  death mid-send no longer swallows the day's digest. New migration
  20260831110000 admits 'pending' to the delivery_status CHECK.
- Company name sanitized against CRLF header injection before the mail
  subject (compliance swarm ASVS V1.2.5), with test.
- RoPA entry for the new processing activity in .compliance/ropa.yaml
  (compliance swarm GDPR Art. 30).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122MznXxrLRyT96fGhfyzD4

* fix(types): admit 'pending' to NotificationLog delivery_status union

Matches migration 20260831110000; surfaced by fix re-verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122MznXxrLRyT96fGhfyzD4

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-31 14:15:31 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 024420d655
commit 6dfaa45061
13 changed files with 1159 additions and 2 deletions
+41
View File
@@ -815,3 +815,44 @@ processing_activities:
- opt_out_stop_keyword_honored
- muted_senders_no_content_persistence
- account_deletion_revokes_and_shreds_whatsapp_link
- id: notifications.bookkeeping_digest
name: Daglig e-postsammanfattning "nytt att bokföra"
purpose: >-
Skicka ett dagligt opt-in-mejl till företagsmedlemmar när nya
banktransaktioner eller inkorgsunderlag har kommit in och väntar på
bokföring. Mejlet innehåller endast antal per kategori och en länk till
appen: aldrig belopp, motparter eller transaktionsinnehåll.
lawful_basis: art_6_1_a
special_category_basis: null
controller: gnubok-tenant
processor: supabase_and_resend
data_subjects:
- company_member
data_categories:
- user.contact.email
- user.financial_activity_counts
recipients:
- name: Supabase
country: EU
role: processor
- name: Resend
country: US
role: processor
international_transfers:
applicable: true
mechanism: scc_2021_c2p
note: Resend processes the outbound delivery under SCC Module 2.
retention:
duration: notification_log_row_retained_with_table
basis: gdpr_storage_limitation
stored_in:
- notification_log
- notification_settings
security_measures:
- opt_in_default_false_user_toggle_only
- counts_only_no_amounts_or_counterparties_in_body
- recipient_restricted_to_active_company_members
- claim_based_once_per_day_dedup
- company_name_header_injection_sanitized
- cron_secret_authenticated_trigger
+1
View File
@@ -1384,3 +1384,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-31] Login/register methods come from GoTrue (/auth/v1/settings + admin customProviders) instead of app-side flags; NEXT_PUBLIC_GOOGLE_AUTH_ENABLED removed (PR #1869): the Supabase dashboard becomes the single switch, an allowlist of auth-js provider ids filters non-login entries like anonymous_users, and hosted rendering is unchanged because Google is enabled in prod GoTrue. The Vercel env var stays set for old-build rollback safety; delete it after a few deploys.
[2026-08-31] Single prominent amount is PROMOTED into editable totals.total (totalSource='prominent') instead of living in a read-only Belopp row: Emil's call, an uncorrectable load-bearing value violated the prefill-override-editors rule. Provenance keeps matching fallback-grade (discount, date guard, hunt exclusion); a user edit of TOTALT clears the stamp. Multi-amount docs keep the Belopp row: promoting one of several figures would invent a total.
[2026-08-31] Image-scan red fixed by bumping the node:22-alpine digest (alpine 3.23 to 3.24.1), not by widening the gate: the Dockerfile's apk-upgrade layer is frozen by the GHCR buildx layer cache, so a fix published after the last cache-busting change (libssl3 3.5.8-r0 for CVE-2026-14456) never reaches the published image until the FROM digest moves; the red scheduled scan is the designed alarm for exactly this bump. cron.Dockerfile gained the same apk upgrade (it had none).
[2026-08-31] Bookkeeping digest email is per-user per-COMPANY per-day (not one aggregated mail across companies): notification_log.company_id anchors the claim, subject lines stay unambiguous, and most users have one company; consultants can opt in and get one short mail per client. Window is a fixed last-24h (cron cadence) rather than tracking last-sent state. Settings toggle stays hardcoded Swedish like the rest of the push-notifications extension UI (no next-intl wiring in extension components); revisit if that surface is ever translated.
@@ -0,0 +1,80 @@
/**
* The cron shell around the digest: authorization and summary aggregation.
* The digest logic itself is covered by
* lib/notifications/__tests__/bookkeeping-digest.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
vi.mock('@/lib/auth/cron', () => ({
verifyCronSecret: vi.fn(() => null),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(() => ({})),
}))
const mockRunDigest = vi.fn()
vi.mock('@/lib/notifications/bookkeeping-digest', () => ({
runBookkeepingDigest: (...args: unknown[]) => mockRunDigest(...args),
}))
import { verifyCronSecret } from '@/lib/auth/cron'
import { createServiceClient } from '@/lib/supabase/server'
import { GET } from '../route'
function request(): Request {
return new Request('https://app.testbrand.example/api/notifications/bookkeeping-digest/cron')
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(verifyCronSecret).mockReturnValue(null)
})
describe('GET /api/notifications/bookkeeping-digest/cron', () => {
it('rejects an unauthorized caller without touching the database', async () => {
vi.mocked(verifyCronSecret).mockReturnValueOnce(
NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
)
const response = await GET(request())
expect(response.status).toBe(401)
expect(vi.mocked(createServiceClient)).not.toHaveBeenCalled()
expect(mockRunDigest).not.toHaveBeenCalled()
})
it('runs the digest and returns its summary', async () => {
mockRunDigest.mockResolvedValueOnce({
optedInUsers: 2,
companiesConsidered: 1,
sent: 2,
skippedEmpty: 0,
skippedDuplicate: 0,
failed: 0,
})
const response = await GET(request())
const body = await response.json()
expect(response.status).toBe(200)
expect(body).toMatchObject({ success: true, sent: 2, optedInUsers: 2 })
expect(mockRunDigest).toHaveBeenCalledTimes(1)
expect(mockRunDigest.mock.calls[0][1]).toBeInstanceOf(Date)
})
it('maps a thrown digest failure to the canonical error envelope', async () => {
mockRunDigest.mockRejectedValueOnce(new Error('boom'))
const response = await GET(request())
expect(response.status).toBeGreaterThanOrEqual(500)
const body = await response.json()
expect(body.error).toBeDefined()
})
})
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withCronContext } from '@/lib/api/with-cron-context'
import { createServiceClient } from '@/lib/supabase/server'
import { runBookkeepingDigest } from '@/lib/notifications/bookkeeping-digest'
ensureInitialized()
/**
* GET /api/notifications/bookkeeping-digest/cron, daily 05:45 UTC.
*
* Emails opted-in users a "nytt att bokföra" summary: bank transactions and
* inbox documents that arrived in the last 24 hours. Runs after the 05:00
* bank sync so the night's imports are in the counts. Strictly opt-in via
* notification_settings.email_digest_enabled (default false); with nobody
* opted in the run is a single cheap query.
*/
export const GET = withCronContext('cron.bookkeeping_digest', async (_request, ctx) => {
const supabase = createServiceClient()
const summary = await runBookkeepingDigest(supabase, new Date())
ctx.log.info('bookkeeping digest summary', { ...summary })
return NextResponse.json({ success: true, ...summary })
})
export const POST = GET
/** Sequential per-company counting plus a handful of emails. */
export const maxDuration = 300
+1
View File
@@ -47,5 +47,6 @@
45 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/invoice-inbox/underlag-reconcile/cron
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
30 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/receipt-hunt/cron
45 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/notifications/bookkeeping-digest/cron
*/10 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/inbound/cron
5,20,35,50 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/outbound/status/cron
+1
View File
@@ -47,5 +47,6 @@
45 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/invoice-inbox/underlag-reconcile/cron
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
30 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/receipt-hunt/cron
45 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/notifications/bookkeeping-digest/cron
*/10 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/inbound/cron
5,20,35,50 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/outbound/status/cron
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { createClient } from '@/lib/supabase/client'
import { Bell, BellOff, Loader2, Moon } from 'lucide-react'
import { Bell, BellOff, Loader2, Mail, Moon } from 'lucide-react'
import type { NotificationSettings as NotificationSettingsType } from '@/types'
interface NotificationSettingsProps {
@@ -298,6 +298,37 @@ export function NotificationSettings({ onSettingsChange }: NotificationSettingsP
</CardContent>
</Card>
{/* Email digest */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
E-post
</CardTitle>
<CardDescription>
Sammanfattningar via e-post
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="email-digest">Nytt att bokföra</Label>
<p className="text-sm text-muted-foreground">
Dagligt mejl när nya banktransaktioner eller underlag har kommit in
</p>
</div>
<Switch
id="email-digest"
checked={settings.email_digest_enabled}
onCheckedChange={(checked) =>
updateSetting('email_digest_enabled', checked)
}
disabled={isSaving}
/>
</div>
</CardContent>
</Card>
{/* Quiet hours */}
<Card>
<CardHeader>
@@ -0,0 +1,369 @@
/**
* The "nytt att bokföra" digest: opt-in sweep, empty-skip, the recoverable
* claim lifecycle (pending -> send -> sent, stale takeover), and the
* counts-only email body.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
const mockIsConfigured = vi.fn()
const mockSendEmail = vi.fn()
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ isConfigured: mockIsConfigured, sendEmail: mockSendEmail }),
}))
vi.mock('@/lib/branding/resolve', () => ({
resolveBrandForCompany: vi.fn(async () => null),
}))
vi.mock('@/lib/branding/service', () => ({
getBranding: () => ({ appUrl: 'https://app.testbrand.example' }),
}))
import { runBookkeepingDigest, buildDigestEmail } from '../bookkeeping-digest'
interface TableFixture {
optedIn?: Array<{ user_id: string }>
members?: Array<{ user_id: string; company_id: string }>
txCount?: number
inboxCount?: number
companyName?: string
claimError?: { code?: string; message: string } | null
/** Row returned when acquireClaim inspects an existing claim after 23505. */
existingClaim?: { id: string; delivery_status: string; sent_at: string | null } | null
/** Whether the stale-takeover lease UPDATE wins (returns the row). */
leaseWon?: boolean
}
interface RecordedWrite {
table: string
op: 'insert' | 'update' | 'delete'
payload?: Record<string, unknown>
}
/**
* Hand-rolled mock: the assertions need recorded write payloads and order
* (claim insert before send, sent-update after), which
* createQueuedMockSupabase cannot key by table.
*/
function makeSupabase(fx: TableFixture) {
const writes: RecordedWrite[] = []
const countBuilder = (count: number) => {
const b: Record<string, unknown> = {}
Object.assign(b, {
eq: () => b,
gte: () => b,
not: () => b,
is: () => b,
in: () => b,
then: (resolve: (v: unknown) => void) => resolve({ data: null, error: null, count }),
})
return b
}
const from = (table: string) => {
const builder: Record<string, unknown> = {}
const finish = () => {
if (table === 'notification_settings') return { data: fx.optedIn ?? [], error: null }
if (table === 'company_members') return { data: fx.members ?? [], error: null }
if (table === 'profiles') {
const ids = new Set((fx.members ?? []).map((m) => m.user_id))
return {
data: [...ids].map((id) => ({ id, email: `${id}@testbrand.example` })),
error: null,
}
}
return { data: [], error: null }
}
Object.assign(builder, {
select: (_cols?: string, opts?: { head?: boolean }) => {
if (table === 'transactions' && opts?.head) return countBuilder(fx.txCount ?? 0)
if (table === 'invoice_inbox_items' && opts?.head) return countBuilder(fx.inboxCount ?? 0)
return builder
},
insert: (payload: Record<string, unknown>) => {
writes.push({ table, op: 'insert', payload })
return Promise.resolve({ data: null, error: fx.claimError ?? null })
},
update: (payload: Record<string, unknown>) => {
writes.push({ table, op: 'update', payload })
const ub: Record<string, unknown> = {}
Object.assign(ub, {
eq: () => ub,
lte: () => ub,
// The lease-renewal UPDATE ends in .select('id'): winning returns
// the row, losing returns [].
select: () =>
Promise.resolve({ data: fx.leaseWon ? [{ id: 'claim-1' }] : [], error: null }),
then: (resolve: (v: unknown) => void) => resolve({ data: null, error: null }),
})
return ub
},
delete: () => {
writes.push({ table, op: 'delete' })
return builder
},
eq: () => builder,
in: () => builder,
gte: () => builder,
not: () => builder,
is: () => builder,
order: () => builder,
range: () => builder,
maybeSingle: async () => {
if (table === 'companies') {
return { data: { name: fx.companyName ?? 'Testbolaget AB' }, error: null }
}
if (table === 'notification_log') {
return { data: fx.existingClaim ?? null, error: null }
}
return { data: null, error: null }
},
then: (resolve: (v: unknown) => void) => resolve(finish()),
})
return builder
}
return { supabase: { from } as unknown as SupabaseClient, writes }
}
const NOW = new Date('2026-08-31T05:45:00Z')
const oneUserOneCompany = {
optedIn: [{ user_id: 'u1' }],
members: [{ user_id: 'u1', company_id: 'c1' }],
}
beforeEach(() => {
vi.clearAllMocks()
mockIsConfigured.mockReturnValue(true)
mockSendEmail.mockResolvedValue({ success: true })
})
describe('runBookkeepingDigest', () => {
it('does nothing when email is not configured', async () => {
mockIsConfigured.mockReturnValue(false)
const { supabase } = makeSupabase({ optedIn: [{ user_id: 'u1' }] })
const summary = await runBookkeepingDigest(supabase, NOW)
expect(summary.sent).toBe(0)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('does nothing when nobody opted in', async () => {
const { supabase, writes } = makeSupabase({ optedIn: [] })
const summary = await runBookkeepingDigest(supabase, NOW)
expect(summary).toMatchObject({ optedInUsers: 0, sent: 0 })
expect(writes).toHaveLength(0)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('skips companies with nothing new instead of sending empty mail', async () => {
const { supabase } = makeSupabase({ ...oneUserOneCompany, txCount: 0, inboxCount: 0 })
const summary = await runBookkeepingDigest(supabase, NOW)
expect(summary.skippedEmpty).toBe(1)
expect(summary.sent).toBe(0)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('claims as pending BEFORE sending, marks sent only after the provider accepted', async () => {
const { supabase, writes } = makeSupabase({ ...oneUserOneCompany, txCount: 3, inboxCount: 1 })
let claimsAtSendTime = -1
mockSendEmail.mockImplementation(async () => {
claimsAtSendTime = writes.filter((w) => w.op === 'insert').length
return { success: true }
})
const summary = await runBookkeepingDigest(supabase, NOW)
expect(summary.sent).toBe(1)
expect(claimsAtSendTime).toBe(1)
const insert = writes.find((w) => w.op === 'insert')
expect(insert?.table).toBe('notification_log')
expect(insert?.payload).toMatchObject({
user_id: 'u1',
company_id: 'c1',
notification_type: 'bookkeeping_digest',
delivery_status: 'pending',
})
// Flipped to 'sent' only after sendEmail resolved successfully.
const sentUpdate = writes.find(
(w) => w.op === 'update' && w.payload?.delivery_status === 'sent',
)
expect(sentUpdate).toBeDefined()
const mail = mockSendEmail.mock.calls[0][0]
expect(mail.to).toBe('u1@testbrand.example')
expect(mail.subject).toContain('Nytt att bokföra')
expect(mail.text).toContain('3 nya banktransaktioner')
expect(mail.text).toContain('1 nytt underlag')
// Data minimization: counts only, never amounts.
expect(mail.text).not.toMatch(/\d+[.,]\d{2}\s*(kr|SEK)/i)
})
it('a 23505 with an already-sent claim means another run delivered today: no email', async () => {
const { supabase } = makeSupabase({
...oneUserOneCompany,
txCount: 2,
claimError: { code: '23505', message: 'duplicate key' },
existingClaim: { id: 'claim-1', delivery_status: 'sent', sent_at: NOW.toISOString() },
})
const summary = await runBookkeepingDigest(supabase, NOW)
expect(summary.skippedDuplicate).toBe(1)
expect(summary.sent).toBe(0)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('a fresh pending claim belongs to a run in flight: no takeover, no email', async () => {
const { supabase } = makeSupabase({
...oneUserOneCompany,
txCount: 2,
claimError: { code: '23505', message: 'duplicate key' },
existingClaim: {
id: 'claim-1',
delivery_status: 'pending',
sent_at: new Date(Date.now() - 60_000).toISOString(),
},
})
const summary = await runBookkeepingDigest(supabase, NOW)
expect(summary.skippedDuplicate).toBe(1)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('takes over a STALE pending claim (a run that died before sending) and sends', async () => {
const { supabase, writes } = makeSupabase({
...oneUserOneCompany,
txCount: 2,
claimError: { code: '23505', message: 'duplicate key' },
existingClaim: {
id: 'claim-1',
delivery_status: 'pending',
sent_at: new Date(Date.now() - 60 * 60_000).toISOString(),
},
leaseWon: true,
})
const summary = await runBookkeepingDigest(supabase, NOW)
expect(summary.sent).toBe(1)
expect(mockSendEmail).toHaveBeenCalledTimes(1)
// Lease renewal update happened before the send-completion update.
expect(writes.filter((w) => w.op === 'update').length).toBeGreaterThanOrEqual(2)
})
it('loses the stale-takeover race cleanly: no email', async () => {
const { supabase } = makeSupabase({
...oneUserOneCompany,
txCount: 2,
claimError: { code: '23505', message: 'duplicate key' },
existingClaim: {
id: 'claim-1',
delivery_status: 'pending',
sent_at: new Date(Date.now() - 60 * 60_000).toISOString(),
},
leaseWon: false,
})
const summary = await runBookkeepingDigest(supabase, NOW)
expect(summary.skippedDuplicate).toBe(1)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('keeps the claim pending when the send fails, so a later run can retry', async () => {
mockSendEmail.mockResolvedValue({ success: false, error: 'smtp down' })
const { supabase, writes } = makeSupabase({ ...oneUserOneCompany, txCount: 2 })
const summary = await runBookkeepingDigest(supabase, NOW)
expect(summary.failed).toBe(1)
expect(summary.sent).toBe(0)
// The claim is neither deleted nor marked sent: it stays 'pending' and
// becomes takeover-eligible once stale.
expect(writes.some((w) => w.op === 'delete')).toBe(false)
expect(
writes.some((w) => w.op === 'update' && w.payload?.delivery_status === 'sent'),
).toBe(false)
})
it('sends to every opted-in member of the same company', async () => {
const { supabase } = makeSupabase({
optedIn: [{ user_id: 'u1' }, { user_id: 'u2' }],
members: [
{ user_id: 'u1', company_id: 'c1' },
{ user_id: 'u2', company_id: 'c1' },
],
txCount: 5,
})
const summary = await runBookkeepingDigest(supabase, NOW)
expect(summary.sent).toBe(2)
const recipients = mockSendEmail.mock.calls.map((c) => c[0].to).sort()
expect(recipients).toEqual(['u1@testbrand.example', 'u2@testbrand.example'])
})
it('strips CRLF from the company name so it cannot inject mail headers', async () => {
const { supabase } = makeSupabase({
...oneUserOneCompany,
txCount: 1,
companyName: 'Evil AB\r\nBcc: attacker@testbrand.example',
})
await runBookkeepingDigest(supabase, NOW)
const mail = mockSendEmail.mock.calls[0][0]
expect(mail.subject).not.toMatch(/[\r\n]/)
expect(mail.subject).toBe('Nytt att bokföra i Evil AB Bcc: attacker@testbrand.example')
})
})
describe('buildDigestEmail', () => {
it('renders singular and plural Swedish correctly', () => {
const one = buildDigestEmail({
companyName: 'Testbolaget AB',
counts: { newTransactions: 1, newInboxItems: 1 },
baseUrl: 'https://app.testbrand.example',
appName: 'Accounted',
})
expect(one.text).toContain('1 ny banktransaktion att bokföra')
expect(one.text).toContain('1 nytt underlag i inkorgen')
expect(one.subject).toBe('Nytt att bokföra i Testbolaget AB')
const many = buildDigestEmail({
companyName: null,
counts: { newTransactions: 4, newInboxItems: 2 },
baseUrl: 'https://app.testbrand.example',
appName: 'Accounted',
})
expect(many.subject).toBe('Nytt att bokföra')
expect(many.text).toContain('4 nya banktransaktioner att bokföra')
expect(many.text).toContain('2 nya underlag i inkorgen')
})
it('omits the zero category', () => {
const mail = buildDigestEmail({
companyName: null,
counts: { newTransactions: 2, newInboxItems: 0 },
baseUrl: 'https://app.testbrand.example',
appName: 'Accounted',
})
expect(mail.text).not.toContain('underlag i inkorgen')
})
it('escapes html in the company name', () => {
const mail = buildDigestEmail({
companyName: '<b>Evil & Co</b>',
counts: { newTransactions: 1, newInboxItems: 0 },
baseUrl: 'https://app.testbrand.example',
appName: 'Accounted',
})
expect(mail.html).not.toContain('<b>Evil')
expect(mail.html).toContain('&lt;b&gt;Evil &amp; Co&lt;/b&gt;')
})
})
+509
View File
@@ -0,0 +1,509 @@
/**
* 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'
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)}`
}
/** Split ids into .in()-safe chunks (see IN_CLAUSE_CHUNK). */
function chunk<T>(items: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
return out
}
/**
* 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()
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
@@ -0,0 +1,62 @@
-- "Nytt att bokföra" daily email digest (user request 2026-08-31).
--
-- Extends notification_log's notification_type CHECK with
-- 'bookkeeping_digest': a per-user, per-company, per-day email summarizing
-- new bank transactions and new inbox documents since the previous day.
--
-- Opt-in: notification_settings gains email_digest_enabled, default false,
-- so nobody receives mail without flipping the toggle in
-- /settings/extensions/push-notifications.
ALTER TABLE public.notification_log
DROP CONSTRAINT IF EXISTS notification_log_notification_type_check;
ALTER TABLE public.notification_log
ADD CONSTRAINT notification_log_notification_type_check
CHECK (notification_type IN (
'tax_deadline',
'invoice_due',
'invoice_overdue',
'period_locked',
'period_year_closed',
'invoice_sent',
'receipt_extracted',
'receipt_matched',
'missing_underlag',
'skv_kvittens',
'skv_connection_expired',
'bookkeeping_digest'
)) NOT VALID;
ALTER TABLE public.notification_log
VALIDATE CONSTRAINT notification_log_notification_type_check;
-- Atomic claim-then-send dedup, same mechanism as the kvittens index
-- (20260712113000): the sender inserts the log row FIRST and only sends
-- when the insert won; an overlapping cron invocation gets a 23505 and
-- skips. reference_id is a deterministic uuid derived from
-- (company, digest date), so the scope is one mail per user per company
-- per day. Scoped per type: other notification types legitimately log
-- multiple rows per reference.
--
-- No defensive duplicate cleanup needed: the type is new in this migration,
-- so the CHECK above guarantees no existing rows can carry it.
--
-- Plain CREATE INDEX (not CONCURRENTLY): Supabase branching applies
-- migrations inside a transaction, where CONCURRENTLY is not allowed.
-- notification_log is small and append-only; the brief lock is fine.
CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_log_bookkeeping_digest_dedup
ON public.notification_log (user_id, reference_id)
WHERE notification_type = 'bookkeeping_digest';
-- NOT NULL DEFAULT false, same rationale as missing_underlag_enabled
-- (20260726174500): a toggle has no meaningful null state, and on
-- Postgres 11+ a constant default adds without a table rewrite. Default
-- false because this is a new outbound email channel: opt-in only.
ALTER TABLE public.notification_settings
ADD COLUMN IF NOT EXISTS email_digest_enabled boolean NOT NULL DEFAULT false;
COMMENT ON COLUMN public.notification_settings.email_digest_enabled IS
'Opt-in for the daily "nytt att bokföra" email digest (new bank transactions and inbox documents).';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,26 @@
-- Admit 'pending' to notification_log's delivery_status CHECK.
--
-- The bookkeeping digest (20260831100000) uses a recoverable claim: the row
-- is inserted as 'pending' BEFORE the email is handed to the provider and
-- flipped to 'sent' only after the provider accepted it. A 'pending' claim
-- whose sent_at lease has gone stale marks a run that died mid-send and can
-- be taken over by a later run. The prior senders (kvittens,
-- connection-expired) insert 'sent' up front and lose the day's mail if the
-- worker dies between claim and send; the digest closes that gap, which
-- needs the intermediate state to be representable.
--
-- The original CHECK was defined inline in 20240101000008
-- (('sent','delivered','failed')); same drop-and-recreate pattern as the
-- notification_type constraint migrations.
ALTER TABLE public.notification_log
DROP CONSTRAINT IF EXISTS notification_log_delivery_status_check;
ALTER TABLE public.notification_log
ADD CONSTRAINT notification_log_delivery_status_check
CHECK (delivery_status IN ('pending', 'sent', 'delivered', 'failed')) NOT VALID;
ALTER TABLE public.notification_log
VALIDATE CONSTRAINT notification_log_delivery_status_check;
NOTIFY pgrst, 'reload schema';
+3 -1
View File
@@ -2881,6 +2881,7 @@ export interface NotificationSettings {
receipt_extracted_enabled: boolean
receipt_matched_enabled: boolean
missing_underlag_enabled: boolean
email_digest_enabled: boolean
created_at: string
updated_at: string
}
@@ -2898,6 +2899,7 @@ export type NotificationType =
| 'missing_underlag'
| 'skv_kvittens'
| 'skv_connection_expired'
| 'bookkeeping_digest'
// Notification log entry
export interface NotificationLog {
@@ -2908,7 +2910,7 @@ export interface NotificationLog {
reference_id: string
days_before: number
sent_at: string
delivery_status: 'sent' | 'delivered' | 'failed'
delivery_status: 'pending' | 'sent' | 'delivered' | 'failed'
}
// ============================================================
+4
View File
@@ -98,6 +98,10 @@
"path": "/api/receipt-hunt/cron",
"schedule": "30 5 * * *"
},
{
"path": "/api/notifications/bookkeeping-digest/cron",
"schedule": "45 5 * * *"
},
{
"path": "/api/peppol/inbound/cron",
"schedule": "*/10 * * * *"