882 lines
26 KiB
TypeScript
882 lines
26 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<DuesSettings> {
|
||
|
|
const { data: settings } = await supabaseAdmin
|
||
|
|
.from('app_settings')
|
||
|
|
.select('setting_key, setting_value')
|
||
|
|
.eq('category', 'dues');
|
||
|
|
|
||
|
|
const config: Record<string, any> = {};
|
||
|
|
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<MemberWithDues[]> {
|
||
|
|
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<MemberWithDues[]> {
|
||
|
|
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<string, string> = {
|
||
|
|
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<DuesReminderResult> {
|
||
|
|
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<DuesAnalytics> {
|
||
|
|
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<string, { amount: number; count: number }>();
|
||
|
|
|
||
|
|
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<OnboardingMember[]> {
|
||
|
|
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<string, string> = {
|
||
|
|
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<DuesReminderResult> {
|
||
|
|
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 };
|
||
|
|
}
|