failed payment method handling
This commit is contained in:
@@ -11,8 +11,11 @@ const RefundService = require("../services/refundService");
|
||||
const LateReturnService = require("../services/lateReturnService");
|
||||
const PayoutService = require("../services/payoutService");
|
||||
const DamageAssessmentService = require("../services/damageAssessmentService");
|
||||
const StripeWebhookService = require("../services/stripeWebhookService");
|
||||
const StripeService = require("../services/stripeService");
|
||||
const emailServices = require("../services/email");
|
||||
const logger = require("../utils/logger");
|
||||
const { PaymentError } = require("../utils/stripeErrors");
|
||||
const { validateS3Keys } = require("../utils/s3KeyValidator");
|
||||
const { IMAGE_LIMITS } = require("../config/imageLimits");
|
||||
const { isActive, getEffectiveStatus } = require("../utils/rentalStatus");
|
||||
@@ -106,6 +109,19 @@ router.get("/renting", authenticateToken, async (req, res) => {
|
||||
|
||||
router.get("/owning", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
// Reconcile payout statuses with Stripe before returning data
|
||||
// This handles cases where webhooks were missed
|
||||
try {
|
||||
await StripeWebhookService.reconcilePayoutStatuses(req.user.id);
|
||||
} catch (reconcileError) {
|
||||
// Log but don't fail the request - still return rentals
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Error reconciling payout statuses", {
|
||||
error: reconcileError.message,
|
||||
userId: req.user.id,
|
||||
});
|
||||
}
|
||||
|
||||
const rentals = await Rental.findAll({
|
||||
where: { ownerId: req.user.id },
|
||||
// Remove explicit attributes to let Sequelize handle missing columns gracefully
|
||||
@@ -236,12 +252,16 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
||||
const now = new Date();
|
||||
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
if (rentalStartDateTime < fiveMinutesAgo) {
|
||||
return res.status(400).json({ error: "Start date cannot be in the past" });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Start date cannot be in the past" });
|
||||
}
|
||||
|
||||
// Validate end date/time is after start date/time
|
||||
if (rentalEndDateTime <= rentalStartDateTime) {
|
||||
return res.status(400).json({ error: "End date/time must be after start date/time" });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "End date/time must be after start date/time" });
|
||||
}
|
||||
|
||||
// Calculate rental cost using duration calculator
|
||||
@@ -403,12 +423,24 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"],
|
||||
attributes: [
|
||||
"id",
|
||||
"firstName",
|
||||
"lastName",
|
||||
"email",
|
||||
"stripeConnectedAccountId",
|
||||
],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "renter",
|
||||
attributes: ["id", "firstName", "lastName", "email", "stripeCustomerId"],
|
||||
attributes: [
|
||||
"id",
|
||||
"firstName",
|
||||
"lastName",
|
||||
"email",
|
||||
"stripeCustomerId",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -477,7 +509,13 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"],
|
||||
attributes: [
|
||||
"id",
|
||||
"firstName",
|
||||
"lastName",
|
||||
"email",
|
||||
"stripeConnectedAccountId",
|
||||
],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
@@ -559,14 +597,55 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Payment failed during approval", {
|
||||
error: paymentError.message,
|
||||
code: paymentError.code,
|
||||
stack: paymentError.stack,
|
||||
rentalId: req.params.id,
|
||||
userId: req.user.id,
|
||||
});
|
||||
// Keep rental as pending, but inform of payment failure
|
||||
return res.status(400).json({
|
||||
error: "Payment failed during approval",
|
||||
details: paymentError.message,
|
||||
|
||||
// Determine the renter-facing message
|
||||
const renterMessage =
|
||||
paymentError instanceof PaymentError
|
||||
? paymentError.renterMessage
|
||||
: "Your payment could not be processed. Please try a different payment method.";
|
||||
|
||||
// Track payment failure timestamp
|
||||
await rental.update({ paymentFailedNotifiedAt: new Date() });
|
||||
|
||||
// Auto-send payment declined email to renter
|
||||
try {
|
||||
await emailServices.payment.sendPaymentDeclinedNotification(
|
||||
rental.renter.email,
|
||||
{
|
||||
renterFirstName: rental.renter.firstName,
|
||||
itemName: rental.item.name,
|
||||
declineReason: renterMessage,
|
||||
rentalId: rental.id,
|
||||
}
|
||||
);
|
||||
reqLogger.info("Payment declined email auto-sent to renter", {
|
||||
rentalId: rental.id,
|
||||
renterId: rental.renterId,
|
||||
});
|
||||
} catch (emailError) {
|
||||
reqLogger.error("Failed to send payment declined email", {
|
||||
error: emailError.message,
|
||||
rentalId: rental.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Keep rental as pending, inform owner of payment failure
|
||||
const ownerMessage =
|
||||
paymentError instanceof PaymentError
|
||||
? paymentError.ownerMessage
|
||||
: "The payment could not be processed.";
|
||||
|
||||
return res.status(402).json({
|
||||
error: "payment_failed",
|
||||
code: paymentError.code || "unknown_error",
|
||||
ownerMessage,
|
||||
renterMessage,
|
||||
rentalId: rental.id,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -581,7 +660,13 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"],
|
||||
attributes: [
|
||||
"id",
|
||||
"firstName",
|
||||
"lastName",
|
||||
"email",
|
||||
"stripeConnectedAccountId",
|
||||
],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
@@ -942,7 +1027,9 @@ router.post("/cost-preview", authenticateToken, async (req, res) => {
|
||||
const now = new Date();
|
||||
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
if (rentalStartDateTime < fiveMinutesAgo) {
|
||||
return res.status(400).json({ error: "Start date cannot be in the past" });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Start date cannot be in the past" });
|
||||
}
|
||||
|
||||
// Validate date range
|
||||
@@ -1195,7 +1282,13 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"],
|
||||
attributes: [
|
||||
"id",
|
||||
"firstName",
|
||||
"lastName",
|
||||
"email",
|
||||
"stripeConnectedAccountId",
|
||||
],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
@@ -1411,4 +1504,152 @@ router.post("/:id/report-damage", authenticateToken, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /rentals/:id/payment-method - Renter updates payment method for pending rental
|
||||
router.put("/:id/payment-method", authenticateToken, async (req, res, next) => {
|
||||
try {
|
||||
const rentalId = req.params.id;
|
||||
const { stripePaymentMethodId } = req.body;
|
||||
|
||||
if (!stripePaymentMethodId) {
|
||||
return res.status(400).json({ error: "Payment method ID is required" });
|
||||
}
|
||||
|
||||
const rental = await Rental.findByPk(rentalId, {
|
||||
include: [
|
||||
{ model: Item, as: "item" },
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
attributes: ["id", "firstName", "email"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!rental) {
|
||||
return res.status(404).json({ error: "Rental not found" });
|
||||
}
|
||||
|
||||
// Only renter can update payment method
|
||||
if (rental.renterId !== req.user.id) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only the renter can update the payment method" });
|
||||
}
|
||||
|
||||
// Only for pending rentals with pending payment
|
||||
if (rental.status !== "pending" || rental.paymentStatus !== "pending") {
|
||||
return res.status(400).json({
|
||||
error: "Can only update payment method for pending rentals",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify payment method belongs to renter's Stripe customer
|
||||
const renter = await User.findByPk(req.user.id);
|
||||
if (!renter.stripeCustomerId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "No Stripe customer account found" });
|
||||
}
|
||||
|
||||
let paymentMethod;
|
||||
try {
|
||||
paymentMethod = await StripeService.getPaymentMethod(
|
||||
stripePaymentMethodId
|
||||
);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid payment method" });
|
||||
}
|
||||
|
||||
if (paymentMethod.customer !== renter.stripeCustomerId) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Payment method does not belong to this account" });
|
||||
}
|
||||
|
||||
// Rate limiting: Max 3 updates per rental per hour
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||
if (
|
||||
rental.paymentMethodUpdatedAt &&
|
||||
rental.paymentMethodUpdatedAt > oneHourAgo
|
||||
) {
|
||||
const updateCount = rental.paymentMethodUpdateCount || 0;
|
||||
if (updateCount >= 3) {
|
||||
return res.status(429).json({
|
||||
error: "Too many payment method updates. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Store old payment method for audit log
|
||||
const oldPaymentMethodId = rental.stripePaymentMethodId;
|
||||
|
||||
// Atomic update with status check (prevents race condition)
|
||||
const [updateCount] = await Rental.update(
|
||||
{
|
||||
stripePaymentMethodId,
|
||||
paymentMethodUpdatedAt: new Date(),
|
||||
paymentMethodUpdateCount:
|
||||
rental.paymentMethodUpdatedAt > oneHourAgo
|
||||
? (rental.paymentMethodUpdateCount || 0) + 1
|
||||
: 1,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
id: rentalId,
|
||||
status: "pending",
|
||||
paymentStatus: "pending",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (updateCount === 0) {
|
||||
return res.status(409).json({
|
||||
error: "Rental status changed. Please refresh and try again.",
|
||||
});
|
||||
}
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Payment method updated", {
|
||||
rentalId,
|
||||
userId: req.user.id,
|
||||
oldPaymentMethodId,
|
||||
newPaymentMethodId: stripePaymentMethodId,
|
||||
});
|
||||
|
||||
// Optionally notify owner that payment method was updated
|
||||
try {
|
||||
await emailServices.payment.sendPaymentMethodUpdatedNotification(
|
||||
rental.owner.email,
|
||||
{
|
||||
ownerFirstName: rental.owner.firstName,
|
||||
itemName: rental.item.name,
|
||||
rentalId: rental.id,
|
||||
approvalUrl: `${process.env.FRONTEND_URL}/rentals/${rentalId}`,
|
||||
}
|
||||
);
|
||||
} catch (emailError) {
|
||||
// Don't fail the request if email fails
|
||||
reqLogger.error("Failed to send payment method updated notification", {
|
||||
error: emailError.message,
|
||||
rentalId,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Payment method updated. The owner can now retry approval.",
|
||||
});
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Error updating payment method", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
rentalId: req.params.id,
|
||||
userId: req.user.id,
|
||||
});
|
||||
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user