Implement dues reminder system with monthly payment cycle
Build And Push Image / docker (push) Failing after 1m10s Details

- Add API endpoint and email templates for dues reminders
- Change due date calculation from yearly to monthly billing
- Add visual status indicators for overdue and due-soon members
- Enhance member cards with status stripes and styling
This commit is contained in:
Matt 2025-08-15 14:39:22 +02:00
parent 7784fab23f
commit 888059a612
7 changed files with 523 additions and 6 deletions

View File

@ -254,10 +254,10 @@ watch(duesPaid, (newValue) => {
} else { } else {
form.value['Membership Date Paid'] = ''; form.value['Membership Date Paid'] = '';
if (!form.value['Payment Due Date']) { if (!form.value['Payment Due Date']) {
// Set due date to one year from member since date or today // Set due date to one month from member since date or today
const memberSince = form.value['Member Since'] || new Date().toISOString().split('T')[0]; const memberSince = form.value['Member Since'] || new Date().toISOString().split('T')[0];
const dueDate = new Date(memberSince); const dueDate = new Date(memberSince);
dueDate.setFullYear(dueDate.getFullYear() + 1); dueDate.setMonth(dueDate.getMonth() + 1);
form.value['Payment Due Date'] = dueDate.toISOString().split('T')[0]; form.value['Payment Due Date'] = dueDate.toISOString().split('T')[0];
} }
} }

View File

@ -415,10 +415,10 @@ watch(duesPaid, (newValue) => {
} else { } else {
form.value.membership_date_paid = ''; form.value.membership_date_paid = '';
if (!form.value.payment_due_date) { if (!form.value.payment_due_date) {
// Set due date to one year from member since date or today // Set due date to one month from member since date or today
const memberSince = form.value.member_since || new Date().toISOString().split('T')[0]; const memberSince = form.value.member_since || new Date().toISOString().split('T')[0];
const dueDate = new Date(memberSince); const dueDate = new Date(memberSince);
dueDate.setFullYear(dueDate.getFullYear() + 1); dueDate.setMonth(dueDate.getMonth() + 1);
form.value.payment_due_date = dueDate.toISOString().split('T')[0]; form.value.payment_due_date = dueDate.toISOString().split('T')[0];
} }
} }

View File

