302 lines
8.8 KiB
JavaScript
302 lines
8.8 KiB
JavaScript
const { Rental, User, Item } = require("../models");
|
|
const StripeService = require("./stripeService");
|
|
const emailServices = require("./email");
|
|
const logger = require("../utils/logger");
|
|
const { Op } = require("sequelize");
|
|
|
|
class PayoutService {
|
|
/**
|
|
* Attempt to process payout for a single rental immediately after completion.
|
|
* Checks if owner's Stripe account has payouts enabled before attempting.
|
|
* @param {string} rentalId - The rental ID to process
|
|
* @returns {Object} - { attempted, success, reason, transferId, amount }
|
|
*/
|
|
static async triggerPayoutOnCompletion(rentalId) {
|
|
try {
|
|
const rental = await Rental.findByPk(rentalId, {
|
|
include: [
|
|
{
|
|
model: User,
|
|
as: "owner",
|
|
attributes: ["id", "email", "firstName", "lastName", "stripeConnectedAccountId", "stripePayoutsEnabled"],
|
|
},
|
|
{ model: Item, as: "item" },
|
|
],
|
|
});
|
|
|
|
if (!rental) {
|
|
logger.warn("Rental not found for payout trigger", { rentalId });
|
|
return { attempted: false, success: false, reason: "rental_not_found" };
|
|
}
|
|
|
|
// Check eligibility conditions
|
|
if (rental.paymentStatus !== "paid") {
|
|
logger.info("Payout skipped: payment not paid", { rentalId, paymentStatus: rental.paymentStatus });
|
|
return { attempted: false, success: false, reason: "payment_not_paid" };
|
|
}
|
|
|
|
if (rental.payoutStatus !== "pending") {
|
|
logger.info("Payout skipped: payout not pending", { rentalId, payoutStatus: rental.payoutStatus });
|
|
return { attempted: false, success: false, reason: "payout_not_pending" };
|
|
}
|
|
|
|
if (!rental.owner?.stripeConnectedAccountId) {
|
|
logger.info("Payout skipped: owner has no Stripe account", { rentalId, ownerId: rental.ownerId });
|
|
return { attempted: false, success: false, reason: "no_stripe_account" };
|
|
}
|
|
|
|
// Check if owner has payouts enabled (onboarding complete)
|
|
if (!rental.owner.stripePayoutsEnabled) {
|
|
logger.info("Payout deferred: owner payouts not enabled, will process when onboarding completes", {
|
|
rentalId,
|
|
ownerId: rental.ownerId,
|
|
});
|
|
return { attempted: false, success: false, reason: "payouts_not_enabled" };
|
|
}
|
|
|
|
// Attempt the payout
|
|
const result = await this.processRentalPayout(rental);
|
|
|
|
logger.info("Payout triggered successfully on completion", {
|
|
rentalId,
|
|
transferId: result.transferId,
|
|
amount: result.amount,
|
|
});
|
|
|
|
return {
|
|
attempted: true,
|
|
success: true,
|
|
transferId: result.transferId,
|
|
amount: result.amount,
|
|
};
|
|
} catch (error) {
|
|
logger.error("Error triggering payout on completion", {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
rentalId,
|
|
});
|
|
|
|
// Payout marked as failed by processRentalPayout, will be retried by daily retry job
|
|
return {
|
|
attempted: true,
|
|
success: false,
|
|
reason: "payout_failed",
|
|
error: error.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
static async getEligiblePayouts() {
|
|
try {
|
|
const eligibleRentals = await Rental.findAll({
|
|
where: {
|
|
status: "completed",
|
|
paymentStatus: "paid",
|
|
payoutStatus: "pending",
|
|
},
|
|
include: [
|
|
{
|
|
model: User,
|
|
as: "owner",
|
|
where: {
|
|
stripeConnectedAccountId: {
|
|
[Op.not]: null,
|
|
},
|
|
stripePayoutsEnabled: true,
|
|
},
|
|
},
|
|
{
|
|
model: Item,
|
|
as: "item",
|
|
},
|
|
],
|
|
});
|
|
|
|
return eligibleRentals;
|
|
} catch (error) {
|
|
logger.error("Error getting eligible payouts", { error: error.message, stack: error.stack });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
static async processRentalPayout(rental) {
|
|
try {
|
|
if (!rental.owner || !rental.owner.stripeConnectedAccountId) {
|
|
throw new Error("Owner does not have a connected Stripe account");
|
|
}
|
|
|
|
if (rental.payoutStatus !== "pending") {
|
|
throw new Error("Rental payout has already been processed");
|
|
}
|
|
|
|
if (!rental.payoutAmount || rental.payoutAmount <= 0) {
|
|
throw new Error("Invalid payout amount");
|
|
}
|
|
|
|
// Create Stripe transfer
|
|
const transfer = await StripeService.createTransfer({
|
|
amount: rental.payoutAmount,
|
|
destination: rental.owner.stripeConnectedAccountId,
|
|
metadata: {
|
|
rentalId: rental.id,
|
|
ownerId: rental.ownerId,
|
|
totalAmount: rental.totalAmount.toString(),
|
|
platformFee: rental.platformFee.toString(),
|
|
startDateTime: rental.startDateTime.toISOString(),
|
|
endDateTime: rental.endDateTime.toISOString(),
|
|
},
|
|
});
|
|
|
|
// Update rental with successful payout
|
|
await rental.update({
|
|
payoutStatus: "completed",
|
|
payoutProcessedAt: new Date(),
|
|
stripeTransferId: transfer.id,
|
|
});
|
|
|
|
console.log(
|
|
`Payout completed for rental ${rental.id}: $${rental.payoutAmount} to ${rental.owner.stripeConnectedAccountId}`
|
|
);
|
|
|
|
// Send payout notification email to owner
|
|
try {
|
|
await emailServices.rentalFlow.sendPayoutReceivedEmail(rental.owner, rental);
|
|
logger.info("Payout notification email sent to owner", {
|
|
rentalId: rental.id,
|
|
ownerId: rental.ownerId
|
|
});
|
|
} catch (emailError) {
|
|
// Log error but don't fail the payout
|
|
logger.error("Failed to send payout notification email", {
|
|
error: emailError.message,
|
|
stack: emailError.stack,
|
|
rentalId: rental.id,
|
|
ownerId: rental.ownerId
|
|
});
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
transferId: transfer.id,
|
|
amount: rental.payoutAmount,
|
|
};
|
|
} catch (error) {
|
|
logger.error("Error processing payout for rental", { error: error.message, stack: error.stack, rentalId: rental.id });
|
|
|
|
// Update status to failed
|
|
await rental.update({
|
|
payoutStatus: "failed",
|
|
});
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
static async processAllEligiblePayouts() {
|
|
try {
|
|
const eligibleRentals = await this.getEligiblePayouts();
|
|
|
|
console.log(
|
|
`Found ${eligibleRentals.length} eligible rentals for payout`
|
|
);
|
|
|
|
const results = {
|
|
successful: [],
|
|
failed: [],
|
|
totalProcessed: eligibleRentals.length,
|
|
};
|
|
|
|
for (const rental of eligibleRentals) {
|
|
try {
|
|
const result = await this.processRentalPayout(rental);
|
|
results.successful.push({
|
|
rentalId: rental.id,
|
|
amount: result.amount,
|
|
transferId: result.transferId,
|
|
});
|
|
} catch (error) {
|
|
results.failed.push({
|
|
rentalId: rental.id,
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`Payout processing complete: ${results.successful.length} successful, ${results.failed.length} failed`
|
|
);
|
|
|
|
return results;
|
|
} catch (error) {
|
|
logger.error("Error processing all eligible payouts", { error: error.message, stack: error.stack });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
static async retryFailedPayouts() {
|
|
try {
|
|
const failedRentals = await Rental.findAll({
|
|
where: {
|
|
status: "completed",
|
|
paymentStatus: "paid",
|
|
payoutStatus: "failed",
|
|
},
|
|
include: [
|
|
{
|
|
model: User,
|
|
as: "owner",
|
|
where: {
|
|
stripeConnectedAccountId: {
|
|
[Op.not]: null,
|
|
},
|
|
stripePayoutsEnabled: true,
|
|
},
|
|
},
|
|
{
|
|
model: Item,
|
|
as: "item",
|
|
},
|
|
],
|
|
});
|
|
|
|
console.log(`Found ${failedRentals.length} failed payouts to retry`);
|
|
|
|
const results = {
|
|
successful: [],
|
|
failed: [],
|
|
totalProcessed: failedRentals.length,
|
|
};
|
|
|
|
for (const rental of failedRentals) {
|
|
try {
|
|
// Reset to pending before retrying
|
|
await rental.update({ payoutStatus: "pending" });
|
|
|
|
const result = await this.processRentalPayout(rental);
|
|
results.successful.push({
|
|
rentalId: rental.id,
|
|
amount: result.amount,
|
|
transferId: result.transferId,
|
|
});
|
|
} catch (error) {
|
|
results.failed.push({
|
|
rentalId: rental.id,
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`Retry processing complete: ${results.successful.length} successful, ${results.failed.length} failed`
|
|
);
|
|
|
|
return results;
|
|
} catch (error) {
|
|
logger.error("Error retrying failed payouts", { error: error.message, stack: error.stack });
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = PayoutService;
|