started payouts
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ node_modules/
|
|||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
.mcp.json
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
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),
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
allowNull: false
|
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: {
|
status: {
|
||||||
type: DataTypes.ENUM('pending', 'confirmed', 'active', 'completed', 'cancelled'),
|
type: DataTypes.ENUM('pending', 'confirmed', 'active', 'completed', 'cancelled'),
|
||||||
defaultValue: 'pending'
|
defaultValue: 'pending'
|
||||||
@@ -57,6 +73,16 @@ const Rental = sequelize.define('Rental', {
|
|||||||
type: DataTypes.ENUM('pending', 'paid', 'refunded'),
|
type: DataTypes.ENUM('pending', 'paid', 'refunded'),
|
||||||
defaultValue: 'pending'
|
defaultValue: 'pending'
|
||||||
},
|
},
|
||||||
|
payoutStatus: {
|
||||||
|
type: DataTypes.ENUM('pending', 'processing', 'completed', 'failed'),
|
||||||
|
defaultValue: 'pending'
|
||||||
|
},
|
||||||
|
payoutProcessedAt: {
|
||||||
|
type: DataTypes.DATE
|
||||||
|
},
|
||||||
|
stripeTransferId: {
|
||||||
|
type: DataTypes.STRING
|
||||||
|
},
|
||||||
deliveryMethod: {
|
deliveryMethod: {
|
||||||
type: DataTypes.ENUM('pickup', 'delivery'),
|
type: DataTypes.ENUM('pickup', 'delivery'),
|
||||||
defaultValue: 'pickup'
|
defaultValue: 'pickup'
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ const User = sequelize.define('User', {
|
|||||||
friday: { availableAfter: "09:00", availableBefore: "17:00" },
|
friday: { availableAfter: "09:00", availableBefore: "17:00" },
|
||||||
saturday: { availableAfter: "09:00", availableBefore: "17:00" }
|
saturday: { availableAfter: "09:00", availableBefore: "17:00" }
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
stripeConnectedAccountId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
hooks: {
|
hooks: {
|
||||||
|
|||||||
22
backend/package-lock.json
generated
22
backend/package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"sequelize": "^6.37.7",
|
"sequelize": "^6.37.7",
|
||||||
"sequelize-cli": "^6.6.3",
|
"sequelize-cli": "^6.6.3",
|
||||||
@@ -1372,6 +1373,27 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.10",
|
"version": "3.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"sequelize": "^6.37.7",
|
"sequelize": "^6.37.7",
|
||||||
"sequelize-cli": "^6.6.3",
|
"sequelize-cli": "^6.6.3",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require("express");
|
|||||||
const { Op } = require("sequelize");
|
const { Op } = require("sequelize");
|
||||||
const { Rental, Item, User } = require("../models"); // Import from models/index.js to get models with associations
|
const { Rental, Item, User } = require("../models"); // Import from models/index.js to get models with associations
|
||||||
const { authenticateToken } = require("../middleware/auth");
|
const { authenticateToken } = require("../middleware/auth");
|
||||||
|
const FeeCalculator = require("../utils/feeCalculator");
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Helper function to check and update review visibility
|
// Helper function to check and update review visibility
|
||||||
@@ -139,7 +140,10 @@ router.post("/", authenticateToken, async (req, res) => {
|
|||||||
const rentalDays = Math.ceil(
|
const rentalDays = Math.ceil(
|
||||||
(new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24)
|
(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({
|
const rental = await Rental.create({
|
||||||
itemId,
|
itemId,
|
||||||
@@ -149,7 +153,11 @@ router.post("/", authenticateToken, async (req, res) => {
|
|||||||
endDate,
|
endDate,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
totalAmount,
|
totalAmount: fees.totalChargedAmount,
|
||||||
|
baseRentalAmount: fees.baseRentalAmount,
|
||||||
|
platformFee: fees.platformFee,
|
||||||
|
processingFee: fees.processingFee,
|
||||||
|
payoutAmount: fees.payoutAmount,
|
||||||
deliveryMethod,
|
deliveryMethod,
|
||||||
deliveryAddress,
|
deliveryAddress,
|
||||||
notes,
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -66,156 +66,100 @@ router.get("/checkout-session/:sessionId", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// // Create connected account
|
// Create connected account
|
||||||
// router.post("/accounts", authenticateToken, async (req, res) => {
|
router.post("/accounts", authenticateToken, async (req, res) => {
|
||||||
// try {
|
try {
|
||||||
// const user = await User.findByPk(req.user.id);
|
const user = await User.findByPk(req.user.id);
|
||||||
|
|
||||||
// if (!user) {
|
if (!user) {
|
||||||
// return res.status(404).json({ error: "User not found" });
|
return res.status(404).json({ error: "User not found" });
|
||||||
// }
|
}
|
||||||
|
|
||||||
// // Check if user already has a connected account
|
// Check if user already has a connected account
|
||||||
// if (user.stripeConnectedAccountId) {
|
if (user.stripeConnectedAccountId) {
|
||||||
// return res
|
return res
|
||||||
// .status(400)
|
.status(400)
|
||||||
// .json({ error: "User already has a connected account" });
|
.json({ error: "User already has a connected account" });
|
||||||
// }
|
}
|
||||||
|
|
||||||
// // Create connected account
|
// Create connected account
|
||||||
// const account = await StripeService.createConnectedAccount({
|
const account = await StripeService.createConnectedAccount({
|
||||||
// email: user.email,
|
email: user.email,
|
||||||
// country: "US", // You may want to make this configurable
|
country: "US", // You may want to make this configurable
|
||||||
// });
|
});
|
||||||
|
|
||||||
// // Update user with account ID
|
// Update user with account ID
|
||||||
// await user.update({
|
await user.update({
|
||||||
// stripeConnectedAccountId: account.id,
|
stripeConnectedAccountId: account.id,
|
||||||
// });
|
});
|
||||||
|
|
||||||
// res.json({
|
res.json({
|
||||||
// stripeConnectedAccountId: account.id,
|
stripeConnectedAccountId: account.id,
|
||||||
// success: true,
|
success: true,
|
||||||
// });
|
});
|
||||||
// } catch (error) {
|
} catch (error) {
|
||||||
// console.error("Error creating connected account:", error);
|
console.error("Error creating connected account:", error);
|
||||||
// res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
// }
|
}
|
||||||
// });
|
});
|
||||||
|
|
||||||
// // Generate onboarding link
|
// Generate onboarding link
|
||||||
// router.post("/account-links", authenticateToken, async (req, res) => {
|
router.post("/account-links", authenticateToken, async (req, res) => {
|
||||||
// try {
|
try {
|
||||||
// const user = await User.findByPk(req.user.id);
|
const user = await User.findByPk(req.user.id);
|
||||||
|
|
||||||
// if (!user || !user.stripeConnectedAccountId) {
|
if (!user || !user.stripeConnectedAccountId) {
|
||||||
// return res.status(400).json({ error: "No connected account found" });
|
return res.status(400).json({ error: "No connected account found" });
|
||||||
// }
|
}
|
||||||
|
|
||||||
// const { refreshUrl, returnUrl } = req.body;
|
const { refreshUrl, returnUrl } = req.body;
|
||||||
|
|
||||||
// if (!refreshUrl || !returnUrl) {
|
if (!refreshUrl || !returnUrl) {
|
||||||
// return res
|
return res
|
||||||
// .status(400)
|
.status(400)
|
||||||
// .json({ error: "refreshUrl and returnUrl are required" });
|
.json({ error: "refreshUrl and returnUrl are required" });
|
||||||
// }
|
}
|
||||||
|
|
||||||
// const accountLink = await StripeService.createAccountLink(
|
const accountLink = await StripeService.createAccountLink(
|
||||||
// user.stripeConnectedAccountId,
|
user.stripeConnectedAccountId,
|
||||||
// refreshUrl,
|
refreshUrl,
|
||||||
// returnUrl
|
returnUrl
|
||||||
// );
|
);
|
||||||
|
|
||||||
// res.json({
|
res.json({
|
||||||
// url: accountLink.url,
|
url: accountLink.url,
|
||||||
// expiresAt: accountLink.expires_at,
|
expiresAt: accountLink.expires_at,
|
||||||
// });
|
});
|
||||||
// } catch (error) {
|
} catch (error) {
|
||||||
// console.error("Error creating account link:", error);
|
console.error("Error creating account link:", error);
|
||||||
// res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
// }
|
}
|
||||||
// });
|
});
|
||||||
|
|
||||||
// // Get account status
|
// Get account status
|
||||||
// router.get("/account-status", authenticateToken, async (req, res) => {
|
router.get("/account-status", authenticateToken, async (req, res) => {
|
||||||
// try {
|
try {
|
||||||
// const user = await User.findByPk(req.user.id);
|
const user = await User.findByPk(req.user.id);
|
||||||
|
|
||||||
// if (!user || !user.stripeConnectedAccountId) {
|
if (!user || !user.stripeConnectedAccountId) {
|
||||||
// return res.status(400).json({ error: "No connected account found" });
|
return res.status(400).json({ error: "No connected account found" });
|
||||||
// }
|
}
|
||||||
|
|
||||||
// const accountStatus = await StripeService.getAccountStatus(
|
const accountStatus = await StripeService.getAccountStatus(
|
||||||
// user.stripeConnectedAccountId
|
user.stripeConnectedAccountId
|
||||||
// );
|
);
|
||||||
|
|
||||||
// res.json({
|
res.json({
|
||||||
// accountId: accountStatus.id,
|
accountId: accountStatus.id,
|
||||||
// detailsSubmitted: accountStatus.details_submitted,
|
detailsSubmitted: accountStatus.details_submitted,
|
||||||
// payoutsEnabled: accountStatus.payouts_enabled,
|
payoutsEnabled: accountStatus.payouts_enabled,
|
||||||
// capabilities: accountStatus.capabilities,
|
capabilities: accountStatus.capabilities,
|
||||||
// requirements: accountStatus.requirements,
|
requirements: accountStatus.requirements,
|
||||||
// });
|
});
|
||||||
// } catch (error) {
|
} catch (error) {
|
||||||
// console.error("Error getting account status:", error);
|
console.error("Error getting account status:", error);
|
||||||
// res.status(500).json({ error: error.message });
|
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 });
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const betaRoutes = require("./routes/beta");
|
|||||||
const itemRequestRoutes = require("./routes/itemRequests");
|
const itemRequestRoutes = require("./routes/itemRequests");
|
||||||
const stripeRoutes = require("./routes/stripe");
|
const stripeRoutes = require("./routes/stripe");
|
||||||
|
|
||||||
|
const PayoutProcessor = require("./jobs/payoutProcessor");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -52,6 +54,10 @@ sequelize
|
|||||||
.sync({ alter: true })
|
.sync({ alter: true })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log("Database synced");
|
console.log("Database synced");
|
||||||
|
|
||||||
|
// Start the payout processor
|
||||||
|
const payoutJobs = PayoutProcessor.startScheduledPayouts();
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server is running on port ${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" }) {
|
static async createConnectedAccount({ email, country = "US" }) {
|
||||||
// try {
|
try {
|
||||||
// const account = await stripe.accounts.create({
|
const account = await stripe.accounts.create({
|
||||||
// type: "standard",
|
type: "standard",
|
||||||
// email,
|
email,
|
||||||
// country,
|
country,
|
||||||
// controller: {
|
capabilities: {
|
||||||
// stripe_dashboard: {
|
transfers: { requested: true },
|
||||||
// type: "full",
|
},
|
||||||
// },
|
});
|
||||||
// },
|
|
||||||
// capabilities: {
|
|
||||||
// transfers: { requested: true },
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
|
|
||||||
// return account;
|
return account;
|
||||||
// } catch (error) {
|
} catch (error) {
|
||||||
// console.error("Error creating connected account:", error);
|
console.error("Error creating connected account:", error);
|
||||||
// throw error;
|
throw error;
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
|
||||||
// static async createAccountLink(accountId, refreshUrl, returnUrl) {
|
static async createAccountLink(accountId, refreshUrl, returnUrl) {
|
||||||
// try {
|
try {
|
||||||
// const accountLink = await stripe.accountLinks.create({
|
const accountLink = await stripe.accountLinks.create({
|
||||||
// account: accountId,
|
account: accountId,
|
||||||
// refresh_url: refreshUrl,
|
refresh_url: refreshUrl,
|
||||||
// return_url: returnUrl,
|
return_url: returnUrl,
|
||||||
// type: "account_onboarding",
|
type: "account_onboarding",
|
||||||
// });
|
});
|
||||||
|
|
||||||
// return accountLink;
|
return accountLink;
|
||||||
// } catch (error) {
|
} catch (error) {
|
||||||
// console.error("Error creating account link:", error);
|
console.error("Error creating account link:", error);
|
||||||
// throw error;
|
throw error;
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
|
||||||
// static async getAccountStatus(accountId) {
|
static async getAccountStatus(accountId) {
|
||||||
// try {
|
try {
|
||||||
// const account = await stripe.accounts.retrieve(accountId);
|
const account = await stripe.accounts.retrieve(accountId);
|
||||||
// return {
|
return {
|
||||||
// id: account.id,
|
id: account.id,
|
||||||
// details_submitted: account.details_submitted,
|
details_submitted: account.details_submitted,
|
||||||
// payouts_enabled: account.payouts_enabled,
|
payouts_enabled: account.payouts_enabled,
|
||||||
// capabilities: account.capabilities,
|
capabilities: account.capabilities,
|
||||||
// requirements: account.requirements,
|
requirements: account.requirements,
|
||||||
// };
|
};
|
||||||
// } catch (error) {
|
} catch (error) {
|
||||||
// console.error("Error retrieving account status:", error);
|
console.error("Error retrieving account status:", error);
|
||||||
// throw error;
|
throw error;
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
|
||||||
// static async createPaymentIntent({
|
static async createTransfer({
|
||||||
// amount,
|
amount,
|
||||||
// currency = "usd",
|
currency = "usd",
|
||||||
// connectedAccountId,
|
destination,
|
||||||
// applicationFeeAmount,
|
metadata = {},
|
||||||
// metadata = {},
|
}) {
|
||||||
// }) {
|
try {
|
||||||
// try {
|
const transfer = await stripe.transfers.create({
|
||||||
// const paymentIntent = await stripe.paymentIntents.create({
|
amount: Math.round(amount * 100), // Convert to cents
|
||||||
// amount,
|
currency,
|
||||||
// currency,
|
destination,
|
||||||
// transfer_data: {
|
metadata,
|
||||||
// destination: connectedAccountId,
|
});
|
||||||
// },
|
|
||||||
// application_fee_amount: applicationFeeAmount,
|
|
||||||
// metadata,
|
|
||||||
// automatic_payment_methods: {
|
|
||||||
// enabled: true,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
|
|
||||||
// return paymentIntent;
|
return transfer;
|
||||||
// } catch (error) {
|
} catch (error) {
|
||||||
// console.error("Error creating payment intent:", error);
|
console.error("Error creating transfer:", error);
|
||||||
// throw 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;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = StripeService;
|
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;
|
||||||
@@ -1402,19 +1402,6 @@ const Profile: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeSection === "payment" && (
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-4">Payment Methods</h4>
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-body">
|
|
||||||
<p className="text-muted">
|
|
||||||
Payment method management coming soon...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user