@ -1,10 +1,23 @@
<template> <template>
<v-card <v-card
class="member-card" class="member-card"
:class="{ 'member-card--inactive': !isActive }" :class="{
'member-card--inactive': !isActive,
'member-card--overdue': isOverdue,
'member-card--due-soon': isDuesComingDue
}"
elevation="2" elevation="2"
@click="$emit('view', member)" @click="$emit('view', member)"
> >
<!-- Status Stripe -->
<div
v-if="isOverdue || isDuesComingDue"
class="status-stripe"
:class="{
'status-stripe--overdue': isOverdue,
'status-stripe--due-soon': isDuesComingDue
}"
/>
<!-- Member Status Badge --> <!-- Member Status Badge -->
<div class="member-status-badge"> <div class="member-status-badge">
<v-chip <v-chip
@ -547,4 +560,33 @@ const formatDate = (dateString: string): string => {
.text-error { .text-error {
color: rgb(var(--v-theme-error)) !important; color: rgb(var(--v-theme-error)) !important;
} }
/* Status Stripe Styles */
.status-stripe {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
z-index: 2;
border-radius: 12px 0 0 12px;
}
.status-stripe--overdue {
background: linear-gradient(180deg, #f44336 0%, #d32f2f 100%);
box-shadow: 2px 0 8px rgba(244, 67, 54, 0.3);
}
.status-stripe--due-soon {
background: linear-gradient(180deg, #ff9800 0%, #f57c00 100%);
box-shadow: 2px 0 8px rgba(255, 152, 0, 0.3);
}
.member-card--overdue {
border-left: 4px solid #f44336;
}
.member-card--due-soon {
border-left: 4px solid #ff9800;
}
</style> </style>

View File

@ -0,0 +1,177 @@
import { sendEmail } from '~/server/utils/email';
import { createNocoDBClient } from '~/server/utils/nocodb';
import { calculateDaysUntilDue, calculateOverdueDays, getNextDuesDate, isPaymentOverOneYear, isDuesActuallyCurrent } from '~/utils/dues-calculations';
import { getRegistrationConfig } from '~/server/utils/admin-config';
export default defineEventHandler(async (event) => {
try {
const memberId = getRouterParam(event, 'id');
const { reminderType } = await readBody(event);
if (!memberId) {
throw createError({
statusCode: 400,
statusMessage: 'Member ID is required'
});
}
if (!['due-soon', 'overdue'].includes(reminderType)) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid reminder type. Must be "due-soon" or "overdue"'
});
}
// Get member data
const nocodb = createNocoDBClient();
const member = await nocodb.findOne('nc_members', memberId);
if (!member) {
throw createError({
statusCode: 404,
statusMessage: 'Member not found'
});
}
if (!member.email) {
throw createError({
statusCode: 400,
statusMessage: 'Member does not have an email address'
});
}
// Get current admin configuration for payment details
const registrationConfig = getRegistrationConfig();
// Calculate dues status
const memberName = member.FullName || `${member.first_name} ${member.last_name}`.trim() || 'Member';
const nextDueDate = getNextDuesDate(member);
const membershipFee = `${registrationConfig.membershipFee}`;
const paymentIban = registrationConfig.iban;
const accountHolder = registrationConfig.accountHolder;
const portalUrl = `${process.env.NUXT_PUBLIC_DOMAIN || 'https://monacousa.org'}/dashboard`;
const currentYear = new Date().getFullYear();
// Format dates
const formatDate = (dateString: string | Date | null | undefined): string => {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch {
return String(dateString);
}
};
let emailData;
let subject;
let template;
if (reminderType === 'due-soon') {
// Check if this member actually has dues coming due
const daysUntilDue = calculateDaysUntilDue(member);
if (!daysUntilDue || daysUntilDue.daysUntilDue <= 0) {
throw createError({
statusCode: 400,
statusMessage: 'Member does not have dues coming due soon'
});
}
subject = `🏎️ MonacoUSA - Annual Dues Due in ${daysUntilDue.daysUntilDue} Days`;
template = 'dues-due-soon';
emailData = {
memberName,
memberId: member.member_id || member.Id,
memberEmail: member.email,
memberSince: formatDate(member.member_since),
amount: membershipFee,
dueDate: formatDate(daysUntilDue.nextDueDate),
daysUntilDue: daysUntilDue.daysUntilDue,
paymentIban,
accountHolder,
portalUrl,
currentYear
};
} else if (reminderType === 'overdue') {
// Check if this member is actually overdue
const isDuesCurrent = isDuesActuallyCurrent(member);
if (isDuesCurrent) {
throw createError({
statusCode: 400,
statusMessage: 'Member does not have overdue dues'
});
}
const overdueDays = calculateOverdueDays(member);
const originalDueDate = member.payment_due_date || nextDueDate;
subject = `🚨 MonacoUSA - URGENT: Overdue Dues Notice (${overdueDays} days overdue)`;
template = 'dues-overdue';
emailData = {
memberName,
memberId: member.member_id || member.Id,
memberEmail: member.email,
memberSince: formatDate(member.member_since),
amount: membershipFee,
originalDueDate: formatDate(originalDueDate),
daysOverdue: overdueDays,
paymentIban,
accountHolder,
portalUrl,
currentYear
};
}
// Send the email
console.log(`[Dues Reminder] Sending ${reminderType} reminder to ${member.email}`, {
memberId,
memberName,
reminderType,
emailData
});
await sendEmail({
to: member.email,
subject,
template,
data: emailData
});
// Log the reminder sent (could store in database for tracking)
console.log(`[Dues Reminder] Successfully sent ${reminderType} reminder to ${memberName} (${member.email})`);
return {
success: true,
message: `${reminderType === 'due-soon' ? 'Due soon' : 'Overdue'} reminder sent successfully`,
data: {
memberId,
memberName,
memberEmail: member.email,
reminderType,
sentAt: new Date().toISOString()
}
};
} catch (error: any) {
console.error('[Dues Reminder] Error sending reminder:', error);
// Handle specific error cases
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to send dues reminder'
});
}
});

