failed payment method handling

This commit is contained in:
jackiettran
2026-01-06 16:13:58 -05:00
parent ec84b8354e
commit 28c0b4976d
14 changed files with 1639 additions and 17 deletions

View File

@@ -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;