* feat: invoicing & account-security polish bundle Five independent improvements bundled to ship together: - BankID/password lockout fix: BankID-only users could enroll MFA and brick themselves (Supabase requires AAL2 to change password or unenroll MFA, and AAL2 needs a password sign-in). New app_metadata.has_password flag tracks this; middleware gates /mfa/enroll behind it, /account/set- password is the unlock path, SecuritySettings shows a banner, and /api/account/password is the single write path that flips the flag. Backfill script for existing users. - Swish invoice payment method: company_settings.swish + invoice_show_swish columns, validation in lib/api/schemas.ts (accepts 123XXXXXXX företag or 07XXXXXXXX mobile, strips whitespace/hyphens), rendered on invoice PDFs. - Send-reminders kill switch: per-company company_settings.send_invoice_ reminders toggle in PdfPrintSettings/Automatisering. Reminder processor also tightened: positive status allowlist (sent + overdue) so terminal statuses can never match; skip when customer already responded via reminder link; race-window re-check before send. - First-invoice logo prompt: one-shot dialog when creating the first invoice without a logo (issue #520). Self-limits via head-only count. - SIE export opening-balance fallback: route IB through getOpeningBalances so the compute_prior_opening_balances RPC supplies #IB after multi-year imports where opening_balance_entry_id is intentionally NULL. Previously #IB silently went to zero and #UB collapsed to current-period movements. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(account-polish): address PR review feedback - BankID-link path (extensions/general/tic/index.ts): read-merge-write app_metadata instead of passing { bankid_linked: true } alone. updateUserById REPLACES app_metadata wholesale, so the previous code would have wiped has_password for any user who later linked BankID, causing the set-password banner to (incorrectly) reappear and blocking the standard MFA enrollment button. The comment is now corrected. - Middleware (lib/supabase/middleware.ts): thread inner returnTo through the /mfa/enroll → /account/set-password redirect so the user lands on their original destination after the full chain completes, not on /. - safeReturnTo helper (lib/auth/safe-return-to.ts): replace the starts-with-/-but-not-// guard on mfa/enroll and set-password pages. The previous guard let /\evil.com and /@evil.com through. The new helper parses against a synthetic base origin and verifies it matches. - set-password page (app/(auth)/account/set-password/page.tsx): remove CLAUDE.md design system violations — bg-gradient-to-b on page bg, inline shadow-md style on the card, space-y-5, font-medium on the h1, rounded-xl on the card. Flat surface, hairline border, font-display h1 per the design tokens. - Swish dedup (lib/payments/swish.ts): extract normaliseSwish() and isValidSwish() helpers and use them in lib/api/schemas.ts, components/settings/BankDetailsForm.tsx, and the invoicing settings page. Single source of truth for the regex. - Password route (app/api/account/password/route.ts): emit a structured success log so the audit pipeline can detect password-set events, not just failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
311 lines
9.5 KiB
TypeScript
311 lines
9.5 KiB
TypeScript
import { createServerClient } from '@supabase/ssr'
|
|
import { getEmailService } from '@/lib/email/service'
|
|
import {
|
|
generateReminderEmailHtml,
|
|
generateReminderEmailText,
|
|
generateReminderEmailSubject,
|
|
getReminderDaysConfig
|
|
} from '@/lib/email/reminder-templates'
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Send a single reminder email
|
|
*/
|
|
export async function sendReminder(
|
|
invoice: Invoice & { customer: Customer },
|
|
company: CompanySettings,
|
|
reminderLevel: 1 | 2 | 3,
|
|
actionToken: string
|
|
): 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Create reminder record first (to get action token)
|
|
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
|
|
})
|
|
.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
|
|
)
|
|
|
|
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
|
|
}
|
|
}
|