View File

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dues Payment Reminder - MonacoUSA</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #a31515 0%, #d32f2f 100%);
color: white;
padding: 30px;
text-align: center;
border-radius: 10px 10px 0 0;
}
.content {
background: #ffffff;
padding: 30px;
border: 1px solid #ddd;
border-radius: 0 0 10px 10px;
}
.logo {
max-width: 200px;
margin-bottom: 20px;
}
.alert-warning {
background-color: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
}
.member-info {
background: #f8f9fa;
padding: 20px;
border-radius: 5px;
margin: 20px 0;
}
.amount-due {
font-size: 24px;
font-weight: bold;
color: #a31515;
text-align: center;
background: #f8f9fa;
padding: 20px;
border-radius: 5px;
margin: 20px 0;
}
.footer {
text-align: center;
color: #666;
font-size: 14px;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #ddd;
}
.btn {
display: inline-block;
background: linear-gradient(135deg, #a31515 0%, #d32f2f 100%);
color: white;
padding: 12px 30px;
text-decoration: none;
border-radius: 5px;
font-weight: bold;
margin: 10px;
}
.btn:hover {
background: linear-gradient(135deg, #8a1212 0%, #b71c1c 100%);
}
</style>
</head>
<body>
<div class="header">
<h1>🏎️ MonacoUSA</h1>
<h2>Annual Dues Payment Reminder</h2>
</div>
<div class="content">
<div class="alert-warning">
<strong>⏰ Payment Due Soon!</strong> Your annual membership dues will be due in {{daysUntilDue}} days.
</div>
<p>Dear {{memberName}},</p>
<p>This is a friendly reminder that your annual MonacoUSA membership dues will be due soon.</p>
<div class="member-info">
<h3>Member Information</h3>
<p><strong>Name:</strong> {{memberName}}</p>
<p><strong>Member ID:</strong> {{memberId}}</p>
<p><strong>Email:</strong> {{memberEmail}}</p>
<p><strong>Member Since:</strong> {{memberSince}}</p>
</div>
<div class="amount-due">
Annual Dues: {{amount}}
<br>
<small style="font-size: 16px; color: #666;">Due Date: {{dueDate}}</small>
</div>
<p>To ensure uninterrupted access to MonacoUSA benefits and avoid any service disruption, please submit your payment before the due date.</p>
<div class="member-info">
<h3>Payment Information</h3>
<p>Membership fee is {{amount}}/yr per person, paid using the IBAN below:</p>
<p><strong>IBAN Euro:</strong> {{paymentIban}}</p>
<p><strong>Account Holder:</strong> {{accountHolder}}</p>
<p><strong>Payment Reference:</strong> {{memberId}} - Annual Dues</p>
</div>
<p><strong>Important:</strong> Please include your member ID ({{memberId}}) in the payment reference to ensure proper credit to your account.</p>
<p>If you have already made your payment, please disregard this reminder. If you have any questions about your membership or payment, please contact us:</p>
<p><strong>Contact Information:</strong></p>
<p>📧 Email: info@monacousa.org</p>
<p>Thank you for your continued membership with MonacoUSA!</p>
<p>Best regards,<br>
The MonacoUSA Team</p>
</div>
<div class="footer">
<p>This is an automated message from MonacoUSA. Please do not reply to this email.</p>
<p>© {{currentYear}} MonacoUSA. All rights reserved.</p>
<p>If you no longer wish to receive these reminders, please contact us at info@monacousa.org</p>
</div>
</body>
</html>

View File

@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Overdue Dues Notice - MonacoUSA</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #d32f2f 0%, #f44336 100%);
color: white;
padding: 30px;
text-align: center;
border-radius: 10px 10px 0 0;
}
.content {
background: #ffffff;
padding: 30px;
border: 1px solid #ddd;
border-radius: 0 0 10px 10px;
}
.logo {
max-width: 200px;
margin-bottom: 20px;
}
.alert-danger {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
}
.member-info {
background: #f8f9fa;
padding: 20px;
border-radius: 5px;
margin: 20px 0;
}
.amount-due {
font-size: 24px;
font-weight: bold;
color: #d32f2f;
text-align: center;
background: #ffebee;
padding: 20px;
border-radius: 5px;
margin: 20px 0;
border: 2px solid #f5c6cb;
}
.urgency-notice {
background: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
text-align: center;
font-weight: bold;
}
.footer {
text-align: center;
color: #666;
font-size: 14px;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #ddd;
}
.btn {
display: inline-block;
background: linear-gradient(135deg, #d32f2f 0%, #f44336 100%);
color: white;
padding: 15px 35px;
text-decoration: none;
border-radius: 5px;
font-weight: bold;
margin: 10px;
font-size: 16px;
}
.btn:hover {
background: linear-gradient(135deg, #b71c1c 0%, #d32f2f 100%);
}
.consequences {
background: #ffebee;
border-left: 4px solid #f44336;
padding: 15px;
margin: 20px 0;
}
</style>
</head>
<body>
<div class="header">
<h1>🏎️ MonacoUSA</h1>
<h2>⚠️ Overdue Dues Notice</h2>
</div>
<div class="content">
<div class="alert-danger">
<strong>🚨 URGENT: Payment Overdue!</strong> Your annual membership dues are now {{daysOverdue}} days overdue.
</div>
<p>Dear {{memberName}},</p>
<p>This is an urgent notice that your annual MonacoUSA membership dues are now overdue and require immediate payment to avoid service interruption.</p>
<div class="member-info">
<h3>Member Information</h3>
<p><strong>Name:</strong> {{memberName}}</p>
<p><strong>Member ID:</strong> {{memberId}}</p>
<p><strong>Email:</strong> {{memberEmail}}</p>
<p><strong>Member Since:</strong> {{memberSince}}</p>
</div>
<div class="amount-due">
Overdue Amount: {{amount}}
<br>
<small style="font-size: 16px; color: #666;">Original Due Date: {{originalDueDate}}</small>
<br>
<small style="font-size: 14px; color: #d32f2f;">{{daysOverdue}} days overdue</small>
</div>
<div class="member-info">
<h3>Payment Information</h3>
<p>Membership fee is {{amount}}/yr per person, paid using the IBAN below:</p>
<p><strong>IBAN Euro:</strong> {{paymentIban}}</p>
<p><strong>Account Holder:</strong> {{accountHolder}}</p>
<p><strong>Payment Reference:</strong> {{memberId}} - Annual Dues</p>
</div>
<p><strong>Important:</strong> Please include your member ID ({{memberId}}) in the payment reference to ensure proper credit to your account.</p>
<div class="consequences">
<p><strong>Important:</strong> Failure of payment will lead to a revoking of MonacoUSA Member Privileges.</p>
</div>
<p>If you have already submitted payment, please forward your payment confirmation to info@monacousa.org to update your account status.</p>
<p><strong>Questions or Concerns?</strong> Contact us:</p>
<p>📧 Email: info@monacousa.org</p>
<p>We value your membership and hope to resolve this matter promptly.</p>
<p>Best regards,<br>
The MonacoUSA Membership Team</p>
</div>
<div class="footer">
<p>This is an automated notice from MonacoUSA. Please do not reply to this email.</p>
<p>© {{currentYear}} MonacoUSA. All rights reserved.</p>
<p>Please submit your payment to avoid membership suspension.</p>
</div>
</body>
</html>

View File

@ -3,7 +3,7 @@ import type { Member, DuesCalculationUtils } from '~/utils/types';
// Constants for date calculations // Constants for date calculations
export const MILLISECONDS_PER_DAY = 1000 * 60 * 60 * 24; export const MILLISECONDS_PER_DAY = 1000 * 60 * 60 * 24;
export const DAYS_IN_YEAR = 365; export const DAYS_IN_YEAR = 365;
export const UPCOMING_THRESHOLD_DAYS = 30; export const UPCOMING_THRESHOLD_DAYS = 30; // Trigger reminders 1 month before yearly due date
/** /**
* Calculate days between two dates * Calculate days between two dates