3D Secure handling

This commit is contained in:
jackiettran
2026-01-08 12:44:57 -05:00
parent 8b9b92d848
commit bcb917c959
14 changed files with 1093 additions and 40 deletions

View File

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