32d9978f1b
* 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>
388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
import { createServerClient } from '@supabase/ssr'
|
|
import { getEmailService } from '@/lib/email/service'
|
|
import {
|
|
generateReminderEmailHtml,
|
|
generateReminderEmailText,
|
|
generateReminderEmailSubject,
|
|
getReminderDaysConfig
|
|
} from '@/lib/email/reminder-templates'
|
|
import { calculateLatePaymentInterest } from '@/lib/invoices/late-payment-interest'
|
|
import { createReminderFeeEntry } from '@/lib/bookkeeping/reminder-fee-entries'
|
|
import { createLogger } from '@/lib/logger'
|
|
import type { Invoice, Customer, CompanySettings } from '@/types'
|
|
|
|
const log = createLogger('reminder-processor')
|
|
|
|
// Create a service client for cron jobs (no cookie access needed)
|
|
function createServiceClient() {
|
|
return createServerClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
|
{
|
|
cookies: {
|
|
getAll() { return [] },
|
|
setAll() { }
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
export interface ReminderResult {
|
|
invoiceId: string
|
|
invoiceNumber: string
|
|
customerEmail: string
|
|
reminderLevel: 1 | 2 | 3
|
|
success: boolean
|
|
error?: string
|
|
}
|
|
|
|
export interface ProcessRemindersResult {
|
|
processed: number
|
|
sent: number
|
|
failed: number
|
|
results: ReminderResult[]
|
|
}
|
|
|
|
/**
|
|
* Determine which reminder level should be sent based on days overdue
|
|
* Returns null if no reminder should be sent
|
|
*/
|
|
export function determineReminderLevel(
|
|
daysOverdue: number,
|
|
existingLevels: number[]
|
|
): 1 | 2 | 3 | null {
|
|
const config = getReminderDaysConfig()
|
|
|
|
// Check level 3 (45 days)
|
|
if (daysOverdue >= config[3] && !existingLevels.includes(3)) {
|
|
return 3
|
|
}
|
|
|
|
// Check level 2 (30 days)
|
|
if (daysOverdue >= config[2] && !existingLevels.includes(2)) {
|
|
return 2
|
|
}
|
|
|
|
// Check level 1 (15 days)
|
|
if (daysOverdue >= config[1] && !existingLevels.includes(1)) {
|
|
return 1
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Calculate days overdue from due date
|
|
*/
|
|
export function calculateDaysOverdue(dueDate: string): number {
|
|
const due = new Date(dueDate)
|
|
const now = new Date()
|
|
const diffTime = now.getTime() - due.getTime()
|
|
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
|
|
return diffDays
|
|
}
|
|
|
|
/**
|
|
* Surcharges computed before sending the reminder. These are passed to the
|
|
* email template and persisted on the invoice_reminders row for audit.
|
|
*/
|
|
export interface ReminderSurcharges {
|
|
interestAmount: number
|
|
interestRate: number
|
|
interestFromDate: string
|
|
interestDays: number
|
|
reminderFee: number
|
|
totalDue: number
|
|
}
|
|
|
|
/**
|
|
* Send a single reminder email
|
|
*/
|
|
export async function sendReminder(
|
|
invoice: Invoice & { customer: Customer },
|
|
company: CompanySettings,
|
|
reminderLevel: 1 | 2 | 3,
|
|
actionToken: string,
|
|
surcharges: ReminderSurcharges,
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
const customer = invoice.customer
|
|
|
|
if (!customer.email) {
|
|
return { success: false, error: 'Customer has no email' }
|
|
}
|
|
|
|
const daysOverdue = calculateDaysOverdue(invoice.due_date)
|
|
|
|
// Build action URL (public page for customer response)
|
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
|
|
const actionUrl = `${baseUrl}/invoice-action/${actionToken}`
|
|
|
|
const emailData = {
|
|
invoice,
|
|
customer,
|
|
company,
|
|
reminderLevel,
|
|
daysOverdue,
|
|
actionUrl,
|
|
...surcharges,
|
|
}
|
|
|
|
const result = await getEmailService().sendEmail({
|
|
to: customer.email,
|
|
subject: generateReminderEmailSubject(emailData),
|
|
html: generateReminderEmailHtml(emailData),
|
|
text: generateReminderEmailText(emailData),
|
|
replyTo: company.email || undefined,
|
|
fromName: company.company_name || undefined
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Process all overdue invoices and send reminders
|
|
* This is the main function called by the cron job
|
|
*/
|
|
export async function processOverdueReminders(): Promise<ProcessRemindersResult> {
|
|
const supabase = createServiceClient()
|
|
const results: ReminderResult[] = []
|
|
const config = getReminderDaysConfig()
|
|
|
|
// Find all sent invoices that are past due date (at least 15 days overdue)
|
|
const minOverdueDays = config[1]
|
|
const cutoffDate = new Date()
|
|
cutoffDate.setDate(cutoffDate.getDate() - minOverdueDays)
|
|
|
|
// Positive allowlist — inherently excludes 'paid', 'partially_paid', 'cancelled', 'credited'.
|
|
// Including 'overdue' ensures level-2 / level-3 reminders re-fire after the first reminder
|
|
// flips status to 'overdue' (see status update below).
|
|
const { data: overdueInvoices, error: invoiceError } = await supabase
|
|
.from('invoices')
|
|
.select(`
|
|
*,
|
|
customer:customers(*)
|
|
`)
|
|
.in('status', ['sent', 'overdue'])
|
|
.is('credited_invoice_id', null)
|
|
.lte('due_date', cutoffDate.toISOString().split('T')[0])
|
|
.order('due_date', { ascending: true })
|
|
|
|
if (invoiceError) {
|
|
log.error('Error fetching overdue invoices:', invoiceError)
|
|
return { processed: 0, sent: 0, failed: 0, results: [] }
|
|
}
|
|
|
|
if (!overdueInvoices || overdueInvoices.length === 0) {
|
|
log.info('No overdue invoices found')
|
|
return { processed: 0, sent: 0, failed: 0, results: [] }
|
|
}
|
|
|
|
log.info(`Found ${overdueInvoices.length} overdue invoices to process`)
|
|
|
|
// Process each invoice
|
|
for (const invoice of overdueInvoices) {
|
|
const customer = invoice.customer as Customer
|
|
|
|
// Skip if customer has no email
|
|
if (!customer?.email) {
|
|
log.info(`Skipping invoice ${invoice.invoice_number}: customer has no email`)
|
|
continue
|
|
}
|
|
|
|
// Get existing reminders for this invoice
|
|
const { data: existingReminders } = await supabase
|
|
.from('invoice_reminders')
|
|
.select('reminder_level, response_type')
|
|
.eq('invoice_id', invoice.id)
|
|
|
|
// Skip if customer already responded (marked paid OR disputed) — they've
|
|
// told us they don't want another reminder. The business owner still needs
|
|
// to record the actual payment (mark-paid / match-invoice) to flip status
|
|
// and post the journal entry; we don't do that here because the customer
|
|
// action is unauthenticated and posting a JE without a verified payment
|
|
// would put the books out of sync.
|
|
const customerResponded = existingReminders?.some(r => r.response_type !== null)
|
|
if (customerResponded) {
|
|
log.info(`Skipping invoice ${invoice.invoice_number}: customer already responded via reminder link`)
|
|
continue
|
|
}
|
|
|
|
const existingLevels = existingReminders?.map(r => r.reminder_level) || []
|
|
const daysOverdue = calculateDaysOverdue(invoice.due_date)
|
|
const reminderLevel = determineReminderLevel(daysOverdue, existingLevels)
|
|
|
|
// Skip if no reminder needed
|
|
if (!reminderLevel) {
|
|
log.info(`Skipping invoice ${invoice.invoice_number}: no reminder needed (${daysOverdue} days overdue, existing levels: ${existingLevels.join(', ')})`)
|
|
continue
|
|
}
|
|
|
|
// Get company settings for this user
|
|
const { data: company, error: companyError } = await supabase
|
|
.from('company_settings')
|
|
.select('*')
|
|
.eq('company_id', invoice.company_id)
|
|
.single()
|
|
|
|
if (companyError || !company) {
|
|
log.error(`Skipping invoice ${invoice.invoice_number}: company settings not found`)
|
|
results.push({
|
|
invoiceId: invoice.id,
|
|
invoiceNumber: invoice.invoice_number,
|
|
customerEmail: customer.email,
|
|
reminderLevel,
|
|
success: false,
|
|
error: 'Company settings not found'
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Per-company kill switch (settings → Fakturering → "Skicka automatiska påminnelser")
|
|
if (company.send_invoice_reminders === false) {
|
|
log.info(`Skipping invoice ${invoice.invoice_number}: automatic reminders disabled for company ${invoice.company_id}`)
|
|
continue
|
|
}
|
|
|
|
// Race-window guard — re-check invoice status immediately before sending.
|
|
// The cron runs at 08:00; a payment match arriving during the run shouldn't
|
|
// produce a reminder for an already-paid invoice.
|
|
const { data: currentInvoice } = await supabase
|
|
.from('invoices')
|
|
.select('status')
|
|
.eq('id', invoice.id)
|
|
.single()
|
|
|
|
if (!currentInvoice || !['sent', 'overdue'].includes(currentInvoice.status as string)) {
|
|
log.info(`Skipping invoice ${invoice.invoice_number}: status changed to ${currentInvoice?.status ?? 'unknown'} mid-run`)
|
|
continue
|
|
}
|
|
|
|
// Compute statutory late-payment interest (Räntelagen §6) using the
|
|
// company override if set, else Riksbankens referensränta + 8 pp.
|
|
const asOfDate = new Date().toISOString().split('T')[0]
|
|
const interest = calculateLatePaymentInterest({
|
|
overdueAmount: invoice.total,
|
|
dueDate: invoice.due_date,
|
|
asOfDate,
|
|
overrideRate: company.reminder_interest_rate_override,
|
|
})
|
|
|
|
// Determine the lagstadgad påminnelseavgift (Lag 1981:739, max 60 kr).
|
|
// Clamp at 60 kr — the statute caps the fee even if company_settings
|
|
// somehow holds a higher value (defense in depth against a stale DB row).
|
|
const reminderFee = company.reminder_fee_enabled
|
|
? Math.min(60, Math.round((company.reminder_fee_amount ?? 60) * 100) / 100)
|
|
: 0
|
|
|
|
// Book the fee as a journal entry. Booked BEFORE creating the
|
|
// invoice_reminders row so we can persist fee_journal_entry_id.
|
|
// Failure to book the fee is logged but does not abort the reminder
|
|
// send — the customer still needs to receive the notification.
|
|
let feeJournalEntryId: string | null = null
|
|
if (reminderFee > 0) {
|
|
try {
|
|
const feeResult = await createReminderFeeEntry(supabase, {
|
|
invoiceId: invoice.id,
|
|
invoiceNumber: invoice.invoice_number,
|
|
companyId: invoice.company_id,
|
|
userId: invoice.user_id,
|
|
feeAmount: reminderFee,
|
|
asOfDate,
|
|
})
|
|
feeJournalEntryId = feeResult?.journal_entry_id ?? null
|
|
} catch (feeError) {
|
|
log.error(
|
|
`Failed to book reminder fee for invoice ${invoice.invoice_number}:`,
|
|
feeError as Error,
|
|
)
|
|
// Continue — surcharge still appears in the email, but no JE is linked.
|
|
}
|
|
}
|
|
|
|
const totalDue =
|
|
Math.round((invoice.total + interest.amount + reminderFee) * 100) / 100
|
|
|
|
// Create reminder record first (to get action token), persisting the
|
|
// computed surcharges so the public action page + audit trail show them.
|
|
const { data: reminderRecord, error: reminderError } = await supabase
|
|
.from('invoice_reminders')
|
|
.insert({
|
|
invoice_id: invoice.id,
|
|
user_id: invoice.user_id,
|
|
company_id: invoice.company_id,
|
|
reminder_level: reminderLevel,
|
|
email_to: customer.email,
|
|
interest_amount: interest.amount,
|
|
interest_rate: interest.rate,
|
|
interest_from_date: interest.fromDate,
|
|
interest_days: interest.days,
|
|
reminder_fee: reminderFee,
|
|
fee_journal_entry_id: feeJournalEntryId,
|
|
})
|
|
.select('action_token')
|
|
.single()
|
|
|
|
if (reminderError || !reminderRecord) {
|
|
log.error(`Failed to create reminder record for invoice ${invoice.invoice_number}:`, reminderError)
|
|
results.push({
|
|
invoiceId: invoice.id,
|
|
invoiceNumber: invoice.invoice_number,
|
|
customerEmail: customer.email,
|
|
reminderLevel,
|
|
success: false,
|
|
error: 'Failed to create reminder record'
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Send the reminder email
|
|
const sendResult = await sendReminder(
|
|
invoice as Invoice & { customer: Customer },
|
|
company as CompanySettings,
|
|
reminderLevel,
|
|
reminderRecord.action_token,
|
|
{
|
|
interestAmount: interest.amount,
|
|
interestRate: interest.rate,
|
|
interestFromDate: interest.fromDate,
|
|
interestDays: interest.days,
|
|
reminderFee,
|
|
totalDue,
|
|
},
|
|
)
|
|
|
|
if (sendResult.success) {
|
|
log.info(`Sent level ${reminderLevel} reminder for invoice ${invoice.invoice_number} to ${customer.email}`)
|
|
|
|
// Update invoice status to overdue if not already
|
|
if (invoice.status === 'sent') {
|
|
await supabase
|
|
.from('invoices')
|
|
.update({ status: 'overdue' })
|
|
.eq('id', invoice.id)
|
|
}
|
|
} else {
|
|
log.error(`Failed to send reminder for invoice ${invoice.invoice_number}:`, sendResult.error)
|
|
}
|
|
|
|
results.push({
|
|
invoiceId: invoice.id,
|
|
invoiceNumber: invoice.invoice_number,
|
|
customerEmail: customer.email,
|
|
reminderLevel,
|
|
success: sendResult.success,
|
|
error: sendResult.error
|
|
})
|
|
}
|
|
|
|
const sent = results.filter(r => r.success).length
|
|
const failed = results.filter(r => !r.success).length
|
|
|
|
return {
|
|
processed: results.length,
|
|
sent,
|
|
failed,
|
|
results
|
|
}
|
|
}
|