monacousa-portal/src/lib/server/dues.ts

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 };
}