diff --git a/backend/models/Rental.js b/backend/models/Rental.js
index 653ef2a..27ebeb6 100644
--- a/backend/models/Rental.js
+++ b/backend/models/Rental.js
@@ -55,6 +55,7 @@ const Rental = sequelize.define("Rental", {
type: DataTypes.ENUM(
"pending",
"confirmed",
+ "declined",
"active",
"completed",
"cancelled",
@@ -98,6 +99,9 @@ const Rental = sequelize.define("Rental", {
cancelledAt: {
type: DataTypes.DATE,
},
+ declineReason: {
+ type: DataTypes.TEXT,
+ },
stripePaymentMethodId: {
type: DataTypes.STRING,
},
diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js
index 0cb6d01..0afd412 100644
--- a/backend/routes/rentals.js
+++ b/backend/routes/rentals.js
@@ -296,12 +296,30 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
// Log error but don't fail the request
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send rental request email", {
- error: emailError.message,
+ error: emailError,
rentalId: rental.id,
ownerId: rentalWithDetails.ownerId,
});
}
+ // Send rental request confirmation to renter
+ try {
+ await emailService.sendRentalRequestConfirmationEmail(rentalWithDetails);
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.info("Rental request confirmation sent to renter", {
+ rentalId: rental.id,
+ renterId: rentalWithDetails.renterId,
+ });
+ } catch (emailError) {
+ // Log error but don't fail the request
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.error("Failed to send rental request confirmation email", {
+ error: emailError,
+ rentalId: rental.id,
+ renterId: rentalWithDetails.renterId,
+ });
+ }
+
res.status(201).json(rentalWithDetails);
} catch (error) {
res.status(500).json({ error: "Failed to create rental" });
@@ -477,6 +495,105 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
}
});
+// Decline rental request (owner only)
+router.put("/:id/decline", authenticateToken, async (req, res) => {
+ try {
+ const { reason } = req.body;
+
+ // Validate that reason is provided
+ if (!reason || reason.trim() === "") {
+ return res.status(400).json({
+ error: "A reason for declining is required",
+ });
+ }
+
+ const rental = await Rental.findByPk(req.params.id, {
+ include: [
+ { model: Item, as: "item" },
+ {
+ model: User,
+ as: "owner",
+ attributes: ["id", "username", "firstName", "lastName"],
+ },
+ {
+ model: User,
+ as: "renter",
+ attributes: ["id", "username", "firstName", "lastName", "email"],
+ },
+ ],
+ });
+
+ if (!rental) {
+ return res.status(404).json({ error: "Rental not found" });
+ }
+
+ // Only owner can decline
+ if (rental.ownerId !== req.user.id) {
+ return res
+ .status(403)
+ .json({ error: "Only the item owner can decline rental requests" });
+ }
+
+ // Can only decline pending rentals
+ if (rental.status !== "pending") {
+ return res.status(400).json({
+ error: "Can only decline pending rental requests",
+ });
+ }
+
+ // Update rental status to declined
+ await rental.update({
+ status: "declined",
+ declineReason: reason,
+ });
+
+ const updatedRental = await Rental.findByPk(rental.id, {
+ include: [
+ { model: Item, as: "item" },
+ {
+ model: User,
+ as: "owner",
+ attributes: ["id", "username", "firstName", "lastName"],
+ },
+ {
+ model: User,
+ as: "renter",
+ attributes: ["id", "username", "firstName", "lastName"],
+ },
+ ],
+ });
+
+ // Send decline notification email to renter
+ try {
+ await emailService.sendRentalDeclinedEmail(updatedRental, reason);
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.info("Rental decline notification sent to renter", {
+ rentalId: rental.id,
+ renterId: updatedRental.renterId,
+ });
+ } catch (emailError) {
+ // Log error but don't fail the request
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.error("Failed to send rental decline email", {
+ error: emailError,
+ rentalId: rental.id,
+ renterId: updatedRental.renterId,
+ });
+ }
+
+ res.json(updatedRental);
+ } catch (error) {
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.error("Error declining rental", {
+ error: error.message,
+ stack: error.stack,
+ rentalId: req.params.id,
+ userId: req.user.id,
+ });
+ res.status(500).json({ error: "Failed to decline rental" });
+ }
+});
+
// Owner reviews renter
router.post("/:id/review-renter", authenticateToken, async (req, res) => {
try {
@@ -738,10 +855,15 @@ router.post("/:id/cancel", authenticateToken, async (req, res) => {
try {
const { reason } = req.body;
+ // Validate that reason is provided
+ if (!reason || !reason.trim()) {
+ return res.status(400).json({ error: "Cancellation reason is required" });
+ }
+
const result = await RefundService.processCancellation(
req.params.id,
req.user.id,
- reason
+ reason.trim()
);
// Return the updated rental with refund information
@@ -761,6 +883,27 @@ router.post("/:id/cancel", authenticateToken, async (req, res) => {
],
});
+ // Send cancellation notification emails
+ try {
+ await emailService.sendRentalCancellationEmails(
+ updatedRental,
+ result.refund
+ );
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.info("Cancellation emails sent", {
+ rentalId: updatedRental.id,
+ cancelledBy: updatedRental.cancelledBy,
+ });
+ } catch (emailError) {
+ // Log error but don't fail the request
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.error("Failed to send cancellation emails", {
+ error: emailError.message,
+ rentalId: updatedRental.id,
+ cancelledBy: updatedRental.cancelledBy,
+ });
+ }
+
res.json({
rental: updatedRental,
refund: result.refund,
diff --git a/backend/services/emailService.js b/backend/services/emailService.js
index 13b0411..a14f7d5 100644
--- a/backend/services/emailService.js
+++ b/backend/services/emailService.js
@@ -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 {
Please respond to this request within 24 hours.
`
),
+
+ rentalRequestConfirmation: baseTemplate.replace(
+ "{{content}}",
+ `
+ Hi {{renterName}},
+ Your Rental Request Has Been Submitted!
+ Your request to rent {{itemName}} has been sent to the owner.
+ Item: {{itemName}}
+ Rental Period: {{startDate}} to {{endDate}}
+ Delivery Method: {{deliveryMethod}}
+ Total Amount: \${{totalAmount}}
+ {{paymentMessage}}
+ You'll receive an email notification once the owner responds to your request.
+ View My Rentals
+ `
+ ),
+
+ rentalCancellationConfirmation: baseTemplate.replace(
+ "{{content}}",
+ `
+ Hi {{recipientName}},
+ Rental Cancelled Successfully
+ This confirms that your rental for {{itemName}} has been cancelled.
+ Item: {{itemName}}
+ Start Date: {{startDate}}
+ End Date: {{endDate}}
+ Cancelled On: {{cancelledAt}}
+ {{refundSection}}
+ `
+ ),
+
+ rentalCancellationNotification: baseTemplate.replace(
+ "{{content}}",
+ `
+ Hi {{recipientName}},
+ Rental Cancellation Notice
+ {{cancellationMessage}}
+ Item: {{itemName}}
+ Start Date: {{startDate}}
+ End Date: {{endDate}}
+ Cancelled On: {{cancelledAt}}
+ {{additionalInfo}}
+ If you have any questions or concerns, please reach out to our support team.
+ `
+ ),
+
+ payoutReceived: baseTemplate.replace(
+ "{{content}}",
+ `
+ Hi {{ownerName}},
+ Earnings Received: \${{payoutAmount}}
+ Great news! Your earnings from the rental of {{itemName}} have been transferred to your account.
+ Rental Details
+ Item: {{itemName}}
+ Rental Period: {{startDate}} to {{endDate}}
+ Transfer ID: {{stripeTransferId}}
+ Earnings Breakdown
+ Rental Amount: \${{totalAmount}}
+ Platform Fee (20%): -\${{platformFee}}
+ Your Earnings: \${{payoutAmount}}
+ Funds are typically available in your bank account within 2-3 business days.
+ View Earnings Dashboard
+ Thank you for being a valued member of the RentAll community!
+ `
+ ),
+
+ rentalDeclined: baseTemplate.replace(
+ "{{content}}",
+ `
+ Hi {{renterName}},
+ Rental Request Declined
+ Thank you for your interest in renting {{itemName}}. Unfortunately, the owner is unable to accept your rental request at this time.
+ Request Details
+ Item: {{itemName}}
+ Start Date: {{startDate}}
+ End Date: {{endDate}}
+ Delivery Method: {{deliveryMethod}}
+ {{ownerMessage}}
+
+
What happens next?
+
{{paymentMessage}}
+
We encourage you to explore other similar items available for rent on RentAll. There are many great options waiting for you!
+
+ Browse Available Items
+ If you have any questions or concerns, please don't hesitate to contact our support team.
+ `
+ ),
};
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
+ ? `
+
+
Message from the owner:
+
${declineReason}
+
+ `
+ : "";
+
+ 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 ${itemName}. We apologize for any inconvenience this may cause.`;
+
+ // Only show refund info if rental had a cost
+ if (rental.totalAmount > 0) {
+ additionalInfo = `
+
+
Full Refund Processed
+
You will receive a full refund of $${refundInfo.amount.toFixed(2)}. The refund will appear in your account within 5-10 business days.
+
+
+ `;
+ } else {
+ additionalInfo = `
+
+
This rental has been cancelled by the owner. We apologize for any inconvenience.
+
+
+ `;
+ }
+ } 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 ${itemName}.`;
+ additionalInfo = `
+
+
Your item is now available
+
Your item is now available for other renters to book for these dates.
+
+ `;
+ }
+
+ // 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 = `
+ Refund Information
+ $${refundInfo.amount.toFixed(2)}
+
+
Refund Amount: $${refundInfo.amount.toFixed(2)} (${refundPercentage}% of total)
+
Reason: ${refundInfo.reason}
+
Processing Time: Refunds typically appear within 5-10 business days.
+
+ `;
+ } else {
+ refundSection = `
+ Refund Information
+
+
No Refund Available
+
${refundInfo.reason}
+
+ `;
+ }
+ }
+ // 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);
diff --git a/backend/services/payoutService.js b/backend/services/payoutService.js
index 88d7f8a..9e0f066 100644
--- a/backend/services/payoutService.js
+++ b/backend/services/payoutService.js
@@ -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",
+ },
],
});
diff --git a/backend/templates/emails/payoutReceived.html b/backend/templates/emails/payoutReceived.html
new file mode 100644
index 0000000..52e65fe
--- /dev/null
+++ b/backend/templates/emails/payoutReceived.html
@@ -0,0 +1,419 @@
+
+
+
+
+
+
+ Earnings Received - RentAll
+
+
+
+
+
+
+
+
Hi {{ownerName}},
+
+
Great news! Your earnings have been transferred to your account.
+
+
+
You Earned
+
${{payoutAmount}}
+
+ From rental of {{itemName}}
+
+
+
+
Rental Details
+
+
+ | Item Rented |
+ {{itemName}} |
+
+
+ | Rental Period |
+ {{startDate}} to {{endDate}} |
+
+
+ | Transfer ID |
+ {{stripeTransferId}} |
+
+
+
+
Earnings Breakdown
+
+
+ | Rental Amount (charged to renter) |
+ ${{totalAmount}} |
+
+
+ | Platform Fee (20%) |
+ -${{platformFee}} |
+
+
+ | Your Earnings |
+ ${{payoutAmount}} |
+
+
+
+
+
When will I receive the funds?
+
+ Funds are typically available in your bank account within
+ 2-3 business days from the transfer date.
+
+
+ You can track this transfer in your Stripe Dashboard using the
+ Transfer ID above.
+
+
+
+
+
+
+ Thank you for being a valued member of the RentAll community! Keep
+ sharing your items to earn more.
+
+
+
+
+
+
+
diff --git a/backend/templates/emails/rentalCancellationConfirmation.html b/backend/templates/emails/rentalCancellationConfirmation.html
new file mode 100644
index 0000000..47e3b9b
--- /dev/null
+++ b/backend/templates/emails/rentalCancellationConfirmation.html
@@ -0,0 +1,311 @@
+
+
+
+
+
+
+ Cancellation Confirmed - RentAll
+
+
+
+
+
+
+
+
Hi {{recipientName}},
+
+
+
Your cancellation has been confirmed
+
+
+
Rental Cancelled Successfully
+
+
+ This confirms that your rental for {{itemName}} has
+ been cancelled.
+
+
+
Cancelled Rental Details
+
+
+ | Item |
+ {{itemName}} |
+
+
+ | Start Date |
+ {{startDate}} |
+
+
+ | End Date |
+ {{endDate}} |
+
+
+ | Cancelled On |
+ {{cancelledAt}} |
+
+
+
+ {{refundSection}}
+
+
+ If you have any questions about this cancellation, please don't
+ hesitate to contact our support team.
+
+
+
+
+
+
+
diff --git a/backend/templates/emails/rentalCancellationNotification.html b/backend/templates/emails/rentalCancellationNotification.html
new file mode 100644
index 0000000..d873077
--- /dev/null
+++ b/backend/templates/emails/rentalCancellationNotification.html
@@ -0,0 +1,310 @@
+
+
+
+
+
+
+ Rental Cancelled - RentAll
+
+
+
+
+
+
+
+
Hi {{recipientName}},
+
+
Rental Cancellation Notice
+
+
{{cancellationMessage}}
+
+
Cancelled Rental Details
+
+
+ | Item |
+ {{itemName}} |
+
+
+ | Start Date |
+ {{startDate}} |
+
+
+ | End Date |
+ {{endDate}} |
+
+
+ | Cancelled On |
+ {{cancelledAt}} |
+
+
+
+ {{additionalInfo}}
+
+
+ We understand this may be inconvenient. If you have any questions or
+ concerns, please don't hesitate to reach out to our support team.
+
+
+
+
+
+
+
diff --git a/backend/templates/emails/rentalDeclined.html b/backend/templates/emails/rentalDeclined.html
new file mode 100644
index 0000000..648403c
--- /dev/null
+++ b/backend/templates/emails/rentalDeclined.html
@@ -0,0 +1,317 @@
+
+
+
+
+
+
+ Rental Request Declined - RentAll
+
+
+
+
+
+
+
+
Hi {{renterName}},
+
+
+ Thank you for your interest in renting {{itemName}}.
+ Unfortunately, the owner is unable to accept your rental request at
+ this time.
+
+
+
Request Details
+
+
+ | Item |
+ {{itemName}} |
+
+
+ | Start Date |
+ {{startDate}} |
+
+
+ | End Date |
+ {{endDate}} |
+
+
+ | Delivery Method |
+ {{deliveryMethod}} |
+
+
+
+ {{ownerMessage}}
+
+
+
What happens next?
+
+ {{paymentMessage}}
+
+
+ We encourage you to explore other similar items available for rent
+ on RentAll. There are many great options waiting for you!
+
+
+
+
+
+
+ If you have any questions or concerns, please don't hesitate to
+ contact our support team.
+
+
+
+
+
+
+
diff --git a/backend/templates/emails/rentalRequestConfirmation.html b/backend/templates/emails/rentalRequestConfirmation.html
new file mode 100644
index 0000000..212256b
--- /dev/null
+++ b/backend/templates/emails/rentalRequestConfirmation.html
@@ -0,0 +1,320 @@
+
+
+
+
+
+
+ Rental Request Submitted - RentAll
+
+
+
+
+
+
+
+
Hi {{renterName}},
+
+
+
Your rental request has been submitted!
+
+
+
+ Your request to rent {{itemName}} has been sent to
+ the owner. They'll review your request and respond soon.
+
+
+
Request Details
+
+
+ | Item |
+ {{itemName}} |
+
+
+ | Start Date |
+ {{startDate}} |
+
+
+ | End Date |
+ {{endDate}} |
+
+
+ | Delivery Method |
+ {{deliveryMethod}} |
+
+
+ | Total Amount |
+ ${{totalAmount}} |
+
+
+
+
+
What happens next?
+
+ {{paymentMessage}}
+
+
+ You'll receive an email notification once the owner responds to your
+ request.
+
+
+
+
+
+
+ Need to make changes? If you need to cancel or modify
+ your request, you can do so from the My Rentals page.
+
+
+
+
+
+
+
diff --git a/backend/utils/logger.js b/backend/utils/logger.js
index 0bc9f47..db6a264 100644
--- a/backend/utils/logger.js
+++ b/backend/utils/logger.js
@@ -25,15 +25,43 @@ const level = () => {
return isDevelopment ? 'debug' : process.env.LOG_LEVEL || 'info';
};
+// Custom format to extract stack traces from Error objects in metadata
+const extractErrorStack = winston.format((info) => {
+ // Check if any metadata value is an Error object and extract its stack
+ Object.keys(info).forEach(key => {
+ if (info[key] instanceof Error) {
+ info[key] = {
+ message: info[key].message,
+ stack: info[key].stack,
+ name: info[key].name
+ };
+ }
+ });
+ return info;
+});
+
// Define log format
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
winston.format.colorize({ all: true }),
+ extractErrorStack(),
winston.format.printf((info) => {
- if (info.stack) {
- return `${info.timestamp} ${info.level}: ${info.message}\n${info.stack}`;
+ const { timestamp, level, message, stack, ...metadata } = info;
+ let output = `${timestamp} ${level}: ${message}`;
+
+ // Check for stack trace in the info object itself
+ if (stack) {
+ output += `\n${stack}`;
}
- return `${info.timestamp} ${info.level}: ${info.message}`;
+
+ // Check for Error objects in metadata
+ Object.keys(metadata).forEach(key => {
+ if (metadata[key] && metadata[key].stack) {
+ output += `\n${key}: ${metadata[key].message}\n${metadata[key].stack}`;
+ }
+ });
+
+ return output;
}),
);
@@ -41,6 +69,7 @@ const logFormat = winston.format.combine(
const jsonFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
winston.format.errors({ stack: true }),
+ extractErrorStack(),
winston.format.json()
);
diff --git a/frontend/src/components/DeclineRentalModal.tsx b/frontend/src/components/DeclineRentalModal.tsx
new file mode 100644
index 0000000..60570cd
--- /dev/null
+++ b/frontend/src/components/DeclineRentalModal.tsx
@@ -0,0 +1,252 @@
+import React, { useState, useEffect, useCallback } from "react";
+import { rentalAPI } from "../services/api";
+import { Rental } from "../types";
+
+interface DeclineRentalModalProps {
+ show: boolean;
+ onHide: () => void;
+ rental: Rental;
+ onDeclineComplete: (updatedRental: Rental) => void;
+}
+
+const DeclineRentalModal: React.FC = ({
+ show,
+ onHide,
+ rental,
+ onDeclineComplete,
+}) => {
+ const [processing, setProcessing] = useState(false);
+ const [error, setError] = useState(null);
+ const [reason, setReason] = useState("");
+ const [success, setSuccess] = useState(false);
+ const [updatedRental, setUpdatedRental] = useState(null);
+
+ const handleDecline = async () => {
+ if (!reason.trim()) {
+ setError("Please provide a reason for declining this request");
+ return;
+ }
+
+ try {
+ setProcessing(true);
+ setError(null);
+
+ const response = await rentalAPI.declineRental(rental.id, reason.trim());
+
+ // Store updated rental data for later callback
+ setUpdatedRental(response.data);
+
+ // Show success confirmation
+ setSuccess(true);
+ } catch (error: any) {
+ setError(
+ error.response?.data?.error || "Failed to decline rental request"
+ );
+ } finally {
+ setProcessing(false);
+ }
+ };
+
+ const handleClose = () => {
+ // Call parent callback with updated rental data if we have it
+ if (updatedRental) {
+ onDeclineComplete(updatedRental);
+ }
+
+ // Reset all states when closing
+ setProcessing(false);
+ setError(null);
+ setReason("");
+ setSuccess(false);
+ setUpdatedRental(null);
+ onHide();
+ };
+
+ const handleBackdropClick = useCallback(
+ (e: React.MouseEvent) => {
+ if (e.target === e.currentTarget && !processing) {
+ handleClose();
+ }
+ },
+ [handleClose, processing]
+ );
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === "Escape" && !processing) {
+ handleClose();
+ }
+ },
+ [handleClose, processing]
+ );
+
+ useEffect(() => {
+ if (show) {
+ document.addEventListener("keydown", handleKeyDown);
+ document.body.style.overflow = "hidden";
+ } else {
+ document.removeEventListener("keydown", handleKeyDown);
+ document.body.style.overflow = "unset";
+ }
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ document.body.style.overflow = "unset";
+ };
+ }, [show, handleKeyDown]);
+
+ if (!show) return null;
+
+ return (
+
+
+
+
+
+ {success ? "Request Declined" : "Decline Rental Request"}
+
+
+
+
+ {success ? (
+
+
+
+
+
Request Declined
+
+
+ The renter has been notified that their request was
+ declined.
+
+
+
+ ) : (
+ <>
+
+
Rental Details
+
+
+ Item: {rental.item?.name}
+
+
+ Renter: {rental.renter?.firstName}{" "}
+ {rental.renter?.lastName}
+
+
+ Start:{" "}
+ {new Date(rental.startDateTime).toLocaleString()}
+
+
+ End:{" "}
+ {new Date(rental.endDateTime).toLocaleString()}
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ >
+ )}
+
+
+ {success ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+
+ );
+};
+
+export default DeclineRentalModal;
diff --git a/frontend/src/components/RentalCancellationModal.tsx b/frontend/src/components/RentalCancellationModal.tsx
index 57d7f69..7af2161 100644
--- a/frontend/src/components/RentalCancellationModal.tsx
+++ b/frontend/src/components/RentalCancellationModal.tsx
@@ -39,6 +39,21 @@ const RentalCancellationModal: React.FC = ({
try {
setLoading(true);
setError(null);
+
+ // Check if rental status allows cancellation before making API call
+ if (rental.status !== "pending" && rental.status !== "confirmed") {
+ let errorMessage = "This rental cannot be cancelled";
+ if (rental.status === "active") {
+ errorMessage = "Cannot cancel rental - the rental period has already started";
+ } else if (rental.status === "completed") {
+ errorMessage = "Cannot cancel rental - the rental has already been completed";
+ } else if (rental.status === "cancelled") {
+ errorMessage = "This rental has already been cancelled";
+ }
+ setError(errorMessage);
+ return;
+ }
+
const response = await rentalAPI.getRefundPreview(rental.id);
setRefundPreview(response.data);
} catch (error: any) {
@@ -53,11 +68,18 @@ const RentalCancellationModal: React.FC = ({
const handleCancel = async () => {
if (!refundPreview) return;
+ // Validate reason is provided
+ const trimmedReason = reason.trim();
+ if (!trimmedReason) {
+ setError("Please provide a cancellation reason");
+ return;
+ }
+
try {
setProcessing(true);
setError(null);
- const response = await rentalAPI.cancelRental(rental.id, reason.trim());
+ const response = await rentalAPI.cancelRental(rental.id, trimmedReason);
// Store refund details for confirmation screen
setProcessedRefund({
@@ -262,7 +284,7 @@ const RentalCancellationModal: React.FC = ({