340 lines
9.8 KiB
TypeScript
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'
|
|
};
|
|
} |