emails for rental cancelation, rental declined, rental request confirmation, payout received
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user