monacousa-portal/server/utils/dues-calculator.ts

340 lines
9.8 KiB
TypeScript

/**
* 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<DuesStatus> {
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<number> {
// 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<string, number> = {
'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<any[]> {
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<any[]> {
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'
};
}