diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml index ebc66534..b3b201d0 100644 --- a/.compliance/ropa.yaml +++ b/.compliance/ropa.yaml @@ -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 diff --git a/DECISIONS.md b/DECISIONS.md index 8fcecf95..d4273f31 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1384,3 +1384,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/notifications/bookkeeping-digest/cron/__tests__/route.test.ts b/app/api/notifications/bookkeeping-digest/cron/__tests__/route.test.ts new file mode 100644 index 00000000..67a746da --- /dev/null +++ b/app/api/notifications/bookkeeping-digest/cron/__tests__/route.test.ts @@ -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() + }) +}) diff --git a/app/api/notifications/bookkeeping-digest/cron/route.ts b/app/api/notifications/bookkeeping-digest/cron/route.ts new file mode 100644 index 00000000..a6c3558a --- /dev/null +++ b/app/api/notifications/bookkeeping-digest/cron/route.ts @@ -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 diff --git a/docker/crontab.hosted b/docker/crontab.hosted index c4de7fa8..12e29a28 100644 --- a/docker/crontab.hosted +++ b/docker/crontab.hosted @@ -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 diff --git a/docker/crontab.self-hosted b/docker/crontab.self-hosted index cca2ad3a..46402b93 100644 --- a/docker/crontab.self-hosted +++ b/docker/crontab.self-hosted @@ -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 diff --git a/extensions/general/push-notifications/NotificationSettings.tsx b/extensions/general/push-notifications/NotificationSettings.tsx index b6a2272a..8f6e187e 100644 --- a/extensions/general/push-notifications/NotificationSettings.tsx +++ b/extensions/general/push-notifications/NotificationSettings.tsx @@ -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 + {/* Email digest */} + + + + + E-post + + + Sammanfattningar via e-post + + + +
+
+ +

+ Dagligt mejl när nya banktransaktioner eller underlag har kommit in +

+
+ + updateSetting('email_digest_enabled', checked) + } + disabled={isSaving} + /> +
+
+
+ {/* Quiet hours */} diff --git a/lib/notifications/__tests__/bookkeeping-digest.test.ts b/lib/notifications/__tests__/bookkeeping-digest.test.ts new file mode 100644 index 00000000..be86bbf5 --- /dev/null +++ b/lib/notifications/__tests__/bookkeeping-digest.test.ts @@ -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 +} + +/** + * 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 = {} + 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 = {} + 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) => { + writes.push({ table, op: 'insert', payload }) + return Promise.resolve({ data: null, error: fx.claimError ?? null }) + }, + update: (payload: Record) => { + writes.push({ table, op: 'update', payload }) + const ub: Record = {} + 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: 'Evil & Co', + counts: { newTransactions: 1, newInboxItems: 0 }, + baseUrl: 'https://app.testbrand.example', + appName: 'Accounted', + }) + expect(mail.html).not.toContain('Evil') + expect(mail.html).toContain('<b>Evil & Co</b>') + }) +}) diff --git a/lib/notifications/bookkeeping-digest.ts b/lib/notifications/bookkeeping-digest.ts new file mode 100644 index 00000000..fea34e04 --- /dev/null +++ b/lib/notifications/bookkeeping-digest.ts @@ -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 { + 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 { + 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() + 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> + 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 { + 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 { + 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 = [ + `

${escapeHtml(intro)}

`, + `
    ${lines.map((l) => `
  • ${escapeHtml(l)}
  • `).join('')}
`, + `

Logga in för att bokföra

`, + `

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 > Aviseringar.

`, + ].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> { + const emails = new Map() + 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(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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} diff --git a/supabase/migrations/20260831100000_bookkeeping_digest_notification.sql b/supabase/migrations/20260831100000_bookkeeping_digest_notification.sql new file mode 100644 index 00000000..73855917 --- /dev/null +++ b/supabase/migrations/20260831100000_bookkeeping_digest_notification.sql @@ -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'; diff --git a/supabase/migrations/20260831110000_notification_log_delivery_status_pending.sql b/supabase/migrations/20260831110000_notification_log_delivery_status_pending.sql new file mode 100644 index 00000000..98174623 --- /dev/null +++ b/supabase/migrations/20260831110000_notification_log_delivery_status_pending.sql @@ -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'; diff --git a/types/index.ts b/types/index.ts index af1c64a6..226b1490 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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' } // ============================================================ diff --git a/vercel.json b/vercel.json index 0bea3c78..3080bb5f 100644 --- a/vercel.json +++ b/vercel.json @@ -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 * * * *"