diff --git a/DECISIONS.md b/DECISIONS.md index df81fd41..6d632175 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -646,3 +646,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-28] The "senaste bokförda verifikat" line in the balans-/resultatrapport header (#1267) reads MAX(voucher_number) over posted entries, never voucher_sequences.last_number. The sequence counter is an allocation high-water mark that provably drifts from the books in both directions: next_voucher_number burns a number when the follow-up insert fails (the reversal path in engine.ts does exactly that), delete_last_voucher decrements blindly by one instead of resetting to the new MAX, and pre-RPC SIE imports left it behind MAX. Since the whole point of the line is avstämning, printing an allocated number would send a reconciler chasing a gap that does not exist, so the label states plainly that the number is the last posted one. Scoped to the report's own date range rather than the fiscal year, so a Q1 report printed in November says something true about Q1; the balansrapport keeps the fiscal-year start as its lower bound because it accumulates. Skipped entirely on a dimension-filtered resultatrapport: that report already discloses it is partial, and an unfiltered voucher range beside a filtered result invites the wrong conclusion. No new i18n keys: both report views and the PDF template are hard-coded Swedish, per the "stays Swedish" report surfaces in .claude/rules/i18n.md, so the issue's acceptance criterion asking for sv+en strings does not apply here. [2026-07-28] Recurring-schedule dims PR ships API/MCP/generator only, no schedule-dialog pickers: UI needs visual sign-off per house rule; substrate stops the cron-spawned-invoices-born-untagged leak now. + +[2026-07-29] Retired the generic design skills now that emilkowalski/skills is installed globally (animation-vocabulary, apple-design, emil-design-eng, find-animation-opportunities, improve-animations, pick-ui-library, prototype, review-animations in ~/.claude/skills). Deleted .claude/skills/mobile-ux-core (52 lines of universal mobile UX whose file triggers are *.dart/*.swift/*Activity.kt, paths that do not exist in this repo; superseded by design.md's accessibility section plus apple-design) and .claude/skills/scout-design (a design scan that filed Linear tickets via mcp__claude_ai_Linear__save_issue, while this project files GitHub issues and loop-design-scan is the same scan with the right output; loop-design-scan's sibling reference updated). Kept web-design-guidelines: it is a Vercel-plugin symlink, cheap to keep, and may regenerate anyway. Also removed the global ui-ux-pro-max skill, a 67-style/96-palette catalogue that pulls against a locked editorial-monochrome system. +[2026-07-29] Consent-expiry follow-up sent from invoiceservice@arcim.io, not a new sender: matching the address the original batch came from lets the two mails corroborate each other; RESEND_FROM_EMAIL alignment to accounted.se stays a separate ops task. diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 6dc88e93..3c7f0ad4 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -428,6 +428,11 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => { /** * Send consent expiry notification email. * Guards with last_expiry_notification_at to avoid spamming (2-day cooldown). + * + * Paused by default (founder call 2026-07-29, after the probe backlog drain + * mass-emailed 24 users at once): the settings panel and attention surfaces + * already flag a dead connection in-app. Set BANK_CONSENT_EXPIRY_EMAILS=true + * to resume sending; the status transitions below run either way. */ async function sendConsentExpiryNotification( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -438,6 +443,8 @@ async function sendConsentExpiryNotification( baseUrl: string ): Promise { try { + if (process.env.BANK_CONSENT_EXPIRY_EMAILS !== 'true') return + // Check cooldown: skip if notified within last 2 days const lastNotified = connection.last_expiry_notification_at as string | null if (lastNotified) { @@ -465,7 +472,7 @@ async function sendConsentExpiryNotification( bankName: connection.bank_name as string, daysUntilExpiry: daysLeft, renewalUrl: `${baseUrl}/settings/banking`, - companyName: companySettings?.company_name || getBranding().appName.toLowerCase(), + companyName: companySettings?.company_name || '', isExpired, } @@ -474,6 +481,7 @@ async function sendConsentExpiryNotification( subject: generateConsentExpiryEmailSubject(emailData), html: generateConsentExpiryEmailHtml(emailData), text: generateConsentExpiryEmailText(emailData), + replyTo: getBranding().supportEmail, }) // Update last notification timestamp diff --git a/lib/email/__tests__/consent-notification-templates.test.ts b/lib/email/__tests__/consent-notification-templates.test.ts new file mode 100644 index 00000000..1a70f9fb --- /dev/null +++ b/lib/email/__tests__/consent-notification-templates.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest' +import { + generateConsentExpiryEmailHtml, + generateConsentExpiryEmailText, + generateConsentExpiryEmailSubject, +} from '@/lib/email/consent-notification-templates' +import { getBranding } from '@/lib/branding/service' + +const expired = { + bankName: 'SEB', + daysUntilExpiry: 0, + renewalUrl: 'https://app.gnubok.se/settings/banking', + companyName: 'Glimworks AB', + isExpired: true, +} + +const expiringSoon = { + ...expired, + daysUntilExpiry: 3, + isExpired: false, +} + +describe('consent expiry email templates', () => { + it('signs off as the app, never as the recipient company', () => { + const html = generateConsentExpiryEmailHtml(expired) + const text = generateConsentExpiryEmailText(expired) + const { appName } = getBranding() + + expect(html).toContain(`Med vänliga hälsningar,
\n ${appName}`) + expect(text).toContain(`Med vänliga hälsningar,\n${appName}`) + expect(html).not.toContain('Med vänliga hälsningar,
\n Glimworks') + }) + + it('uses no alarm colors in the chrome', () => { + for (const data of [expired, expiringSoon]) { + const html = generateConsentExpiryEmailHtml(data) + expect(html).not.toMatch(/#dc2626|#ea580c|#ef4444|#b91c1c/i) + expect(html).not.toContain('Åtgärd krävs') + } + }) + + it('names the bank, the company, and the destination URL', () => { + const html = generateConsentExpiryEmailHtml(expired) + expect(html).toContain('SEB') + expect(html).toContain('Glimworks AB') + // The URL is shown as plain text next to the button so the recipient can + // verify where it leads before clicking. + const urlMentions = html.split(expired.renewalUrl).length - 1 + expect(urlMentions).toBeGreaterThanOrEqual(2) + }) + + it('explains why the recipient got the email', () => { + const html = generateConsentExpiryEmailHtml(expired) + const text = generateConsentExpiryEmailText(expired) + const { supportEmail } = getBranding() + expect(html).toContain('Du får det här mejlet eftersom') + expect(html).toContain(supportEmail) + expect(text).toContain('Du får det här mejlet eftersom') + expect(text).toContain(supportEmail) + }) + + it('omits the company row cleanly when no company name is available', () => { + const data = { ...expired, companyName: '' } + const html = generateConsentExpiryEmailHtml(data) + const text = generateConsentExpiryEmailText(data) + expect(html).not.toContain('Företag') + expect(html).not.toContain('för { + expect(generateConsentExpiryEmailSubject(expired)).toBe( + 'Förnya bankkopplingen till SEB - Glimworks AB' + ) + expect(generateConsentExpiryEmailSubject(expiringSoon)).toBe( + 'Bankkopplingen till SEB löper ut om 3 dagar - Glimworks AB' + ) + expect(generateConsentExpiryEmailSubject({ ...expiringSoon, daysUntilExpiry: 1 })).toBe( + 'Bankkopplingen till SEB löper ut om 1 dag - Glimworks AB' + ) + }) + + it('pluralizes days in the expiring-soon body', () => { + const html = generateConsentExpiryEmailHtml({ ...expiringSoon, daysUntilExpiry: 1 }) + expect(html).toContain('löper ut om 1 dag') + const html3 = generateConsentExpiryEmailHtml(expiringSoon) + expect(html3).toContain('löper ut om 3 dagar') + }) +}) diff --git a/lib/email/consent-notification-templates.ts b/lib/email/consent-notification-templates.ts index 0cf63264..0a18e56c 100644 --- a/lib/email/consent-notification-templates.ts +++ b/lib/email/consent-notification-templates.ts @@ -4,20 +4,50 @@ export interface ConsentExpiryEmailData { bankName: string daysUntilExpiry: number renewalUrl: string + /** The company the bank connection belongs to. Shown so the recipient knows + * which of their companies the email concerns; never used as the sender. */ companyName: string isExpired: boolean } +/** + * Consent expiry notification emails. + * + * Tone and layout are deliberately calm: a PSD2 consent running out is + * routine, not an incident. No red, no urgency chrome; the email is signed + * by the app (never the recipient's own company), states plainly why the + * recipient got it, and shows the destination URL in plain text next to the + * button so it does not pattern-match phishing. + */ + +const SERIF = `Georgia, 'Times New Roman', serif` +const SANS = `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif` + +function dagar(n: number): string { + return `${n} ${n === 1 ? 'dag' : 'dagar'}` +} + /** * Generate HTML email for consent expiry notification */ export function generateConsentExpiryEmailHtml(data: ConsentExpiryEmailData): string { const { bankName, daysUntilExpiry, renewalUrl, companyName, isExpired } = data - const { appName } = getBranding() - const headerColor = isExpired ? '#dc2626' : '#ea580c' + const { appName, supportEmail } = getBranding() + const title = isExpired - ? 'Banksynkronisering har stoppats' - : `Samtycket för ${bankName} löper ut snart` + ? 'Bankkopplingen behöver förnyas' + : `Bankkopplingen löper ut om ${dagar(daysUntilExpiry)}` + + const intro = isExpired + ? `Banksamtycket för ${bankName} har löpt ut och den automatiska hämtningen av nya transaktioner är pausad.` + : `Banksamtycket för ${bankName} löper ut om ${dagar(daysUntilExpiry)}.` + + const explanation = + 'Det här är väntat: av säkerhetsskäl gäller ett banksamtycke (PSD2) bara en begränsad tid, och därefter behöver det godkännas på nytt hos banken.' + + const consequence = isExpired + ? 'Ingenting har försvunnit. Redan hämtade transaktioner och din bokföring påverkas inte, och när kopplingen är förnyad hämtas mellanliggande transaktioner ikapp.' + : 'Förnya gärna i förväg så fortsätter transaktionerna att hämtas utan avbrott. Din bokföring påverkas inte.' return ` @@ -27,47 +57,54 @@ export function generateConsentExpiryEmailHtml(data: ConsentExpiryEmailData): st ${title} - -
-
-
-
- ${isExpired ? 'Åtgärd krävs' : 'Påminnelse'} -
+ +
+
+ +
+ ${appName}
-

+

${title}

-
- ${isExpired ? ` -

- PSD2-samtycket för ${bankName} har löpt ut. Automatisk transaktionssynkronisering är stoppad. -

-

- Förnya anslutningen för att återuppta synkroniseringen. -

- ` : ` -

- Samtycket för ${bankName} löper ut om ${daysUntilExpiry} ${daysUntilExpiry === 1 ? 'dag' : 'dagar'}. -

-

- Förnya anslutningen innan samtycket löper ut för att undvika avbrott i transaktionssynkroniseringen. -

- `} -
+

${intro}

+

${explanation}

+

${consequence}

-
- - ${isExpired ? 'Förnya anslutning' : 'Hantera bankanslutningar'} + + + + + + ${companyName ? ` + + + + + ` : ''} +
Bank${bankName}
Företag${companyName}
+ +
-
-

+

+ Knappen leder till ${renewalUrl}.
+ Du kan också logga in som vanligt och gå till Inställningar och sedan Bank. +

+ +
+

Med vänliga hälsningar,
- ${companyName || appName.toLowerCase()} + ${appName} +

+

+ Du får det här mejlet eftersom det finns en bankkoppling i ${appName}${companyName ? ` för ${companyName}` : ''}. + Undrar du något? Mejla ${supportEmail}.

@@ -82,26 +119,33 @@ export function generateConsentExpiryEmailHtml(data: ConsentExpiryEmailData): st */ export function generateConsentExpiryEmailText(data: ConsentExpiryEmailData): string { const { bankName, daysUntilExpiry, renewalUrl, companyName, isExpired } = data - const { appName } = getBranding() + const { appName, supportEmail } = getBranding() let text = '' if (isExpired) { - text += `BANKSYNKRONISERING HAR STOPPATS\n` - text += `=`.repeat(40) + `\n\n` - text += `PSD2-samtycket för ${bankName} har löpt ut.\n` - text += `Automatisk transaktionssynkronisering är stoppad.\n\n` - text += `Förnya anslutningen för att återuppta synkroniseringen.\n\n` + text += `Bankkopplingen behöver förnyas\n\n` + text += `Banksamtycket för ${bankName} har löpt ut och den automatiska hämtningen av nya transaktioner är pausad.\n\n` } else { - text += `SAMTYCKET FÖR ${bankName.toUpperCase()} LÖPER UT SNART\n` - text += `=`.repeat(40) + `\n\n` - text += `Samtycket för ${bankName} löper ut om ${daysUntilExpiry} ${daysUntilExpiry === 1 ? 'dag' : 'dagar'}.\n\n` - text += `Förnya anslutningen innan samtycket löper ut för att undvika avbrott.\n\n` + text += `Bankkopplingen löper ut om ${dagar(daysUntilExpiry)}\n\n` + text += `Banksamtycket för ${bankName} löper ut om ${dagar(daysUntilExpiry)}.\n\n` } - text += `Hantera bankanslutningar: ${renewalUrl}\n\n` + text += `Det här är väntat: av säkerhetsskäl gäller ett banksamtycke (PSD2) bara en begränsad tid, och därefter behöver det godkännas på nytt hos banken.\n\n` + + if (isExpired) { + text += `Ingenting har försvunnit. Redan hämtade transaktioner och din bokföring påverkas inte, och när kopplingen är förnyad hämtas mellanliggande transaktioner ikapp.\n\n` + } else { + text += `Förnya gärna i förväg så fortsätter transaktionerna att hämtas utan avbrott. Din bokföring påverkas inte.\n\n` + } + + text += `Bank: ${bankName}\n` + if (companyName) text += `Företag: ${companyName}\n` + text += `\nFörnya bankkopplingen: ${renewalUrl}\n` + text += `Du kan också logga in som vanligt och gå till Inställningar och sedan Bank.\n\n` text += `Med vänliga hälsningar,\n` - text += `${companyName || appName.toLowerCase()}\n` + text += `${appName}\n\n` + text += `Du får det här mejlet eftersom det finns en bankkoppling i ${appName}${companyName ? ` för ${companyName}` : ''}. Undrar du något? Mejla ${supportEmail}.\n` return text } @@ -110,8 +154,9 @@ export function generateConsentExpiryEmailText(data: ConsentExpiryEmailData): st * Generate email subject for consent expiry notification */ export function generateConsentExpiryEmailSubject(data: ConsentExpiryEmailData): string { + const suffix = data.companyName ? ` - ${data.companyName}` : '' if (data.isExpired) { - return `Banksynkronisering stoppad - ${data.bankName}` + return `Förnya bankkopplingen till ${data.bankName}${suffix}` } - return `Banksamtycke löper ut om ${data.daysUntilExpiry} ${data.daysUntilExpiry === 1 ? 'dag' : 'dagar'} - ${data.bankName}` + return `Bankkopplingen till ${data.bankName} löper ut om ${dagar(data.daysUntilExpiry)}${suffix}` }