emails for rental cancelation, rental declined, rental request confirmation, payout received

This commit is contained in:
jackiettran
2025-10-27 13:07:02 -04:00
parent 407c69aa22
commit 502d84a741
17 changed files with 2690 additions and 45 deletions

View File

@@ -42,6 +42,11 @@ class EmailService {
"damageReportCS.html",
"lostItemCS.html",
"rentalRequest.html",
"rentalRequestConfirmation.html",
"rentalCancellationConfirmation.html",
"rentalCancellationNotification.html",
"rentalDeclined.html",
"payoutReceived.html",
];
for (const templateFile of templateFiles) {
@@ -50,12 +55,14 @@ class EmailService {
const templateContent = await fs.readFile(templatePath, "utf-8");
const templateName = path.basename(templateFile, ".html");
this.templates.set(templateName, templateContent);
console.log(`✓ Loaded template: ${templateName}`);
} catch (error) {
console.warn(`Template ${templateFile} not found, will use fallback`);
console.error(`✗ Failed to load template ${templateFile}:`, error.message);
console.error(` Template path: ${path.join(templatesDir, templateFile)}`);
}
}
console.log(`Loaded ${this.templates.size} email templates`);
console.log(`Loaded ${this.templates.size} of ${templateFiles.length} email templates`);
} catch (error) {
console.warn("Templates directory not found, using fallback templates");
}
@@ -290,6 +297,93 @@ class EmailService {
<p>Please respond to this request within 24 hours.</p>
`
),
rentalRequestConfirmation: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{renterName}},</p>
<h2>Your Rental Request Has Been Submitted!</h2>
<p>Your request to rent <strong>{{itemName}}</strong> has been sent to the owner.</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p><strong>Delivery Method:</strong> {{deliveryMethod}}</p>
<p><strong>Total Amount:</strong> \${{totalAmount}}</p>
<p>{{paymentMessage}}</p>
<p>You'll receive an email notification once the owner responds to your request.</p>
<p><a href="{{viewRentalsUrl}}" class="button">View My Rentals</a></p>
`
),
rentalCancellationConfirmation: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Rental Cancelled Successfully</h2>
<p>This confirms that your rental for <strong>{{itemName}}</strong> has been cancelled.</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Start Date:</strong> {{startDate}}</p>
<p><strong>End Date:</strong> {{endDate}}</p>
<p><strong>Cancelled On:</strong> {{cancelledAt}}</p>
{{refundSection}}
`
),
rentalCancellationNotification: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Rental Cancellation Notice</h2>
<p>{{cancellationMessage}}</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Start Date:</strong> {{startDate}}</p>
<p><strong>End Date:</strong> {{endDate}}</p>
<p><strong>Cancelled On:</strong> {{cancelledAt}}</p>
{{additionalInfo}}
<p>If you have any questions or concerns, please reach out to our support team.</p>
`
),
payoutReceived: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{ownerName}},</p>
<h2 style="color: #28a745;">Earnings Received: \${{payoutAmount}}</h2>
<p>Great news! Your earnings from the rental of <strong>{{itemName}}</strong> have been transferred to your account.</p>
<h3>Rental Details</h3>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p><strong>Transfer ID:</strong> {{stripeTransferId}}</p>
<h3>Earnings Breakdown</h3>
<p><strong>Rental Amount:</strong> \${{totalAmount}}</p>
<p><strong>Platform Fee (20%):</strong> -\${{platformFee}}</p>
<p style="font-size: 18px; color: #28a745;"><strong>Your Earnings:</strong> \${{payoutAmount}}</p>
<p>Funds are typically available in your bank account within 2-3 business days.</p>
<p><a href="{{earningsDashboardUrl}}" class="button">View Earnings Dashboard</a></p>
<p>Thank you for being a valued member of the RentAll community!</p>
`
),
rentalDeclined: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{renterName}},</p>
<h2>Rental Request Declined</h2>
<p>Thank you for your interest in renting <strong>{{itemName}}</strong>. Unfortunately, the owner is unable to accept your rental request at this time.</p>
<h3>Request Details</h3>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Start Date:</strong> {{startDate}}</p>
<p><strong>End Date:</strong> {{endDate}}</p>
<p><strong>Delivery Method:</strong> {{deliveryMethod}}</p>
{{ownerMessage}}
<div class="info-box">
<p><strong>What happens next?</strong></p>
<p>{{paymentMessage}}</p>
<p>We encourage you to explore other similar items available for rent on RentAll. There are many great options waiting for you!</p>
</div>
<p style="text-align: center;"><a href="{{browseItemsUrl}}" class="button">Browse Available Items</a></p>
<p>If you have any questions or concerns, please don't hesitate to contact our support team.</p>
`
),
};
return (
@@ -456,6 +550,7 @@ class EmailService {
? parseFloat(rental.payoutAmount).toFixed(2)
: "0.00",
deliveryMethod: rental.deliveryMethod || "Not specified",
rentalNotes: rental.notes || "No additional notes provided",
approveUrl: approveUrl,
};
@@ -468,6 +563,382 @@ class EmailService {
);
}
async sendRentalRequestConfirmationEmail(rental) {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const viewRentalsUrl = `${frontendUrl}/my-rentals`;
// Fetch renter details
const renter = await User.findByPk(rental.renterId, {
attributes: ["email", "firstName", "lastName"],
});
if (!renter) {
console.error(
"Renter not found for rental request confirmation notification"
);
return { success: false, error: "Renter not found" };
}
// Determine payment message based on rental amount
const totalAmount = parseFloat(rental.totalAmount) || 0;
const paymentMessage =
totalAmount > 0
? "The owner will review your request. You'll only be charged if they approve it."
: "The owner will review your request and respond soon.";
const variables = {
renterName: renter.firstName || "there",
itemName: rental.item?.name || "the item",
startDate: rental.startDateTime
? new Date(rental.startDateTime).toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
: "Not specified",
endDate: rental.endDateTime
? new Date(rental.endDateTime).toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
: "Not specified",
totalAmount: totalAmount.toFixed(2),
deliveryMethod: rental.deliveryMethod || "Not specified",
paymentMessage: paymentMessage,
viewRentalsUrl: viewRentalsUrl,
};
const htmlContent = this.renderTemplate(
"rentalRequestConfirmation",
variables
);
return await this.sendEmail(
renter.email,
`Rental Request Submitted - ${rental.item?.name || "Item"}`,
htmlContent
);
}
async sendRentalDeclinedEmail(rental, declineReason) {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const browseItemsUrl = `${frontendUrl}/`;
// Fetch renter details
const renter = await User.findByPk(rental.renterId, {
attributes: ["email", "firstName", "lastName"],
});
if (!renter) {
console.error(
"Renter not found for rental decline notification"
);
return { success: false, error: "Renter not found" };
}
// Determine payment message based on rental amount
const totalAmount = parseFloat(rental.totalAmount) || 0;
const paymentMessage =
totalAmount > 0
? "Since your request was declined before payment was processed, you will not be charged."
: "No payment was required for this rental request.";
// Build owner message section if decline reason provided
const ownerMessage = declineReason
? `
<div class="notice-box">
<p><strong>Message from the owner:</strong></p>
<p>${declineReason}</p>
</div>
`
: "";
const variables = {
renterName: renter.firstName || "there",
itemName: rental.item?.name || "the item",
startDate: rental.startDateTime
? new Date(rental.startDateTime).toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
: "Not specified",
endDate: rental.endDateTime
? new Date(rental.endDateTime).toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
: "Not specified",
deliveryMethod: rental.deliveryMethod || "Not specified",
paymentMessage: paymentMessage,
ownerMessage: ownerMessage,
browseItemsUrl: browseItemsUrl,
payoutAmount: rental.payoutAmount
? parseFloat(rental.payoutAmount).toFixed(2)
: "0.00",
totalAmount: totalAmount.toFixed(2),
};
const htmlContent = this.renderTemplate(
"rentalDeclined",
variables
);
return await this.sendEmail(
renter.email,
`Rental Request Declined - ${rental.item?.name || "Item"}`,
htmlContent
);
}
async sendPayoutReceivedEmail(rental) {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const earningsDashboardUrl = `${frontendUrl}/earnings`;
// Fetch owner details
const owner = await User.findByPk(rental.ownerId, {
attributes: ["email", "firstName", "lastName"],
});
if (!owner) {
console.error("Owner not found for payout notification");
return { success: false, error: "Owner not found" };
}
// Format currency values
const totalAmount = parseFloat(rental.totalAmount) || 0;
const platformFee = parseFloat(rental.platformFee) || 0;
const payoutAmount = parseFloat(rental.payoutAmount) || 0;
const variables = {
ownerName: owner.firstName || "there",
itemName: rental.item?.name || "your item",
startDate: rental.startDateTime
? new Date(rental.startDateTime).toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
: "Not specified",
endDate: rental.endDateTime
? new Date(rental.endDateTime).toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
: "Not specified",
totalAmount: totalAmount.toFixed(2),
platformFee: platformFee.toFixed(2),
payoutAmount: payoutAmount.toFixed(2),
stripeTransferId: rental.stripeTransferId || "N/A",
earningsDashboardUrl: earningsDashboardUrl,
};
const htmlContent = this.renderTemplate("payoutReceived", variables);
return await this.sendEmail(
owner.email,
`Earnings Received - $${payoutAmount.toFixed(2)} for ${rental.item?.name || "Your Item"}`,
htmlContent
);
}
async sendRentalCancellationEmails(rental, refundInfo) {
const results = {
confirmationEmailSent: false,
notificationEmailSent: false,
};
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const browseUrl = `${frontendUrl}/`;
// Fetch both owner and renter details
const owner = await User.findByPk(rental.ownerId, {
attributes: ["email", "firstName", "lastName"],
});
const renter = await User.findByPk(rental.renterId, {
attributes: ["email", "firstName", "lastName"],
});
if (!owner || !renter) {
console.error(
"Owner or renter not found for rental cancellation emails"
);
return { success: false, error: "User not found" };
}
const cancelledBy = rental.cancelledBy; // 'owner' or 'renter'
const itemName = rental.item?.name || "the item";
const startDate = rental.startDateTime
? new Date(rental.startDateTime).toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
: "Not specified";
const endDate = rental.endDateTime
? new Date(rental.endDateTime).toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
: "Not specified";
const cancelledAt = rental.cancelledAt
? new Date(rental.cancelledAt).toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
: "Not specified";
// Determine who gets confirmation and who gets notification
let confirmationRecipient, notificationRecipient;
let confirmationRecipientName, notificationRecipientName;
let cancellationMessage, additionalInfo;
if (cancelledBy === "owner") {
// Owner cancelled: owner gets confirmation, renter gets notification
confirmationRecipient = owner.email;
confirmationRecipientName = owner.firstName || "there";
notificationRecipient = renter.email;
notificationRecipientName = renter.firstName || "there";
cancellationMessage = `The owner has cancelled the rental for <strong>${itemName}</strong>. We apologize for any inconvenience this may cause.`;
// Only show refund info if rental had a cost
if (rental.totalAmount > 0) {
additionalInfo = `
<div class="info-box">
<p><strong>Full Refund Processed</strong></p>
<p>You will receive a full refund of $${refundInfo.amount.toFixed(2)}. The refund will appear in your account within 5-10 business days.</p>
</div>
<div style="text-align: center">
<a href="${browseUrl}" class="button">Browse Other Items</a>
</div>
`;
} else {
additionalInfo = `
<div class="info-box">
<p>This rental has been cancelled by the owner. We apologize for any inconvenience.</p>
</div>
<div style="text-align: center">
<a href="${browseUrl}" class="button">Browse Other Items</a>
</div>
`;
}
} else {
// Renter cancelled: renter gets confirmation, owner gets notification
confirmationRecipient = renter.email;
confirmationRecipientName = renter.firstName || "there";
notificationRecipient = owner.email;
notificationRecipientName = owner.firstName || "there";
cancellationMessage = `The renter has cancelled their rental for <strong>${itemName}</strong>.`;
additionalInfo = `
<div class="info-box">
<p><strong>Your item is now available</strong></p>
<p>Your item is now available for other renters to book for these dates.</p>
</div>
`;
}
// Build refund section for confirmation email (only for paid rentals)
let refundSection = "";
if (rental.totalAmount > 0) {
if (refundInfo.amount > 0) {
const refundPercentage = (refundInfo.percentage * 100).toFixed(0);
refundSection = `
<h2>Refund Information</h2>
<div class="refund-amount">$${refundInfo.amount.toFixed(2)}</div>
<div class="info-box">
<p><strong>Refund Amount:</strong> $${refundInfo.amount.toFixed(2)} (${refundPercentage}% of total)</p>
<p><strong>Reason:</strong> ${refundInfo.reason}</p>
<p><strong>Processing Time:</strong> Refunds typically appear within 5-10 business days.</p>
</div>
`;
} else {
refundSection = `
<h2>Refund Information</h2>
<div class="warning-box">
<p><strong>No Refund Available</strong></p>
<p>${refundInfo.reason}</p>
</div>
`;
}
}
// For free rentals (totalAmount = 0), refundSection stays empty
// Send confirmation email to canceller
try {
const confirmationVariables = {
recipientName: confirmationRecipientName,
itemName: itemName,
startDate: startDate,
endDate: endDate,
cancelledAt: cancelledAt,
refundSection: refundSection,
};
const confirmationHtml = this.renderTemplate(
"rentalCancellationConfirmation",
confirmationVariables
);
const confirmationResult = await this.sendEmail(
confirmationRecipient,
`Cancellation Confirmed - ${itemName}`,
confirmationHtml
);
if (confirmationResult.success) {
console.log(
`Cancellation confirmation email sent to ${cancelledBy}: ${confirmationRecipient}`
);
results.confirmationEmailSent = true;
}
} catch (error) {
console.error(
`Failed to send cancellation confirmation email to ${cancelledBy}:`,
error.message
);
}
// Send notification email to other party
try {
const notificationVariables = {
recipientName: notificationRecipientName,
itemName: itemName,
startDate: startDate,
endDate: endDate,
cancelledAt: cancelledAt,
cancellationMessage: cancellationMessage,
additionalInfo: additionalInfo,
};
const notificationHtml = this.renderTemplate(
"rentalCancellationNotification",
notificationVariables
);
const notificationResult = await this.sendEmail(
notificationRecipient,
`Rental Cancelled - ${itemName}`,
notificationHtml
);
if (notificationResult.success) {
console.log(
`Cancellation notification email sent to ${cancelledBy === "owner" ? "renter" : "owner"}: ${notificationRecipient}`
);
results.notificationEmailSent = true;
}
} catch (error) {
console.error(
`Failed to send cancellation notification email:`,
error.message
);
}
} catch (error) {
console.error("Error sending cancellation emails:", error);
}
return results;
}
async sendTemplateEmail(toEmail, subject, templateName, variables = {}) {
const htmlContent = this.renderTemplate(templateName, variables);
return await this.sendEmail(toEmail, subject, htmlContent);

View File

@@ -1,5 +1,6 @@
const { Rental, User } = require("../models");
const { Rental, User, Item } = require("../models");
const StripeService = require("./stripeService");
const emailService = require("./emailService");
const { Op } = require("sequelize");
class PayoutService {
@@ -21,6 +22,10 @@ class PayoutService {
},
},
},
{
model: Item,
as: "item",
},
],
});
@@ -75,6 +80,20 @@ class PayoutService {
`Payout completed for rental ${rental.id}: $${rental.payoutAmount} to ${rental.owner.stripeConnectedAccountId}`
);
// Send payout notification email to owner
try {
await emailService.sendPayoutReceivedEmail(rental);
console.log(
`Payout notification email sent to owner for rental ${rental.id}`
);
} catch (emailError) {
// Log error but don't fail the payout
console.error(
`Failed to send payout notification email for rental ${rental.id}:`,
emailError.message
);
}
return {
success: true,
transferId: transfer.id,
@@ -151,6 +170,10 @@ class PayoutService {
},
},
},
{
model: Item,
as: "item",
},
],
});