/** * Dues Calculator Utility Service * * Provides centralized dues calculation and tracking logic * Replaces hardcoded "1 year from member_since" fallback logic with proper data-driven calculations */ export interface DuesStatus { isDue: boolean; isOverdue: boolean; dueDate: Date | null; paidUntil: Date | null; daysUntilDue: number | null; daysOverdue: number | null; amount: number; currency: string; source: 'database' | 'calculated' | 'fallback'; confidence: 'high' | 'medium' | 'low'; } export interface DuesPayment { memberId: string; amount: number; currency: string; paymentDate: Date; paidUntil: Date; paymentMethod?: string; transactionId?: string; notes?: string; } /** * Calculate dues status for a member * Uses actual payment_due_date field from database when available */ export async function calculateDuesStatus(member: any): Promise { const now = new Date(); // First check if member has payment_due_date field if (member.payment_due_date) { const paidUntil = new Date(member.payment_due_date); const isDue = paidUntil < now; const daysUntilDue = isDue ? null : Math.floor((paidUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); const daysOverdue = isDue ? Math.floor((now.getTime() - paidUntil.getTime()) / (1000 * 60 * 60 * 24)) : null; return { isDue, isOverdue: isDue && daysOverdue !== null && daysOverdue > 30, // Consider overdue after 30 days dueDate: paidUntil, paidUntil, daysUntilDue, daysOverdue, amount: await getDuesAmount(member), currency: 'EUR', source: 'database', confidence: 'high' }; } // Check if member has last_dues_paid field and calculate from there if (member.last_dues_paid) { const lastPaid = new Date(member.last_dues_paid); const paidUntil = new Date(lastPaid); paidUntil.setFullYear(paidUntil.getFullYear() + 1); // Assume annual dues const isDue = paidUntil < now; const daysUntilDue = isDue ? null : Math.floor((paidUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); const daysOverdue = isDue ? Math.floor((now.getTime() - paidUntil.getTime()) / (1000 * 60 * 60 * 24)) : null; return { isDue, isOverdue: isDue && daysOverdue !== null && daysOverdue > 30, dueDate: paidUntil, paidUntil, daysUntilDue, daysOverdue, amount: await getDuesAmount(member), currency: 'EUR', source: 'calculated', confidence: 'medium' }; } // Check membership_start_date or member_since as last resort const startDate = member.membership_start_date || member.member_since; if (startDate) { const memberSince = new Date(startDate); // For new members (joined within last year), calculate from join date const oneYearAgo = new Date(); oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); if (memberSince > oneYearAgo) { // New member - first year dues const paidUntil = new Date(memberSince); paidUntil.setFullYear(paidUntil.getFullYear() + 1); const isDue = paidUntil < now; const daysUntilDue = isDue ? null : Math.floor((paidUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); const daysOverdue = isDue ? Math.floor((now.getTime() - paidUntil.getTime()) / (1000 * 60 * 60 * 24)) : null; return { isDue, isOverdue: isDue && daysOverdue !== null && daysOverdue > 30, dueDate: paidUntil, paidUntil, daysUntilDue, daysOverdue, amount: await getDuesAmount(member), currency: 'EUR', source: 'calculated', confidence: 'low' }; } } // No dues information available - return null values instead of guessing console.warn(`[dues-calculator] No dues information available for member ${member.Id || member.email}`); return { isDue: false, isOverdue: false, dueDate: null, paidUntil: null, daysUntilDue: null, daysOverdue: null, amount: await getDuesAmount(member), currency: 'EUR', source: 'fallback', confidence: 'low' }; } /** * Get dues amount for a member based on their membership type */ async function getDuesAmount(member: any): Promise { // Check if member has a specific dues amount set if (member.dues_amount) { return parseFloat(member.dues_amount); } // Check membership type for different rates const membershipType = member.membership_type?.toLowerCase() || 'regular'; // These should come from configuration, not hardcoded const duesRates: Record = { 'regular': 50, 'student': 25, 'senior': 35, 'family': 75, 'corporate': 200, 'lifetime': 0, // Lifetime members don't pay dues 'honorary': 0 // Honorary members don't pay dues }; // Find matching rate or use default for (const [type, amount] of Object.entries(duesRates)) { if (membershipType.includes(type)) { return amount; } } // Default dues amount return 50; } /** * Record a dues payment for a member */ export async function recordDuesPayment(payment: DuesPayment): Promise<{ success: boolean; message: string }> { try { const { updateMember } = await import('~/server/utils/nocodb'); // Update member record with payment information await updateMember(payment.memberId, { last_dues_paid: payment.paymentDate.toISOString(), payment_due_date: payment.paidUntil.toISOString(), dues_amount: payment.amount, last_payment_method: payment.paymentMethod, last_transaction_id: payment.transactionId }); // TODO: Also record in a payments history table when available console.log(`[dues-calculator] Recorded dues payment for member ${payment.memberId}: €${payment.amount} until ${payment.paidUntil.toISOString()}`); return { success: true, message: 'Dues payment recorded successfully' }; } catch (error: any) { console.error('[dues-calculator] Error recording dues payment:', error); return { success: false, message: error.message || 'Failed to record dues payment' }; } } /** * Calculate next dues date based on payment date and membership type */ export function calculateNextDuesDate( paymentDate: Date, membershipType: string = 'regular' ): Date { const nextDue = new Date(paymentDate); // Check for special membership types const type = membershipType.toLowerCase(); if (type.includes('lifetime') || type.includes('honorary')) { // Set to far future date for lifetime/honorary members nextDue.setFullYear(nextDue.getFullYear() + 100); } else if (type.includes('quarterly')) { // Quarterly dues nextDue.setMonth(nextDue.getMonth() + 3); } else if (type.includes('monthly')) { // Monthly dues nextDue.setMonth(nextDue.getMonth() + 1); } else { // Default to annual dues nextDue.setFullYear(nextDue.getFullYear() + 1); } return nextDue; } /** * Get all members with overdue dues */ export async function getOverdueMembers(daysOverdue: number = 0): Promise { try { const { getMembers } = await import('~/server/utils/nocodb'); const allMembers = await getMembers(); if (!allMembers?.list) { return []; } const now = new Date(); const overdueMembers = []; for (const member of allMembers.list) { const status = await calculateDuesStatus(member); if (status.isOverdue && status.daysOverdue !== null && status.daysOverdue >= daysOverdue) { overdueMembers.push({ ...member, duesStatus: status }); } } return overdueMembers; } catch (error) { console.error('[dues-calculator] Error getting overdue members:', error); return []; } } /** * Get members with dues coming due soon */ export async function getMembersDueSoon(daysAhead: number = 30): Promise { try { const { getMembers } = await import('~/server/utils/nocodb'); const allMembers = await getMembers(); if (!allMembers?.list) { return []; } const dueSoonMembers = []; for (const member of allMembers.list) { const status = await calculateDuesStatus(member); if (!status.isDue && status.daysUntilDue !== null && status.daysUntilDue <= daysAhead) { dueSoonMembers.push({ ...member, duesStatus: status }); } } return dueSoonMembers; } catch (error) { console.error('[dues-calculator] Error getting members due soon:', error); return []; } } /** * Bulk update dues dates (for admin use) */ export async function bulkUpdateDuesDates( memberIds: string[], paidUntil: Date ): Promise<{ success: number; failed: number; errors: string[] }> { const results = { success: 0, failed: 0, errors: [] as string[] }; const { updateMember } = await import('~/server/utils/nocodb'); for (const memberId of memberIds) { try { await updateMember(memberId, { payment_due_date: paidUntil.toISOString() }); results.success++; } catch (error: any) { results.failed++; results.errors.push(`Failed to update member ${memberId}: ${error.message}`); } } return results; } /** * Generate dues reminder data for email templates */ export function generateDuesReminderData(member: any, status: DuesStatus): any { return { memberName: `${member.first_name} ${member.last_name}`, email: member.email, dueDate: status.dueDate ? status.dueDate.toLocaleDateString() : 'Not set', daysOverdue: status.daysOverdue, daysUntilDue: status.daysUntilDue, amount: status.amount, currency: status.currency, isOverdue: status.isOverdue, memberSince: member.member_since ? new Date(member.member_since).toLocaleDateString() : 'Unknown', membershipType: member.membership_type || 'Regular' }; }