* feat: add option to exclude year-end closing entries in SIE export and related reports * delete docs * fix: allow Chrome's PDF viewer in verifikat document preview The /api/documents/:id/inline route shipped with `object-src 'none'` in its CSP, which blocked Chrome's built-in PDF viewer (it renders inline PDFs via an internal <embed>). Users on Chrome saw "Det här innehållet har blockerats" when expanding a PDF attachment in the bookkeeping view; Firefox (PDF.js) and Edge (own viewer) were unaffected, and JPGs worked because <img> isn't subject to object-src. Drops the CSP for this route to the minimum needed for embeddability: `frame-ancestors 'self'`. X-Content-Type-Options: nosniff plus the fixed Content-Type from the handler already block MIME confusion; X-Frame-Options: SAMEORIGIN + frame-ancestors still block clickjacking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(auth): add webmail deep link to email confirmation screens Mirrors Stripe's signup UX: after asking the user to verify their email, detect their webmail provider from the domain and show a button that opens the inbox in a new tab. Gmail gets a from:<sender> search pre-populated; Outlook/Yahoo/iCloud/Proton open the inbox directly. Unknown / custom domains fall back to the existing copy. Sender address is configurable via NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM (default noreply@gnubok.se) so white-label installs can match their Supabase Auth SMTP config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): unblock first-time password set for BankID users with MFA Supabase rejects updateUser({password}) and mfa.unenroll with "AAL2 session is required" whenever a TOTP factor is enrolled. BankID magic-link logins produce AAL1, and middleware skips MFA enforcement for bankid_linked users, so they had no path to AAL2 — leaving them unable to set a backup password or disable MFA without going through the email-recovery escape hatch. - /api/account/password: branch on app_metadata.has_password. First-time set writes via service.auth.admin.updateUserById (no existing credential to protect, AAL2 guard does not apply). Change-password keeps the user-session updateUser so AAL2 still fires for credential rotation. - /mfa/verify: accept a safeReturnTo query param and route there after successful verify, so step-up flows can land back where they came from. - SecuritySettings: detect the AAL2 error from both change-password and mfa.unenroll and redirect through /mfa/verify?returnTo=/settings/account instead of toasting a dead-end error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add tests and rounding utility for öre precision in bokslut calculations - Implemented `roundOre` function for rounding SEK amounts to two decimal places, ensuring consistent monetary calculations. - Introduced `ORE_TOLERANCE` constant for comparing rounded amounts, facilitating invariant checks in financial entries. - Created comprehensive tests for `roundOre`, covering typical cases, edge cases, and idempotency. - Added year-end invariants tests to verify database-level guarantees for closing entries, ensuring they balance to the öre and reject discrepancies. - Developed end-to-end tests for the dispositions chain, validating the correctness of calculations across various scenarios. * fix: update PDF rendering to remove Swish QR code generation and set default to disable Swish visibility * fix: enhance security by rejecting data URIs in safeReturnTo function tests * fix: improve rounding logic in roundOre function and add customer_type migration * fix: add customer_type column to customers and enforce CHECK constraint --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
322 lines
13 KiB
TypeScript
322 lines
13 KiB
TypeScript
import type { Invoice, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
|
import { formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils'
|
|
|
|
type EmailLang = 'sv' | 'en'
|
|
|
|
// Customer-facing labels. Statutory chapter references stay intact in both
|
|
// locales. lib/utils.ts formatCurrency() keeps the Swedish "kr" symbol for
|
|
// in-app financial UI per the accounting standard; here we want the ISO code
|
|
// so a non-Swedish recipient understands the unit.
|
|
const LABELS = {
|
|
sv: {
|
|
docInvoice: 'Faktura',
|
|
docCreditNote: 'Kreditfaktura',
|
|
docProforma: 'Proformafaktura',
|
|
docDeliveryNote: 'Följesedel',
|
|
htmlLang: 'sv',
|
|
documentFrom: (doc: string, sender: string) => `${doc} från ${sender}`,
|
|
documentNumber: (doc: string) => `${doc}nummer:`,
|
|
documentDate: (doc: string) => `${doc}datum:`,
|
|
dueDate: 'Förfallodatum:',
|
|
greeting: (firstName: string) => `Hej${firstName ? ` ${firstName}` : ''},`,
|
|
bodyCreditNote: 'Bifogat hittar du en kreditfaktura som korrigerar en tidigare faktura.',
|
|
bodyInvoice: 'Tack för ditt förtroende! Bifogat hittar du din faktura.',
|
|
toPay: 'Att betala:',
|
|
paymentHeading: 'Betalningsinformation',
|
|
bank: 'Bank:',
|
|
account: 'Kontonummer:',
|
|
iban: 'IBAN:',
|
|
bic: 'BIC/SWIFT:',
|
|
message: 'Meddelande:',
|
|
questions: 'Har du frågor om fakturan? Svara direkt på detta mejl så hjälper vi dig.',
|
|
sincerely: 'Med vänliga hälsningar,',
|
|
orgNo: 'Org.nr:',
|
|
vat: 'VAT:',
|
|
fSkatt: 'Innehar F-skattsedel',
|
|
documentSummary: (doc: string) => `${doc.toLowerCase()}sammanfattning:`,
|
|
subjectFrom: (doc: string, num: string, sender: string) => `${doc} ${num} från ${sender}`,
|
|
},
|
|
en: {
|
|
docInvoice: 'Invoice',
|
|
docCreditNote: 'Credit note',
|
|
docProforma: 'Proforma invoice',
|
|
docDeliveryNote: 'Delivery note',
|
|
htmlLang: 'en',
|
|
documentFrom: (doc: string, sender: string) => `${doc} from ${sender}`,
|
|
documentNumber: (doc: string) => `${doc} number:`,
|
|
documentDate: (doc: string) => `${doc} date:`,
|
|
dueDate: 'Due date:',
|
|
greeting: (firstName: string) => `Hi${firstName ? ` ${firstName}` : ''},`,
|
|
bodyCreditNote: 'Attached you will find a credit note that corrects an earlier invoice.',
|
|
bodyInvoice: 'Thank you for your business. Attached you will find your invoice.',
|
|
toPay: 'Total due:',
|
|
paymentHeading: 'Payment information',
|
|
bank: 'Bank:',
|
|
account: 'Account number:',
|
|
iban: 'IBAN:',
|
|
bic: 'BIC/SWIFT:',
|
|
message: 'Reference:',
|
|
questions: 'Questions about the invoice? Reply directly to this email and we will help you.',
|
|
sincerely: 'Kind regards,',
|
|
orgNo: 'Reg. no.:',
|
|
vat: 'VAT:',
|
|
// Statutory Swedish phrase — kept verbatim in both locales. F-skatt is a
|
|
// Swedish tax-authority designation; translating it has no legal standing.
|
|
fSkatt: 'Innehar F-skattsedel',
|
|
documentSummary: (doc: string) => `${doc} summary:`,
|
|
subjectFrom: (doc: string, num: string, sender: string) => `${doc} ${num} from ${sender}`,
|
|
},
|
|
} as const
|
|
|
|
function resolveLang(customer: Customer): EmailLang {
|
|
return customer.language === 'en' ? 'en' : 'sv'
|
|
}
|
|
|
|
function getDocumentLabel(invoice: Invoice, lang: EmailLang): string {
|
|
const L = LABELS[lang]
|
|
if (invoice.credited_invoice_id) return L.docCreditNote
|
|
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
|
|
if (docType === 'proforma') return L.docProforma
|
|
if (docType === 'delivery_note') return L.docDeliveryNote
|
|
return L.docInvoice
|
|
}
|
|
|
|
// Currency for the customer-facing total — explicit ISO code so a non-Swedish
|
|
// recipient reads "1 234,56 SEK" instead of the Swedish symbol "kr". Use the
|
|
// English locale for digit grouping when the email is in English so the comma
|
|
// thousands separator matches reader expectation.
|
|
function formatCurrencyForCustomer(amount: number, currency: string, lang: EmailLang): string {
|
|
const formatted = new Intl.NumberFormat(lang === 'en' ? 'en-US' : 'sv-SE', {
|
|
style: 'decimal',
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
}).format(amount)
|
|
return `${formatted} ${currency}`
|
|
}
|
|
|
|
export interface InvoiceEmailData {
|
|
invoice: Invoice
|
|
customer: Customer
|
|
company: CompanySettings
|
|
}
|
|
|
|
// Minimal hex validator — guards against branding values that bypass the
|
|
// settings UI and could inject CSS via crafted strings. Anything malformed
|
|
// falls back to the legacy default.
|
|
function safeBrandingColor(value: string | null | undefined, fallback: string): string {
|
|
if (!value) return fallback
|
|
return /^#[0-9A-Fa-f]{6}$/.test(value) ? value : fallback
|
|
}
|
|
|
|
/**
|
|
* Generate HTML email for sending an invoice
|
|
*/
|
|
export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
|
const { invoice, customer, company } = data
|
|
|
|
const lang = resolveLang(customer)
|
|
const L = LABELS[lang]
|
|
const documentType = getDocumentLabel(invoice, lang)
|
|
const isCreditNote = !!invoice.credited_invoice_id
|
|
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
|
|
const isDeliveryNote = docType === 'delivery_note'
|
|
const isProforma = docType === 'proforma'
|
|
const hidePayment = isCreditNote || isDeliveryNote || isProforma
|
|
const firstName = customer.name ? customer.name.split(' ')[0] : ''
|
|
|
|
// Primary color drives the heading accent and the highlighted total. The
|
|
// accent is sanitized to a strict hex pattern — anything else falls back
|
|
// to the legacy dark neutral. Credit notes intentionally use the success
|
|
// green for the total regardless of branding, because the customer's brain
|
|
// is wired to expect "money coming back = green".
|
|
const primaryColor = safeBrandingColor(company.invoice_primary_color, '#111111')
|
|
|
|
return `
|
|
<!DOCTYPE html>
|
|
<html lang="${L.htmlLang}">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>${documentType} ${invoice.invoice_number}</title>
|
|
</head>
|
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333;">
|
|
<div style="max-width: 600px; margin: 0 auto; padding: 40px 20px;">
|
|
<!-- Header -->
|
|
<div style="margin-bottom: 30px; border-bottom: 2px solid ${primaryColor}; padding-bottom: 16px;">
|
|
<h1 style="margin: 0 0 10px 0; font-size: 24px; font-weight: 600; color: ${primaryColor};">
|
|
${L.documentFrom(documentType, getCompanyPrimaryName(company))}
|
|
</h1>
|
|
<p style="margin: 0; color: #666; font-size: 14px;">
|
|
${L.documentNumber(documentType)} ${invoice.invoice_number}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Greeting -->
|
|
<div style="margin-bottom: 30px;">
|
|
<p style="margin: 0 0 15px 0;">
|
|
${L.greeting(firstName)}
|
|
</p>
|
|
<p style="margin: 0;">
|
|
${isCreditNote ? L.bodyCreditNote : L.bodyInvoice}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Summary Box -->
|
|
<div style="background: #f8f9fa; border-radius: 8px; padding: 25px; margin-bottom: 30px;">
|
|
<table style="width: 100%; border-collapse: collapse;">
|
|
<tr>
|
|
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.documentNumber(documentType)}</td>
|
|
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${invoice.invoice_number}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.documentDate(documentType)}</td>
|
|
<td style="padding: 8px 0; text-align: right;">${formatDate(invoice.invoice_date)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.dueDate}</td>
|
|
<td style="padding: 8px 0; text-align: right; font-weight: 500; color: ${isCreditNote ? '#333' : '#e11d48'};">
|
|
${formatDate(invoice.due_date)}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td colspan="2" style="padding: 15px 0 8px 0; border-top: 1px solid #e5e7eb;"></td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">${L.toPay}</td>
|
|
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 600; color: ${isCreditNote ? '#059669' : primaryColor};">
|
|
${formatCurrencyForCustomer(invoice.total, invoice.currency, lang)}
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Payment Details -->
|
|
${!hidePayment ? `
|
|
<div style="margin-bottom: 30px;">
|
|
<h2 style="margin: 0 0 15px 0; font-size: 16px; font-weight: 600; color: ${primaryColor};">
|
|
${L.paymentHeading}
|
|
</h2>
|
|
<table style="width: 100%; border-collapse: collapse;">
|
|
${company.bank_name ? `
|
|
<tr>
|
|
<td style="padding: 6px 0; color: #666; font-size: 14px; width: 140px;">${L.bank}</td>
|
|
<td style="padding: 6px 0;">${company.bank_name}</td>
|
|
</tr>
|
|
` : ''}
|
|
${company.clearing_number && company.account_number ? `
|
|
<tr>
|
|
<td style="padding: 6px 0; color: #666; font-size: 14px;">${L.account}</td>
|
|
<td style="padding: 6px 0;">${company.clearing_number}-${company.account_number}</td>
|
|
</tr>
|
|
` : ''}
|
|
${company.iban ? `
|
|
<tr>
|
|
<td style="padding: 6px 0; color: #666; font-size: 14px;">${L.iban}</td>
|
|
<td style="padding: 6px 0;">${company.iban}</td>
|
|
</tr>
|
|
` : ''}
|
|
${company.bic ? `
|
|
<tr>
|
|
<td style="padding: 6px 0; color: #666; font-size: 14px;">${L.bic}</td>
|
|
<td style="padding: 6px 0;">${company.bic}</td>
|
|
</tr>
|
|
` : ''}
|
|
<tr>
|
|
<td style="padding: 6px 0; color: #666; font-size: 14px;">${L.message}</td>
|
|
<td style="padding: 6px 0; font-weight: 500;">${invoice.invoice_number}</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- Footer -->
|
|
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
|
|
<p style="margin: 0 0 10px 0; color: #666; font-size: 14px;">
|
|
${L.questions}
|
|
</p>
|
|
<p style="margin: 0; color: #666; font-size: 14px;">
|
|
${L.sincerely}<br>
|
|
<strong style="color: ${primaryColor};">${getCompanyPrimaryName(company)}</strong>
|
|
</p>
|
|
${company.org_number ? `
|
|
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
|
|
${L.orgNo} ${company.org_number}
|
|
${company.vat_number ? ` | ${L.vat} ${company.vat_number}` : ''}
|
|
${company.f_skatt ? ` | ${L.fSkatt}` : ''}
|
|
</p>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`
|
|
}
|
|
|
|
/**
|
|
* Generate plain text email for sending an invoice
|
|
*/
|
|
export function generateInvoiceEmailText(data: InvoiceEmailData): string {
|
|
const { invoice, customer, company } = data
|
|
|
|
const lang = resolveLang(customer)
|
|
const L = LABELS[lang]
|
|
const documentType = getDocumentLabel(invoice, lang)
|
|
const isCreditNote = !!invoice.credited_invoice_id
|
|
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
|
|
const isDeliveryNote = docType === 'delivery_note'
|
|
const isProforma = docType === 'proforma'
|
|
const hidePayment = isCreditNote || isDeliveryNote || isProforma
|
|
const firstName = customer.name ? customer.name.split(' ')[0] : ''
|
|
|
|
let text = `${L.documentFrom(documentType, getCompanyPrimaryName(company))}\n`
|
|
text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n\n`
|
|
|
|
text += `${L.greeting(firstName)}\n\n`
|
|
|
|
text += `${isCreditNote ? L.bodyCreditNote : L.bodyInvoice}\n\n`
|
|
|
|
text += `${L.documentSummary(documentType)}\n`
|
|
text += `---\n`
|
|
text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n`
|
|
text += `${L.documentDate(documentType)} ${formatDate(invoice.invoice_date)}\n`
|
|
text += `${L.dueDate} ${formatDate(invoice.due_date)}\n`
|
|
text += `${L.toPay} ${formatCurrencyForCustomer(invoice.total, invoice.currency, lang)}\n`
|
|
text += `---\n\n`
|
|
|
|
if (!hidePayment) {
|
|
text += `${L.paymentHeading}:\n`
|
|
if (company.bank_name) text += `${L.bank} ${company.bank_name}\n`
|
|
if (company.clearing_number && company.account_number) {
|
|
text += `${L.account} ${company.clearing_number}-${company.account_number}\n`
|
|
}
|
|
if (company.iban) text += `${L.iban} ${company.iban}\n`
|
|
if (company.bic) text += `${L.bic} ${company.bic}\n`
|
|
text += `${L.message} ${invoice.invoice_number}\n\n`
|
|
}
|
|
|
|
text += `${L.questions}\n\n`
|
|
text += `${L.sincerely}\n`
|
|
text += `${getCompanyDisplayName(company)}\n`
|
|
|
|
if (company.org_number) {
|
|
text += `\n${L.orgNo} ${company.org_number}`
|
|
if (company.vat_number) text += ` | ${L.vat} ${company.vat_number}`
|
|
if (company.f_skatt) text += ` | ${L.fSkatt}`
|
|
text += `\n`
|
|
}
|
|
|
|
return text
|
|
}
|
|
|
|
/**
|
|
* Generate email subject for an invoice
|
|
*/
|
|
export function generateInvoiceEmailSubject(data: InvoiceEmailData): string {
|
|
const { invoice, customer, company } = data
|
|
const lang = resolveLang(customer)
|
|
const L = LABELS[lang]
|
|
const documentType = getDocumentLabel(invoice, lang)
|
|
|
|
return L.subjectFrom(documentType, invoice.invoice_number ?? '', getCompanyPrimaryName(company))
|
|
}
|