/** * Dues Management Service * Handles dues reminders, bulk operations, and analytics */ import { supabaseAdmin } from './supabase'; import { sendTemplatedEmail } from './email'; import type { MemberWithDues } from '$lib/types/database'; // ============================================ // TYPES // ============================================ export type ReminderType = 'due_soon_30' | 'due_soon_7' | 'due_soon_1' | 'overdue' | 'grace_period' | 'inactive_notice'; // Onboarding reminder types (for new members with payment_deadline) export type OnboardingReminderType = 'onboarding_reminder_7' | 'onboarding_reminder_1' | 'onboarding_expired'; export interface DuesSettings { reminder_days_before: number[]; grace_period_days: number; auto_inactive_enabled: boolean; payment_iban: string; payment_account_holder: string; payment_bank_name: string; } export interface DuesReminderResult { sent: number; skipped: number; errors: string[]; members: Array<{ id: string; name: string; email: string; status: 'sent' | 'skipped' | 'error'; error?: string }>; } export interface DuesAnalytics { totalMembers: number; current: number; dueSoon: number; overdue: number; neverPaid: number; totalCollectedThisMonth: number; totalCollectedThisYear: number; totalOutstanding: number; paymentsByMonth: Array<{ month: string; amount: number; count: number }>; remindersSentThisMonth: number; statusBreakdown: Array<{ status: string; count: number; percentage: number }>; } // ============================================ // SETTINGS // ============================================ /** * Get dues-related settings from the database */ export async function getDuesSettings(): Promise { const { data: settings } = await supabaseAdmin .from('app_settings') .select('setting_key, setting_value') .eq('category', 'dues'); const config: Record = {}; for (const s of settings || []) { config[s.setting_key] = s.setting_value; } return { reminder_days_before: Array.isArray(config.reminder_days_before) ? config.reminder_days_before : [30, 7, 1], grace_period_days: typeof config.grace_period_days === 'number' ? config.grace_period_days : 30, auto_inactive_enabled: typeof config.auto_inactive_enabled === 'boolean' ? config.auto_inactive_enabled : true, payment_iban: config.payment_iban || '', payment_account_holder: config.payment_account_holder || '', payment_bank_name: config.payment_bank_name || '' }; } // ============================================ // MEMBER QUERIES // ============================================ /** * Get members who need a specific type of reminder * Excludes members who have already received this reminder for their current due date */ export async function getMembersNeedingReminder(reminderType: ReminderType): Promise { const settings = await getDuesSettings(); const today = new Date(); today.setHours(0, 0, 0, 0); // Get all members with dues info const { data: members, error } = await supabaseAdmin .from('members_with_dues') .select('*') .not('email', 'is', null); if (error || !members) { console.error('Error fetching members:', error); return []; } // Filter based on reminder type let filteredMembers: MemberWithDues[] = []; if (reminderType.startsWith('due_soon_')) { const daysMatch = reminderType.match(/due_soon_(\d+)/); if (!daysMatch) return []; const daysBefore = parseInt(daysMatch[1]); filteredMembers = members.filter((m) => { if (!m.current_due_date || m.dues_status === 'never_paid') return false; const dueDate = new Date(m.current_due_date); const daysUntil = Math.ceil((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); // Member is due within the specified window (e.g., 30 days means dues_until <= 30) return daysUntil > 0 && daysUntil <= daysBefore; }); } else if (reminderType === 'overdue') { filteredMembers = members.filter((m) => { if (!m.current_due_date) return false; const daysOverdue = m.days_overdue || 0; // Overdue but still within grace period return m.dues_status === 'overdue' && daysOverdue <= settings.grace_period_days; }); } else if (reminderType === 'grace_period') { filteredMembers = members.filter((m) => { if (!m.current_due_date) return false; const daysOverdue = m.days_overdue || 0; // In final week of grace period const graceDaysRemaining = settings.grace_period_days - daysOverdue; return m.dues_status === 'overdue' && graceDaysRemaining > 0 && graceDaysRemaining <= 7; }); } // Exclude members who already received this reminder for their current due period if (filteredMembers.length > 0) { const memberIds = filteredMembers.map((m) => m.id); // Get reminders already sent const { data: existingReminders } = await supabaseAdmin .from('dues_reminder_logs') .select('member_id, due_date') .eq('reminder_type', reminderType) .in('member_id', memberIds); if (existingReminders && existingReminders.length > 0) { const sentSet = new Set( existingReminders.map((r) => `${r.member_id}-${r.due_date}`) ); filteredMembers = filteredMembers.filter((m) => { const key = `${m.id}-${m.current_due_date}`; return !sentSet.has(key); }); } } return filteredMembers; } /** * Get overdue members who have exceeded the grace period and should be marked inactive */ export async function getMembersForInactivation(): Promise { const settings = await getDuesSettings(); if (!settings.auto_inactive_enabled) { return []; } const { data: members } = await supabaseAdmin .from('members_with_dues') .select('*') .eq('dues_status', 'overdue') .not('status_name', 'eq', 'inactive'); if (!members) return []; // Filter to those past grace period return members.filter((m) => { const daysOverdue = m.days_overdue || 0; return daysOverdue > settings.grace_period_days; }); } // ============================================ // REMINDER SENDING // ============================================ /** * Send a dues reminder to a specific member */ export async function sendDuesReminder( member: MemberWithDues, reminderType: ReminderType, baseUrl: string = 'https://monacousa.org' ): Promise<{ success: boolean; error?: string; emailLogId?: string }> { const settings = await getDuesSettings(); // Determine template key based on reminder type const templateKey = reminderType === 'overdue' ? 'dues_overdue' : reminderType === 'grace_period' ? 'dues_grace_warning' : reminderType === 'inactive_notice' ? 'dues_inactive_notice' : `dues_reminder_${reminderType.replace('due_soon_', '')}`; // Calculate variables const dueDate = member.current_due_date ? new Date(member.current_due_date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : 'N/A'; const daysOverdue = member.days_overdue || 0; const graceDaysRemaining = Math.max(0, settings.grace_period_days - daysOverdue); const graceEndDate = member.current_due_date ? new Date( new Date(member.current_due_date).getTime() + settings.grace_period_days * 24 * 60 * 60 * 1000 ).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : 'N/A'; const variables: Record = { first_name: member.first_name, last_name: member.last_name, member_id: member.member_id, due_date: dueDate, amount: `€${(member.annual_dues || 50).toFixed(2)}`, days_overdue: daysOverdue.toString(), grace_days_remaining: graceDaysRemaining.toString(), grace_end_date: graceEndDate, account_holder: settings.payment_account_holder, bank_name: settings.payment_bank_name, iban: settings.payment_iban, portal_url: `${baseUrl}/payments` }; // Send email const result = await sendTemplatedEmail(templateKey, member.email, variables, { recipientId: member.id, recipientName: `${member.first_name} ${member.last_name}`, baseUrl }); if (!result.success) { return { success: false, error: result.error }; } // Log the reminder const { error: logError } = await supabaseAdmin.from('dues_reminder_logs').insert({ member_id: member.id, reminder_type: reminderType, due_date: member.current_due_date || new Date().toISOString().split('T')[0] }); if (logError) { console.error('Error logging reminder:', logError); } return { success: true }; } /** * Send bulk reminders of a specific type */ export async function sendBulkReminders( reminderType: ReminderType, baseUrl: string = 'https://monacousa.org' ): Promise { const members = await getMembersNeedingReminder(reminderType); const result: DuesReminderResult = { sent: 0, skipped: 0, errors: [], members: [] }; for (const member of members) { try { const sendResult = await sendDuesReminder(member, reminderType, baseUrl); if (sendResult.success) { result.sent++; result.members.push({ id: member.id, name: `${member.first_name} ${member.last_name}`, email: member.email, status: 'sent' }); } else { result.errors.push(`${member.email}: ${sendResult.error}`); result.members.push({ id: member.id, name: `${member.first_name} ${member.last_name}`, email: member.email, status: 'error', error: sendResult.error }); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; result.errors.push(`${member.email}: ${errorMessage}`); result.members.push({ id: member.id, name: `${member.first_name} ${member.last_name}`, email: member.email, status: 'error', error: errorMessage }); } } return result; } // ============================================ // GRACE PERIOD & INACTIVATION // ============================================ /** * Process members who have exceeded grace period and mark them inactive */ export async function processGracePeriodExpirations( baseUrl: string = 'https://monacousa.org' ): Promise<{ processed: number; members: Array<{ id: string; name: string; email: string }> }> { const settings = await getDuesSettings(); if (!settings.auto_inactive_enabled) { return { processed: 0, members: [] }; } const members = await getMembersForInactivation(); const processed: Array<{ id: string; name: string; email: string }> = []; // Get inactive status ID const { data: inactiveStatus } = await supabaseAdmin .from('membership_statuses') .select('id') .eq('name', 'inactive') .single(); if (!inactiveStatus) { console.error('Inactive status not found'); return { processed: 0, members: [] }; } for (const member of members) { // Update member status to inactive const { error: updateError } = await supabaseAdmin .from('members') .update({ membership_status_id: inactiveStatus.id }) .eq('id', member.id); if (updateError) { console.error(`Error updating member ${member.id}:`, updateError); continue; } // Send inactive notice await sendDuesReminder(member, 'inactive_notice', baseUrl); // Log the reminder await supabaseAdmin.from('dues_reminder_logs').insert({ member_id: member.id, reminder_type: 'inactive_notice', due_date: member.current_due_date || new Date().toISOString().split('T')[0] }); processed.push({ id: member.id, name: `${member.first_name} ${member.last_name}`, email: member.email }); } return { processed: processed.length, members: processed }; } // ============================================ // ANALYTICS // ============================================ /** * Get comprehensive dues analytics */ export async function getDuesAnalytics(): Promise { const now = new Date(); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); const startOfYear = new Date(now.getFullYear(), 0, 1); // Get members with dues const { data: members } = await supabaseAdmin.from('members_with_dues').select('*'); const allMembers = members || []; const totalMembers = allMembers.length; const current = allMembers.filter((m) => m.dues_status === 'current').length; const dueSoon = allMembers.filter((m) => m.dues_status === 'due_soon').length; const overdue = allMembers.filter((m) => m.dues_status === 'overdue').length; const neverPaid = allMembers.filter((m) => m.dues_status === 'never_paid').length; // Calculate total outstanding (overdue + due_soon + never_paid) const totalOutstanding = allMembers .filter((m) => m.dues_status !== 'current') .reduce((sum, m) => sum + (m.annual_dues || 0), 0); // Get payments this month const { data: monthPayments } = await supabaseAdmin .from('dues_payments') .select('amount') .gte('payment_date', startOfMonth.toISOString().split('T')[0]); const totalCollectedThisMonth = (monthPayments || []).reduce((sum, p) => sum + p.amount, 0); // Get payments this year const { data: yearPayments } = await supabaseAdmin .from('dues_payments') .select('amount') .gte('payment_date', startOfYear.toISOString().split('T')[0]); const totalCollectedThisYear = (yearPayments || []).reduce((sum, p) => sum + p.amount, 0); // Get payments by month (last 12 months) const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 11, 1); const { data: allPayments } = await supabaseAdmin .from('dues_payments') .select('amount, payment_date') .gte('payment_date', twelveMonthsAgo.toISOString().split('T')[0]) .order('payment_date', { ascending: true }); const paymentsByMonth: Array<{ month: string; amount: number; count: number }> = []; const monthMap = new Map(); for (const payment of allPayments || []) { const date = new Date(payment.payment_date); const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; const existing = monthMap.get(monthKey) || { amount: 0, count: 0 }; existing.amount += payment.amount; existing.count += 1; monthMap.set(monthKey, existing); } // Fill in missing months for (let i = 0; i < 12; i++) { const date = new Date(now.getFullYear(), now.getMonth() - 11 + i, 1); const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; const data = monthMap.get(monthKey) || { amount: 0, count: 0 }; paymentsByMonth.push({ month: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }), amount: data.amount, count: data.count }); } // Get reminders sent this month const { count: remindersSentThisMonth } = await supabaseAdmin .from('dues_reminder_logs') .select('*', { count: 'exact', head: true }) .gte('sent_at', startOfMonth.toISOString()); // Status breakdown with percentages const statusBreakdown = [ { status: 'current', count: current, percentage: totalMembers > 0 ? (current / totalMembers) * 100 : 0 }, { status: 'due_soon', count: dueSoon, percentage: totalMembers > 0 ? (dueSoon / totalMembers) * 100 : 0 }, { status: 'overdue', count: overdue, percentage: totalMembers > 0 ? (overdue / totalMembers) * 100 : 0 }, { status: 'never_paid', count: neverPaid, percentage: totalMembers > 0 ? (neverPaid / totalMembers) * 100 : 0 } ]; return { totalMembers, current, dueSoon, overdue, neverPaid, totalCollectedThisMonth, totalCollectedThisYear, totalOutstanding, paymentsByMonth, remindersSentThisMonth: remindersSentThisMonth || 0, statusBreakdown }; } /** * Get detailed dues report data for CSV export */ export async function getDuesReportData(): Promise<{ members: Array<{ member_id: string; name: string; email: string; membership_type: string; status: string; dues_status: string; annual_dues: number; last_payment_date: string | null; current_due_date: string | null; days_overdue: number | null; }>; payments: Array<{ member_id: string; member_name: string; amount: number; payment_date: string; payment_method: string; reference: string | null; recorded_by: string | null; }>; }> { // Get members with dues const { data: members } = await supabaseAdmin.from('members_with_dues').select('*'); const memberReport = (members || []).map((m) => ({ member_id: m.member_id, name: `${m.first_name} ${m.last_name}`, email: m.email, membership_type: m.membership_type_name || 'Regular', status: m.status_display_name || 'Unknown', dues_status: m.dues_status, annual_dues: m.annual_dues || 0, last_payment_date: m.last_payment_date, current_due_date: m.current_due_date, days_overdue: m.days_overdue })); // Get all payments with member info const { data: payments } = await supabaseAdmin .from('dues_payments') .select( ` *, member:members(member_id, first_name, last_name), recorder:members!dues_payments_recorded_by_fkey(first_name, last_name) ` ) .order('payment_date', { ascending: false }); const paymentReport = (payments || []).map((p: any) => ({ member_id: p.member?.member_id || 'Unknown', member_name: p.member ? `${p.member.first_name} ${p.member.last_name}` : 'Unknown', amount: p.amount, payment_date: p.payment_date, payment_method: p.payment_method, reference: p.reference, recorded_by: p.recorder ? `${p.recorder.first_name} ${p.recorder.last_name}` : null })); return { members: memberReport, payments: paymentReport }; } /** * Get reminder effectiveness stats */ export async function getReminderEffectiveness(): Promise<{ totalRemindersSent: number; paidWithin7Days: number; paidWithin30Days: number; effectivenessRate: number; }> { // Get all reminder logs with payment data const { data: reminders } = await supabaseAdmin .from('dues_reminder_logs') .select('member_id, sent_at, due_date'); if (!reminders || reminders.length === 0) { return { totalRemindersSent: 0, paidWithin7Days: 0, paidWithin30Days: 0, effectivenessRate: 0 }; } let paidWithin7Days = 0; let paidWithin30Days = 0; for (const reminder of reminders) { const sentDate = new Date(reminder.sent_at); const sevenDaysLater = new Date(sentDate.getTime() + 7 * 24 * 60 * 60 * 1000); const thirtyDaysLater = new Date(sentDate.getTime() + 30 * 24 * 60 * 60 * 1000); // Check if member paid within windows const { data: payments } = await supabaseAdmin .from('dues_payments') .select('payment_date') .eq('member_id', reminder.member_id) .gte('payment_date', sentDate.toISOString().split('T')[0]) .lte('payment_date', thirtyDaysLater.toISOString().split('T')[0]) .limit(1); if (payments && payments.length > 0) { const paymentDate = new Date(payments[0].payment_date); if (paymentDate <= sevenDaysLater) { paidWithin7Days++; } paidWithin30Days++; } } return { totalRemindersSent: reminders.length, paidWithin7Days, paidWithin30Days, effectivenessRate: reminders.length > 0 ? (paidWithin30Days / reminders.length) * 100 : 0 }; } // ============================================ // ONBOARDING REMINDERS // ============================================ interface OnboardingMember { id: string; first_name: string; last_name: string; email: string; member_id: string; payment_deadline: string; } /** * Get new members who need onboarding payment reminders * These are members with a payment_deadline set from onboarding */ export async function getMembersNeedingOnboardingReminder( reminderType: OnboardingReminderType ): Promise { const today = new Date(); today.setHours(0, 0, 0, 0); // Get pending status ID const { data: pendingStatus } = await supabaseAdmin .from('membership_statuses') .select('id') .eq('name', 'pending') .single(); if (!pendingStatus) { console.error('Pending status not found'); return []; } // Get members with payment_deadline set (from onboarding) const { data: members, error } = await supabaseAdmin .from('members') .select('id, first_name, last_name, email, member_id, payment_deadline') .eq('membership_status_id', pendingStatus.id) .not('payment_deadline', 'is', null) .not('email', 'is', null); if (error || !members) { console.error('Error fetching onboarding members:', error); return []; } // Filter based on reminder type let filteredMembers: OnboardingMember[] = []; if (reminderType === 'onboarding_reminder_7') { // 7 days or less until deadline filteredMembers = members.filter((m) => { if (!m.payment_deadline) return false; const deadline = new Date(m.payment_deadline); const daysUntil = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); return daysUntil > 0 && daysUntil <= 7; }) as OnboardingMember[]; } else if (reminderType === 'onboarding_reminder_1') { // 1 day or less until deadline (final reminder) filteredMembers = members.filter((m) => { if (!m.payment_deadline) return false; const deadline = new Date(m.payment_deadline); const daysUntil = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); return daysUntil === 1; }) as OnboardingMember[]; } else if (reminderType === 'onboarding_expired') { // Deadline has passed filteredMembers = members.filter((m) => { if (!m.payment_deadline) return false; const deadline = new Date(m.payment_deadline); return deadline < today; }) as OnboardingMember[]; } // Exclude members who already received this reminder if (filteredMembers.length > 0) { const memberIds = filteredMembers.map((m) => m.id); const { data: existingReminders } = await supabaseAdmin .from('dues_reminder_logs') .select('member_id') .eq('reminder_type', reminderType) .in('member_id', memberIds); if (existingReminders && existingReminders.length > 0) { const sentSet = new Set(existingReminders.map((r) => r.member_id)); filteredMembers = filteredMembers.filter((m) => !sentSet.has(m.id)); } } return filteredMembers; } /** * Send an onboarding reminder to a specific member */ export async function sendOnboardingReminder( member: OnboardingMember, reminderType: OnboardingReminderType, baseUrl: string = 'https://monacousa.org' ): Promise<{ success: boolean; error?: string }> { const settings = await getDuesSettings(); // Calculate days until deadline const deadline = new Date(member.payment_deadline); const today = new Date(); today.setHours(0, 0, 0, 0); const daysLeft = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); // Get default membership dues amount const { data: defaultType } = await supabaseAdmin .from('membership_types') .select('annual_dues') .eq('is_default', true) .single(); const variables: Record = { first_name: member.first_name, last_name: member.last_name, member_id: member.member_id || 'N/A', payment_deadline: deadline.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }), days_remaining: Math.max(0, daysLeft).toString(), amount: `€${defaultType?.annual_dues || 150}`, account_holder: settings.payment_account_holder || 'Monaco USA', bank_name: settings.payment_bank_name || 'Credit Foncier de Monaco', iban: settings.payment_iban || 'Contact for details', portal_url: `${baseUrl}/payments` }; // Send email const result = await sendTemplatedEmail(reminderType, member.email, variables, { recipientId: member.id, recipientName: `${member.first_name} ${member.last_name}`, baseUrl }); if (!result.success) { return { success: false, error: result.error }; } // Log the reminder const { error: logError } = await supabaseAdmin.from('dues_reminder_logs').insert({ member_id: member.id, reminder_type: reminderType, due_date: member.payment_deadline }); if (logError) { console.error('Error logging onboarding reminder:', logError); } return { success: true }; } /** * Send bulk onboarding reminders of a specific type */ export async function sendOnboardingReminders( reminderType: OnboardingReminderType, baseUrl: string = 'https://monacousa.org' ): Promise { const members = await getMembersNeedingOnboardingReminder(reminderType); const result: DuesReminderResult = { sent: 0, skipped: 0, errors: [], members: [] }; for (const member of members) { try { const sendResult = await sendOnboardingReminder(member, reminderType, baseUrl); if (sendResult.success) { result.sent++; result.members.push({ id: member.id, name: `${member.first_name} ${member.last_name}`, email: member.email, status: 'sent' }); } else { result.errors.push(`${member.email}: ${sendResult.error}`); result.members.push({ id: member.id, name: `${member.first_name} ${member.last_name}`, email: member.email, status: 'error', error: sendResult.error }); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; result.errors.push(`${member.email}: ${errorMessage}`); result.members.push({ id: member.id, name: `${member.first_name} ${member.last_name}`, email: member.email, status: 'error', error: errorMessage }); } } return result; } /** * Process expired onboarding payment deadlines and mark members as inactive */ export async function processOnboardingExpirations( baseUrl: string = 'https://monacousa.org' ): Promise<{ processed: number; members: Array<{ id: string; name: string; email: string }> }> { const members = await getMembersNeedingOnboardingReminder('onboarding_expired'); const processed: Array<{ id: string; name: string; email: string }> = []; // Get inactive status ID const { data: inactiveStatus } = await supabaseAdmin .from('membership_statuses') .select('id') .eq('name', 'inactive') .single(); if (!inactiveStatus) { console.error('Inactive status not found'); return { processed: 0, members: [] }; } for (const member of members) { // Update member status to inactive const { error: updateError } = await supabaseAdmin .from('members') .update({ membership_status_id: inactiveStatus.id }) .eq('id', member.id); if (updateError) { console.error(`Error updating member ${member.id}:`, updateError); continue; } // Send expired notice await sendOnboardingReminder(member, 'onboarding_expired', baseUrl); processed.push({ id: member.id, name: `${member.first_name} ${member.last_name}`, email: member.email }); } return { processed: processed.length, members: processed }; }