started payouts
This commit is contained in:
90
backend/jobs/payoutProcessor.js
Normal file
90
backend/jobs/payoutProcessor.js
Normal file
@@ -0,0 +1,90 @@
|
||||
const cron = require("node-cron");
|
||||
const PayoutService = require("../services/payoutService");
|
||||
|
||||
const paymentsSchedule = "31 * * * *"; // Run every hour at minute 0
|
||||
const retrySchedule = "0 7 * * *"; // Retry failed payouts once daily at 7 AM
|
||||
|
||||
class PayoutProcessor {
|
||||
static startScheduledPayouts() {
|
||||
console.log("Starting automated payout processor...");
|
||||
|
||||
const payoutJob = cron.schedule(
|
||||
paymentsSchedule,
|
||||
async () => {
|
||||
console.log("Running scheduled payout processing...");
|
||||
|
||||
try {
|
||||
const results = await PayoutService.processAllEligiblePayouts();
|
||||
|
||||
if (results.totalProcessed > 0) {
|
||||
console.log(
|
||||
`Payout batch completed: ${results.successful.length} successful, ${results.failed.length} failed`
|
||||
);
|
||||
|
||||
// Log any failures for monitoring
|
||||
if (results.failed.length > 0) {
|
||||
console.warn("Failed payouts:", results.failed);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in scheduled payout processing:", error);
|
||||
}
|
||||
},
|
||||
{
|
||||
scheduled: false,
|
||||
timezone: "America/New_York",
|
||||
}
|
||||
);
|
||||
|
||||
const retryJob = cron.schedule(
|
||||
retrySchedule,
|
||||
async () => {
|
||||
console.log("Running failed payout retry process...");
|
||||
|
||||
try {
|
||||
const results = await PayoutService.retryFailedPayouts();
|
||||
|
||||
if (results.totalProcessed > 0) {
|
||||
console.log(
|
||||
`Retry batch completed: ${results.successful.length} successful, ${results.failed.length} still failed`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in retry payout processing:", error);
|
||||
}
|
||||
},
|
||||
{
|
||||
scheduled: false,
|
||||
timezone: "America/New_York",
|
||||
}
|
||||
);
|
||||
|
||||
// Start the jobs
|
||||
payoutJob.start();
|
||||
retryJob.start();
|
||||
|
||||
console.log("Payout processor jobs scheduled:");
|
||||
console.log("- Hourly payout processing: " + paymentsSchedule);
|
||||
console.log("- Daily retry processing: " + retrySchedule);
|
||||
|
||||
return {
|
||||
payoutJob,
|
||||
retryJob,
|
||||
|
||||
stop() {
|
||||
payoutJob.stop();
|
||||
retryJob.stop();
|
||||
console.log("Payout processor jobs stopped");
|
||||
},
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
payoutJobRunning: payoutJob.getStatus() === "scheduled",
|
||||
retryJobRunning: retryJob.getStatus() === "scheduled",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PayoutProcessor;
|
||||
@@ -49,6 +49,22 @@ const Rental = sequelize.define('Rental', {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false
|
||||
},
|
||||
baseRentalAmount: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false
|
||||
},
|
||||
platformFee: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false
|
||||
},
|
||||
processingFee: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false
|
||||
},
|
||||
payoutAmount: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('pending', 'confirmed', 'active', 'completed', 'cancelled'),
|
||||
defaultValue: 'pending'
|
||||
@@ -57,6 +73,16 @@ const Rental = sequelize.define('Rental', {
|
||||
type: DataTypes.ENUM('pending', 'paid', 'refunded'),
|
||||
defaultValue: 'pending'
|
||||
},
|
||||
payoutStatus: {
|
||||
type: DataTypes.ENUM('pending', 'processing', 'completed', 'failed'),
|
||||
defaultValue: 'pending'
|
||||
},
|
||||
payoutProcessedAt: {
|
||||
type: DataTypes.DATE
|
||||
},
|
||||
stripeTransferId: {
|
||||
type: DataTypes.STRING
|
||||
},
|
||||
deliveryMethod: {
|
||||
type: DataTypes.ENUM('pickup', 'delivery'),
|
||||
defaultValue: 'pickup'
|
||||
|
||||
@@ -98,6 +98,10 @@ const User = sequelize.define('User', {
|
||||
friday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||
saturday: { availableAfter: "09:00", availableBefore: "17:00" }
|
||||
}
|
||||
},
|
||||
stripeConnectedAccountId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
}
|
||||
}, {
|
||||
hooks: {
|
||||
|
||||
22
backend/package-lock.json
generated
22
backend/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"express": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.3",
|
||||
"pg": "^8.16.3",
|
||||
"sequelize": "^6.37.7",
|
||||
"sequelize-cli": "^6.6.3",
|
||||
@@ -1372,6 +1373,27 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
|
||||
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"uuid": "8.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"express": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.3",
|
||||
"pg": "^8.16.3",
|
||||
"sequelize": "^6.37.7",
|
||||
"sequelize-cli": "^6.6.3",
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require("express");
|
||||
const { Op } = require("sequelize");
|
||||
const { Rental, Item, User } = require("../models"); // Import from models/index.js to get models with associations
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const FeeCalculator = require("../utils/feeCalculator");
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to check and update review visibility
|
||||
@@ -139,7 +140,10 @@ router.post("/", authenticateToken, async (req, res) => {
|
||||
const rentalDays = Math.ceil(
|
||||
(new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const totalAmount = rentalDays * (item.pricePerDay || 0);
|
||||
const baseRentalAmount = rentalDays * (item.pricePerDay || 0);
|
||||
|
||||
// Calculate fees using FeeCalculator
|
||||
const fees = FeeCalculator.calculateRentalFees(baseRentalAmount);
|
||||
|
||||
const rental = await Rental.create({
|
||||
itemId,
|
||||
@@ -149,7 +153,11 @@ router.post("/", authenticateToken, async (req, res) => {
|
||||
endDate,
|
||||
startTime,
|
||||
endTime,
|
||||
totalAmount,
|
||||
totalAmount: fees.totalChargedAmount,
|
||||
baseRentalAmount: fees.baseRentalAmount,
|
||||
platformFee: fees.platformFee,
|
||||
processingFee: fees.processingFee,
|
||||
payoutAmount: fees.payoutAmount,
|
||||
deliveryMethod,
|
||||
deliveryAddress,
|
||||
notes,
|
||||
@@ -376,4 +384,54 @@ router.post("/:id/review", authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate fees for rental pricing display
|
||||
router.post("/calculate-fees", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { baseAmount } = req.body;
|
||||
|
||||
if (!baseAmount || baseAmount <= 0) {
|
||||
return res.status(400).json({ error: "Valid base amount is required" });
|
||||
}
|
||||
|
||||
const fees = FeeCalculator.calculateRentalFees(baseAmount);
|
||||
const displayFees = FeeCalculator.formatFeesForDisplay(fees);
|
||||
|
||||
res.json({
|
||||
fees,
|
||||
display: displayFees,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error calculating fees:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get payout status for owner's rentals
|
||||
router.get("/payouts/status", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const ownerRentals = await Rental.findAll({
|
||||
where: {
|
||||
ownerId: req.user.id,
|
||||
status: "completed",
|
||||
},
|
||||
attributes: [
|
||||
"id",
|
||||
"baseRentalAmount",
|
||||
"platformFee",
|
||||
"payoutAmount",
|
||||
"payoutStatus",
|
||||
"payoutProcessedAt",
|
||||
"stripeTransferId",
|
||||
],
|
||||
include: [{ model: Item, as: "item", attributes: ["name"] }],
|
||||
order: [["createdAt", "DESC"]],
|
||||
});
|
||||
|
||||
res.json(ownerRentals);
|
||||
} catch (error) {
|
||||
console.error("Error getting payout status:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -66,156 +66,100 @@ router.get("/checkout-session/:sessionId", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// // Create connected account
|
||||
// router.post("/accounts", authenticateToken, async (req, res) => {
|
||||
// try {
|
||||
// const user = await User.findByPk(req.user.id);
|
||||
// Create connected account
|
||||
router.post("/accounts", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const user = await User.findByPk(req.user.id);
|
||||
|
||||
// if (!user) {
|
||||
// return res.status(404).json({ error: "User not found" });
|
||||
// }
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
// // Check if user already has a connected account
|
||||
// if (user.stripeConnectedAccountId) {
|
||||
// return res
|
||||
// .status(400)
|
||||
// .json({ error: "User already has a connected account" });
|
||||
// }
|
||||
// Check if user already has a connected account
|
||||
if (user.stripeConnectedAccountId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "User already has a connected account" });
|
||||
}
|
||||
|
||||
// // Create connected account
|
||||
// const account = await StripeService.createConnectedAccount({
|
||||
// email: user.email,
|
||||
// country: "US", // You may want to make this configurable
|
||||
// });
|
||||
// Create connected account
|
||||
const account = await StripeService.createConnectedAccount({
|
||||
email: user.email,
|
||||
country: "US", // You may want to make this configurable
|
||||
});
|
||||
|
||||
// // Update user with account ID
|
||||
// await user.update({
|
||||
// stripeConnectedAccountId: account.id,
|
||||
// });
|
||||
// Update user with account ID
|
||||
await user.update({
|
||||
stripeConnectedAccountId: account.id,
|
||||
});
|
||||
|
||||
// res.json({
|
||||
// stripeConnectedAccountId: account.id,
|
||||
// success: true,
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error("Error creating connected account:", error);
|
||||
// res.status(500).json({ error: error.message });
|
||||
// }
|
||||
// });
|
||||
res.json({
|
||||
stripeConnectedAccountId: account.id,
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating connected account:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// // Generate onboarding link
|
||||
// router.post("/account-links", authenticateToken, async (req, res) => {
|
||||
// try {
|
||||
// const user = await User.findByPk(req.user.id);
|
||||
// Generate onboarding link
|
||||
router.post("/account-links", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const user = await User.findByPk(req.user.id);
|
||||
|
||||
// if (!user || !user.stripeConnectedAccountId) {
|
||||
// return res.status(400).json({ error: "No connected account found" });
|
||||
// }
|
||||
if (!user || !user.stripeConnectedAccountId) {
|
||||
return res.status(400).json({ error: "No connected account found" });
|
||||
}
|
||||
|
||||
// const { refreshUrl, returnUrl } = req.body;
|
||||
const { refreshUrl, returnUrl } = req.body;
|
||||
|
||||
// if (!refreshUrl || !returnUrl) {
|
||||
// return res
|
||||
// .status(400)
|
||||
// .json({ error: "refreshUrl and returnUrl are required" });
|
||||
// }
|
||||
if (!refreshUrl || !returnUrl) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "refreshUrl and returnUrl are required" });
|
||||
}
|
||||
|
||||
// const accountLink = await StripeService.createAccountLink(
|
||||
// user.stripeConnectedAccountId,
|
||||
// refreshUrl,
|
||||
// returnUrl
|
||||
// );
|
||||
const accountLink = await StripeService.createAccountLink(
|
||||
user.stripeConnectedAccountId,
|
||||
refreshUrl,
|
||||
returnUrl
|
||||
);
|
||||
|
||||
// res.json({
|
||||
// url: accountLink.url,
|
||||
// expiresAt: accountLink.expires_at,
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error("Error creating account link:", error);
|
||||
// res.status(500).json({ error: error.message });
|
||||
// }
|
||||
// });
|
||||
res.json({
|
||||
url: accountLink.url,
|
||||
expiresAt: accountLink.expires_at,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating account link:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// // Get account status
|
||||
// router.get("/account-status", authenticateToken, async (req, res) => {
|
||||
// try {
|
||||
// const user = await User.findByPk(req.user.id);
|
||||
// Get account status
|
||||
router.get("/account-status", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const user = await User.findByPk(req.user.id);
|
||||
|
||||
// if (!user || !user.stripeConnectedAccountId) {
|
||||
// return res.status(400).json({ error: "No connected account found" });
|
||||
// }
|
||||
if (!user || !user.stripeConnectedAccountId) {
|
||||
return res.status(400).json({ error: "No connected account found" });
|
||||
}
|
||||
|
||||
// const accountStatus = await StripeService.getAccountStatus(
|
||||
// user.stripeConnectedAccountId
|
||||
// );
|
||||
const accountStatus = await StripeService.getAccountStatus(
|
||||
user.stripeConnectedAccountId
|
||||
);
|
||||
|
||||
// res.json({
|
||||
// accountId: accountStatus.id,
|
||||
// detailsSubmitted: accountStatus.details_submitted,
|
||||
// payoutsEnabled: accountStatus.payouts_enabled,
|
||||
// capabilities: accountStatus.capabilities,
|
||||
// requirements: accountStatus.requirements,
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error("Error getting account status:", error);
|
||||
// res.status(500).json({ error: error.message });
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Create payment intent for rental
|
||||
// router.post("/payment-intents", authenticateToken, async (req, res) => {
|
||||
// try {
|
||||
// const { rentalId, amount } = req.body;
|
||||
|
||||
// if (!rentalId || !amount) {
|
||||
// return res
|
||||
// .status(400)
|
||||
// .json({ error: "rentalId and amount are required" });
|
||||
// }
|
||||
|
||||
// // Get rental details to find owner's connected account
|
||||
// const rental = await Rental.findByPk(rentalId, {
|
||||
// include: [{ model: Item, as: "item" }],
|
||||
// });
|
||||
|
||||
// if (!rental) {
|
||||
// return res.status(404).json({ error: "Rental not found" });
|
||||
// }
|
||||
|
||||
// if (rental.ownerId !== req.user.id) {
|
||||
// return res.status(403).json({ error: "Unauthorized" });
|
||||
// }
|
||||
|
||||
// // Get owner's connected account
|
||||
// const owner = await User.findByPk(rental.ownerId);
|
||||
// if (!owner || !owner.stripeConnectedAccountId) {
|
||||
// return res
|
||||
// .status(400)
|
||||
// .json({ error: "Owner does not have a connected account" });
|
||||
// }
|
||||
|
||||
// const applicationFeeAmount = Math.round(amount * platformFee);
|
||||
|
||||
// const paymentIntent = await StripeService.createPaymentIntent({
|
||||
// amount: Math.round(amount * 100), // Convert to cents
|
||||
// currency: "usd",
|
||||
// connectedAccountId: owner.stripeConnectedAccountId,
|
||||
// applicationFeeAmount: applicationFeeAmount * 100, // Convert to cents
|
||||
// metadata: {
|
||||
// rentalId: rental.id,
|
||||
// renterId: rental.renterId,
|
||||
// ownerId: owner.id,
|
||||
// },
|
||||
// });
|
||||
|
||||
// res.json({
|
||||
// clientSecret: paymentIntent.client_secret,
|
||||
// paymentIntentId: paymentIntent.id,
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error("Error creating payment intent:", error);
|
||||
// res.status(500).json({ error: error.message });
|
||||
// }
|
||||
// });
|
||||
res.json({
|
||||
accountId: accountStatus.id,
|
||||
detailsSubmitted: accountStatus.details_submitted,
|
||||
payoutsEnabled: accountStatus.payouts_enabled,
|
||||
capabilities: accountStatus.capabilities,
|
||||
requirements: accountStatus.requirements,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error getting account status:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -21,6 +21,8 @@ const betaRoutes = require("./routes/beta");
|
||||
const itemRequestRoutes = require("./routes/itemRequests");
|
||||
const stripeRoutes = require("./routes/stripe");
|
||||
|
||||
const PayoutProcessor = require("./jobs/payoutProcessor");
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
@@ -52,6 +54,10 @@ sequelize
|
||||
.sync({ alter: true })
|
||||
.then(() => {
|
||||
console.log("Database synced");
|
||||
|
||||
// Start the payout processor
|
||||
const payoutJobs = PayoutProcessor.startScheduledPayouts();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
});
|
||||
|
||||
194
backend/services/payoutService.js
Normal file
194
backend/services/payoutService.js
Normal file
@@ -0,0 +1,194 @@
|
||||
const { Rental, User } = require("../models");
|
||||
const StripeService = require("./stripeService");
|
||||
const { Op } = require("sequelize");
|
||||
|
||||
class PayoutService {
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return eligibleRentals;
|
||||
} catch (error) {
|
||||
console.error("Error getting eligible payouts:", error);
|
||||
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");
|
||||
}
|
||||
|
||||
// Update status to processing
|
||||
await rental.update({
|
||||
payoutStatus: "processing",
|
||||
});
|
||||
|
||||
// Create Stripe transfer
|
||||
const transfer = await StripeService.createTransfer({
|
||||
amount: rental.payoutAmount,
|
||||
destination: rental.owner.stripeConnectedAccountId,
|
||||
metadata: {
|
||||
rentalId: rental.id,
|
||||
ownerId: rental.ownerId,
|
||||
baseAmount: rental.baseRentalAmount.toString(),
|
||||
platformFee: rental.platformFee.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
// 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}`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transferId: transfer.id,
|
||||
amount: rental.payoutAmount,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error processing payout for rental ${rental.id}:`, error);
|
||||
|
||||
// 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) {
|
||||
console.error("Error processing all eligible payouts:", error);
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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) {
|
||||
console.error("Error retrying failed payouts:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PayoutService;
|
||||
@@ -45,113 +45,76 @@ class StripeService {
|
||||
}
|
||||
}
|
||||
|
||||
// static async createConnectedAccount({ email, country = "US" }) {
|
||||
// try {
|
||||
// const account = await stripe.accounts.create({
|
||||
// type: "standard",
|
||||
// email,
|
||||
// country,
|
||||
// controller: {
|
||||
// stripe_dashboard: {
|
||||
// type: "full",
|
||||
// },
|
||||
// },
|
||||
// capabilities: {
|
||||
// transfers: { requested: true },
|
||||
// },
|
||||
// });
|
||||
static async createConnectedAccount({ email, country = "US" }) {
|
||||
try {
|
||||
const account = await stripe.accounts.create({
|
||||
type: "standard",
|
||||
email,
|
||||
country,
|
||||
capabilities: {
|
||||
transfers: { requested: true },
|
||||
},
|
||||
});
|
||||
|
||||
// return account;
|
||||
// } catch (error) {
|
||||
// console.error("Error creating connected account:", error);
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
return account;
|
||||
} catch (error) {
|
||||
console.error("Error creating connected account:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// static async createAccountLink(accountId, refreshUrl, returnUrl) {
|
||||
// try {
|
||||
// const accountLink = await stripe.accountLinks.create({
|
||||
// account: accountId,
|
||||
// refresh_url: refreshUrl,
|
||||
// return_url: returnUrl,
|
||||
// type: "account_onboarding",
|
||||
// });
|
||||
static async createAccountLink(accountId, refreshUrl, returnUrl) {
|
||||
try {
|
||||
const accountLink = await stripe.accountLinks.create({
|
||||
account: accountId,
|
||||
refresh_url: refreshUrl,
|
||||
return_url: returnUrl,
|
||||
type: "account_onboarding",
|
||||
});
|
||||
|
||||
// return accountLink;
|
||||
// } catch (error) {
|
||||
// console.error("Error creating account link:", error);
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
return accountLink;
|
||||
} catch (error) {
|
||||
console.error("Error creating account link:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// static async getAccountStatus(accountId) {
|
||||
// try {
|
||||
// const account = await stripe.accounts.retrieve(accountId);
|
||||
// return {
|
||||
// id: account.id,
|
||||
// details_submitted: account.details_submitted,
|
||||
// payouts_enabled: account.payouts_enabled,
|
||||
// capabilities: account.capabilities,
|
||||
// requirements: account.requirements,
|
||||
// };
|
||||
// } catch (error) {
|
||||
// console.error("Error retrieving account status:", error);
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
static async getAccountStatus(accountId) {
|
||||
try {
|
||||
const account = await stripe.accounts.retrieve(accountId);
|
||||
return {
|
||||
id: account.id,
|
||||
details_submitted: account.details_submitted,
|
||||
payouts_enabled: account.payouts_enabled,
|
||||
capabilities: account.capabilities,
|
||||
requirements: account.requirements,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error retrieving account status:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// static async createPaymentIntent({
|
||||
// amount,
|
||||
// currency = "usd",
|
||||
// connectedAccountId,
|
||||
// applicationFeeAmount,
|
||||
// metadata = {},
|
||||
// }) {
|
||||
// try {
|
||||
// const paymentIntent = await stripe.paymentIntents.create({
|
||||
// amount,
|
||||
// currency,
|
||||
// transfer_data: {
|
||||
// destination: connectedAccountId,
|
||||
// },
|
||||
// application_fee_amount: applicationFeeAmount,
|
||||
// metadata,
|
||||
// automatic_payment_methods: {
|
||||
// enabled: true,
|
||||
// },
|
||||
// });
|
||||
static async createTransfer({
|
||||
amount,
|
||||
currency = "usd",
|
||||
destination,
|
||||
metadata = {},
|
||||
}) {
|
||||
try {
|
||||
const transfer = await stripe.transfers.create({
|
||||
amount: Math.round(amount * 100), // Convert to cents
|
||||
currency,
|
||||
destination,
|
||||
metadata,
|
||||
});
|
||||
|
||||
// return paymentIntent;
|
||||
// } catch (error) {
|
||||
// console.error("Error creating payment intent:", error);
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
|
||||
// static async confirmPaymentIntent(paymentIntentId, paymentMethodId) {
|
||||
// try {
|
||||
// const paymentIntent = await stripe.paymentIntents.confirm(
|
||||
// paymentIntentId,
|
||||
// {
|
||||
// payment_method: paymentMethodId,
|
||||
// }
|
||||
// );
|
||||
|
||||
// return paymentIntent;
|
||||
// } catch (error) {
|
||||
// console.error("Error confirming payment intent:", error);
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
|
||||
// static async retrievePaymentIntent(paymentIntentId) {
|
||||
// try {
|
||||
// return await stripe.paymentIntents.retrieve(paymentIntentId);
|
||||
// } catch (error) {
|
||||
// console.error("Error retrieving payment intent:", error);
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
return transfer;
|
||||
} catch (error) {
|
||||
console.error("Error creating transfer:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = StripeService;
|
||||
|
||||
30
backend/utils/feeCalculator.js
Normal file
30
backend/utils/feeCalculator.js
Normal file
@@ -0,0 +1,30 @@
|
||||
class FeeCalculator {
|
||||
static calculateRentalFees(baseAmount) {
|
||||
const platformFeeRate = 0.2;
|
||||
const stripeRate = 0.029;
|
||||
const stripeFixedFee = 0.3;
|
||||
|
||||
const platformFee = baseAmount * platformFeeRate;
|
||||
const processingFee = baseAmount * stripeRate + stripeFixedFee;
|
||||
|
||||
return {
|
||||
baseRentalAmount: parseFloat(baseAmount.toFixed(2)),
|
||||
platformFee: parseFloat(platformFee.toFixed(2)),
|
||||
processingFee: parseFloat(processingFee.toFixed(2)),
|
||||
totalChargedAmount: parseFloat((baseAmount + processingFee).toFixed(2)),
|
||||
payoutAmount: parseFloat((baseAmount - platformFee).toFixed(2)),
|
||||
};
|
||||
}
|
||||
|
||||
static formatFeesForDisplay(fees) {
|
||||
return {
|
||||
baseRental: `$${fees.baseRentalAmount.toFixed(2)}`,
|
||||
platformFee: `$${fees.platformFee.toFixed(2)} (20%)`,
|
||||
processingFee: `$${fees.processingFee.toFixed(2)} (2.9% + $0.30)`,
|
||||
totalCharge: `$${fees.totalChargedAmount.toFixed(2)}`,
|
||||
ownerPayout: `$${fees.payoutAmount.toFixed(2)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FeeCalculator;
|
||||
Reference in New Issue
Block a user