3D Secure handling
This commit is contained in:
@@ -493,6 +493,51 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
}
|
||||
);
|
||||
|
||||
// Check if 3DS authentication is required
|
||||
if (paymentResult.requiresAction) {
|
||||
// Store payment intent for later completion
|
||||
await rental.update({
|
||||
stripePaymentIntentId: paymentResult.paymentIntentId,
|
||||
paymentStatus: "requires_action",
|
||||
});
|
||||
|
||||
// Send email to renter (without direct link for security)
|
||||
try {
|
||||
await emailServices.rentalFlow.sendAuthenticationRequiredEmail(
|
||||
rental.renter.email,
|
||||
{
|
||||
renterName: rental.renter.firstName,
|
||||
itemName: rental.item.name,
|
||||
ownerName: rental.owner.firstName,
|
||||
amount: rental.totalAmount,
|
||||
}
|
||||
);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Authentication required email sent to renter", {
|
||||
rentalId: rental.id,
|
||||
renterId: rental.renterId,
|
||||
});
|
||||
} catch (emailError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error(
|
||||
"Failed to send authentication required email",
|
||||
{
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
rentalId: rental.id,
|
||||
renterId: rental.renterId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(402).json({
|
||||
error: "authentication_required",
|
||||
requiresAction: true,
|
||||
message: "The renter's card requires additional authentication.",
|
||||
rentalId: rental.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Update rental with payment completion
|
||||
await rental.update({
|
||||
status: "confirmed",
|
||||
@@ -1652,4 +1697,223 @@ router.put("/:id/payment-method", authenticateToken, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /rentals/:id/payment-client-secret
|
||||
* Returns client secret for 3DS completion (renter only)
|
||||
*/
|
||||
router.get(
|
||||
"/:id/payment-client-secret",
|
||||
authenticateToken,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const rental = await Rental.findByPk(req.params.id, {
|
||||
include: [
|
||||
{ model: User, as: "renter", attributes: ["id", "stripeCustomerId"] },
|
||||
],
|
||||
});
|
||||
|
||||
if (!rental) {
|
||||
return res.status(404).json({ error: "Rental not found" });
|
||||
}
|
||||
|
||||
if (rental.renterId !== req.user.id) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
if (!rental.stripePaymentIntentId) {
|
||||
return res.status(400).json({ error: "No payment intent found" });
|
||||
}
|
||||
|
||||
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
||||
const paymentIntent = await stripe.paymentIntents.retrieve(
|
||||
rental.stripePaymentIntentId
|
||||
);
|
||||
|
||||
return res.json({
|
||||
clientSecret: paymentIntent.client_secret,
|
||||
status: paymentIntent.status,
|
||||
});
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Get client secret error", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
rentalId: req.params.id,
|
||||
userId: req.user.id,
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /rentals/:id/complete-payment
|
||||
* Called after renter completes 3DS authentication
|
||||
*/
|
||||
router.post(
|
||||
"/:id/complete-payment",
|
||||
authenticateToken,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const rental = await Rental.findByPk(req.params.id, {
|
||||
include: [
|
||||
{ model: User, as: "renter", attributes: ["id", "firstName", "lastName", "email", "stripeCustomerId"] },
|
||||
{ model: User, as: "owner", attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId", "stripePayoutsEnabled"] },
|
||||
{ model: Item, as: "item" },
|
||||
],
|
||||
});
|
||||
|
||||
if (!rental) {
|
||||
return res.status(404).json({ error: "Rental not found" });
|
||||
}
|
||||
|
||||
if (rental.renterId !== req.user.id) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
if (rental.paymentStatus !== "requires_action") {
|
||||
return res.status(400).json({
|
||||
error: "Invalid state",
|
||||
message: "This rental is not awaiting payment authentication",
|
||||
});
|
||||
}
|
||||
|
||||
// Retrieve payment intent to check status (expand latest_charge for payment method details)
|
||||
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
||||
const paymentIntent = await stripe.paymentIntents.retrieve(
|
||||
rental.stripePaymentIntentId,
|
||||
{ expand: ['latest_charge.payment_method_details'] }
|
||||
);
|
||||
|
||||
if (paymentIntent.status !== "succeeded") {
|
||||
return res.status(402).json({
|
||||
error: "payment_incomplete",
|
||||
status: paymentIntent.status,
|
||||
message:
|
||||
paymentIntent.status === "requires_action"
|
||||
? "Authentication not yet completed"
|
||||
: "Payment could not be completed",
|
||||
});
|
||||
}
|
||||
|
||||
// Extract payment method details from latest_charge (charges is deprecated)
|
||||
const charge = paymentIntent.latest_charge;
|
||||
const paymentMethodDetails = charge?.payment_method_details;
|
||||
|
||||
let paymentMethodBrand = null;
|
||||
let paymentMethodLast4 = null;
|
||||
if (paymentMethodDetails) {
|
||||
const type = paymentMethodDetails.type;
|
||||
if (type === "card") {
|
||||
paymentMethodBrand = paymentMethodDetails.card?.brand || "card";
|
||||
paymentMethodLast4 = paymentMethodDetails.card?.last4 || null;
|
||||
} else if (type === "us_bank_account") {
|
||||
paymentMethodBrand = "bank_account";
|
||||
paymentMethodLast4 = paymentMethodDetails.us_bank_account?.last4 || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Payment succeeded - complete rental confirmation
|
||||
await rental.update({
|
||||
status: "confirmed",
|
||||
paymentStatus: "paid",
|
||||
chargedAt: new Date(),
|
||||
paymentMethodBrand,
|
||||
paymentMethodLast4,
|
||||
});
|
||||
|
||||
// Send confirmation emails
|
||||
try {
|
||||
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
|
||||
rental.owner,
|
||||
rental.renter,
|
||||
rental
|
||||
);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Rental approval confirmation sent to owner (after 3DS)", {
|
||||
rentalId: rental.id,
|
||||
ownerId: rental.ownerId,
|
||||
});
|
||||
} catch (emailError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error(
|
||||
"Failed to send rental approval confirmation email after 3DS",
|
||||
{
|
||||
error: emailError.message,
|
||||
rentalId: rental.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const renterNotification = {
|
||||
type: "rental_confirmed",
|
||||
title: "Rental Confirmed",
|
||||
message: `Your rental of "${rental.item.name}" has been confirmed.`,
|
||||
rentalId: rental.id,
|
||||
userId: rental.renterId,
|
||||
metadata: { rentalStart: rental.startDateTime },
|
||||
};
|
||||
await emailServices.rentalFlow.sendRentalConfirmation(
|
||||
rental.renter.email,
|
||||
renterNotification,
|
||||
rental,
|
||||
rental.renter.firstName,
|
||||
true
|
||||
);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Rental confirmation sent to renter (after 3DS)", {
|
||||
rentalId: rental.id,
|
||||
renterId: rental.renterId,
|
||||
});
|
||||
} catch (emailError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error(
|
||||
"Failed to send rental confirmation email after 3DS",
|
||||
{
|
||||
error: emailError.message,
|
||||
rentalId: rental.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger payout if owner has payouts enabled
|
||||
if (rental.owner.stripePayoutsEnabled && rental.owner.stripeConnectedAccountId) {
|
||||
try {
|
||||
await PayoutService.processRentalPayout(rental);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Payout processed after 3DS completion", {
|
||||
rentalId: rental.id,
|
||||
ownerId: rental.ownerId,
|
||||
});
|
||||
} catch (payoutError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Payout failed after 3DS completion", {
|
||||
error: payoutError.message,
|
||||
rentalId: rental.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
rental: {
|
||||
id: rental.id,
|
||||
status: "confirmed",
|
||||
paymentStatus: "paid",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Complete payment error", {
|
||||
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