refund and delayed charge
This commit is contained in:
@@ -57,7 +57,7 @@ class PayoutService {
|
||||
metadata: {
|
||||
rentalId: rental.id,
|
||||
ownerId: rental.ownerId,
|
||||
baseAmount: rental.baseRentalAmount.toString(),
|
||||
totalAmount: rental.totalAmount.toString(),
|
||||
platformFee: rental.platformFee.toString(),
|
||||
startDateTime: rental.startDateTime.toISOString(),
|
||||
endDateTime: rental.endDateTime.toISOString(),
|
||||
|
||||
229
backend/services/refundService.js
Normal file
229
backend/services/refundService.js
Normal file
@@ -0,0 +1,229 @@
|
||||
const { Rental } = require("../models");
|
||||
const StripeService = require("./stripeService");
|
||||
|
||||
class RefundService {
|
||||
/**
|
||||
* Calculate refund amount based on policy and who cancelled
|
||||
* @param {Object} rental - Rental instance
|
||||
* @param {string} cancelledBy - 'renter' or 'owner'
|
||||
* @returns {Object} - { refundAmount, refundPercentage, reason }
|
||||
*/
|
||||
static calculateRefundAmount(rental, cancelledBy) {
|
||||
const totalAmount = rental.totalAmount;
|
||||
let refundPercentage = 0;
|
||||
let reason = "";
|
||||
|
||||
if (cancelledBy === "owner") {
|
||||
// Owner cancellation = full refund
|
||||
refundPercentage = 1.0;
|
||||
reason = "Full refund - cancelled by owner";
|
||||
} else if (cancelledBy === "renter") {
|
||||
// Calculate based on time until rental start
|
||||
const now = new Date();
|
||||
const startDateTime = new Date(rental.startDateTime);
|
||||
const hoursUntilStart = (startDateTime - now) / (1000 * 60 * 60);
|
||||
|
||||
if (hoursUntilStart < 24) {
|
||||
refundPercentage = 0.0;
|
||||
reason = "No refund - cancelled within 24 hours of start time";
|
||||
} else if (hoursUntilStart < 48) {
|
||||
refundPercentage = 0.5;
|
||||
reason = "50% refund - cancelled between 24-48 hours of start time";
|
||||
} else {
|
||||
refundPercentage = 1.0;
|
||||
reason = "Full refund - cancelled more than 48 hours before start time";
|
||||
}
|
||||
}
|
||||
|
||||
const refundAmount = parseFloat((totalAmount * refundPercentage).toFixed(2));
|
||||
|
||||
return {
|
||||
refundAmount,
|
||||
refundPercentage,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a rental can be cancelled
|
||||
* @param {Object} rental - Rental instance
|
||||
* @param {string} userId - User ID attempting to cancel
|
||||
* @returns {Object} - { canCancel, reason, cancelledBy }
|
||||
*/
|
||||
static validateCancellationEligibility(rental, userId) {
|
||||
// Check if rental is already cancelled
|
||||
if (rental.status === "cancelled") {
|
||||
return {
|
||||
canCancel: false,
|
||||
reason: "Rental is already cancelled",
|
||||
cancelledBy: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if rental is completed
|
||||
if (rental.status === "completed") {
|
||||
return {
|
||||
canCancel: false,
|
||||
reason: "Cannot cancel completed rental",
|
||||
cancelledBy: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if rental is active
|
||||
if (rental.status === "active") {
|
||||
return {
|
||||
canCancel: false,
|
||||
reason: "Cannot cancel active rental",
|
||||
cancelledBy: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user has permission to cancel
|
||||
let cancelledBy = null;
|
||||
if (rental.renterId === userId) {
|
||||
cancelledBy = "renter";
|
||||
} else if (rental.ownerId === userId) {
|
||||
cancelledBy = "owner";
|
||||
} else {
|
||||
return {
|
||||
canCancel: false,
|
||||
reason: "You are not authorized to cancel this rental",
|
||||
cancelledBy: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Check payment status
|
||||
if (rental.paymentStatus !== "paid") {
|
||||
return {
|
||||
canCancel: false,
|
||||
reason: "Cannot cancel rental that hasn't been paid",
|
||||
cancelledBy: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
canCancel: true,
|
||||
reason: "Cancellation allowed",
|
||||
cancelledBy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the full cancellation and refund
|
||||
* @param {string} rentalId - Rental ID
|
||||
* @param {string} userId - User ID cancelling
|
||||
* @param {string} cancellationReason - Optional reason provided by user
|
||||
* @returns {Object} - Updated rental with refund information
|
||||
*/
|
||||
static async processCancellation(rentalId, userId, cancellationReason = null) {
|
||||
const rental = await Rental.findByPk(rentalId);
|
||||
|
||||
if (!rental) {
|
||||
throw new Error("Rental not found");
|
||||
}
|
||||
|
||||
// Validate cancellation eligibility
|
||||
const eligibility = this.validateCancellationEligibility(rental, userId);
|
||||
if (!eligibility.canCancel) {
|
||||
throw new Error(eligibility.reason);
|
||||
}
|
||||
|
||||
// Calculate refund amount
|
||||
const refundCalculation = this.calculateRefundAmount(
|
||||
rental,
|
||||
eligibility.cancelledBy
|
||||
);
|
||||
|
||||
let stripeRefundId = null;
|
||||
let refundProcessedAt = null;
|
||||
|
||||
// Process refund with Stripe if amount > 0 and we have payment intent ID
|
||||
if (
|
||||
refundCalculation.refundAmount > 0 &&
|
||||
rental.stripePaymentIntentId
|
||||
) {
|
||||
try {
|
||||
const refund = await StripeService.createRefund({
|
||||
paymentIntentId: rental.stripePaymentIntentId,
|
||||
amount: refundCalculation.refundAmount,
|
||||
metadata: {
|
||||
rentalId: rental.id,
|
||||
cancelledBy: eligibility.cancelledBy,
|
||||
refundReason: refundCalculation.reason,
|
||||
},
|
||||
});
|
||||
|
||||
stripeRefundId = refund.id;
|
||||
refundProcessedAt = new Date();
|
||||
} catch (error) {
|
||||
console.error("Error processing Stripe refund:", error);
|
||||
throw new Error(`Failed to process refund: ${error.message}`);
|
||||
}
|
||||
} else if (refundCalculation.refundAmount > 0) {
|
||||
// Log warning if we should refund but don't have payment intent
|
||||
console.warn(
|
||||
`Refund amount calculated but no payment intent ID for rental ${rentalId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Update rental with cancellation and refund info
|
||||
const updatedRental = await rental.update({
|
||||
status: "cancelled",
|
||||
cancelledBy: eligibility.cancelledBy,
|
||||
cancelledAt: new Date(),
|
||||
refundAmount: refundCalculation.refundAmount,
|
||||
refundProcessedAt,
|
||||
refundReason:
|
||||
cancellationReason || refundCalculation.reason,
|
||||
stripeRefundId,
|
||||
// Reset payout status since rental is cancelled
|
||||
payoutStatus: "pending",
|
||||
});
|
||||
|
||||
return {
|
||||
rental: updatedRental,
|
||||
refund: {
|
||||
amount: refundCalculation.refundAmount,
|
||||
percentage: refundCalculation.refundPercentage,
|
||||
reason: refundCalculation.reason,
|
||||
processed: !!refundProcessedAt,
|
||||
stripeRefundId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refund preview without processing
|
||||
* @param {string} rentalId - Rental ID
|
||||
* @param {string} userId - User ID requesting preview
|
||||
* @returns {Object} - Preview of refund calculation
|
||||
*/
|
||||
static async getRefundPreview(rentalId, userId) {
|
||||
const rental = await Rental.findByPk(rentalId);
|
||||
|
||||
if (!rental) {
|
||||
throw new Error("Rental not found");
|
||||
}
|
||||
|
||||
const eligibility = this.validateCancellationEligibility(rental, userId);
|
||||
if (!eligibility.canCancel) {
|
||||
throw new Error(eligibility.reason);
|
||||
}
|
||||
|
||||
const refundCalculation = this.calculateRefundAmount(
|
||||
rental,
|
||||
eligibility.cancelledBy
|
||||
);
|
||||
|
||||
return {
|
||||
canCancel: true,
|
||||
cancelledBy: eligibility.cancelledBy,
|
||||
refundAmount: refundCalculation.refundAmount,
|
||||
refundPercentage: refundCalculation.refundPercentage,
|
||||
reason: refundCalculation.reason,
|
||||
totalAmount: rental.totalAmount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RefundService;
|
||||
@@ -1,44 +1,12 @@
|
||||
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
class StripeService {
|
||||
static async createCheckoutSession({
|
||||
item_name,
|
||||
total,
|
||||
return_url,
|
||||
metadata = {},
|
||||
}) {
|
||||
try {
|
||||
const sessionConfig = {
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
product_data: {
|
||||
name: item_name,
|
||||
},
|
||||
unit_amount: total * 100,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: "payment",
|
||||
ui_mode: "embedded",
|
||||
return_url: return_url,
|
||||
metadata: metadata,
|
||||
};
|
||||
|
||||
const session = await stripe.checkout.sessions.create(sessionConfig);
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error("Error creating checkout session:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getCheckoutSession(sessionId) {
|
||||
try {
|
||||
return await stripe.checkout.sessions.retrieve(sessionId);
|
||||
return await stripe.checkout.sessions.retrieve(sessionId, {
|
||||
expand: ['setup_intent', 'setup_intent.payment_method']
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error retrieving checkout session:", error);
|
||||
throw error;
|
||||
@@ -115,6 +83,97 @@ class StripeService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async createRefund({
|
||||
paymentIntentId,
|
||||
amount,
|
||||
metadata = {},
|
||||
reason = "requested_by_customer",
|
||||
}) {
|
||||
try {
|
||||
const refund = await stripe.refunds.create({
|
||||
payment_intent: paymentIntentId,
|
||||
amount: Math.round(amount * 100), // Convert to cents
|
||||
metadata,
|
||||
reason,
|
||||
});
|
||||
|
||||
return refund;
|
||||
} catch (error) {
|
||||
console.error("Error creating refund:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getRefund(refundId) {
|
||||
try {
|
||||
return await stripe.refunds.retrieve(refundId);
|
||||
} catch (error) {
|
||||
console.error("Error retrieving refund:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async chargePaymentMethod(paymentMethodId, amount, customerId, metadata = {}) {
|
||||
try {
|
||||
// Create a payment intent with the stored payment method
|
||||
const paymentIntent = await stripe.paymentIntents.create({
|
||||
amount: Math.round(amount * 100), // Convert to cents
|
||||
currency: "usd",
|
||||
payment_method: paymentMethodId,
|
||||
customer: customerId, // Include customer ID
|
||||
confirm: true, // Automatically confirm the payment
|
||||
return_url: `${process.env.FRONTEND_URL || 'http://localhost:3000'}/payment-complete`,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
paymentIntentId: paymentIntent.id,
|
||||
status: paymentIntent.status,
|
||||
clientSecret: paymentIntent.client_secret,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error charging payment method:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async createCustomer({ email, name, metadata = {} }) {
|
||||
try {
|
||||
const customer = await stripe.customers.create({
|
||||
email,
|
||||
name,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return customer;
|
||||
} catch (error) {
|
||||
console.error("Error creating customer:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static async createSetupCheckoutSession({ customerId, metadata = {} }) {
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
payment_method_types: ['card', 'us_bank_account', 'link'],
|
||||
mode: 'setup',
|
||||
ui_mode: 'embedded',
|
||||
redirect_on_completion: 'never',
|
||||
metadata: {
|
||||
type: 'payment_method_setup',
|
||||
...metadata
|
||||
}
|
||||
});
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error("Error creating setup checkout session:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = StripeService;
|
||||
|
||||
Reference in New Issue
Block a user