started payouts

This commit is contained in:
jackiettran
2025-08-29 00:32:06 -04:00
parent 0f04182768
commit b52104c3fa
13 changed files with 578 additions and 252 deletions

1
.gitignore vendored
View File

@@ -17,6 +17,7 @@ node_modules/
.env.development.local
.env.test.local
.env.production.local
.mcp.json
# Logs
npm-debug.log*

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

View File

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

View File

@@ -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: {

View File

@@ -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",

View File

@@ -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",

View File

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

View File

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

View File

@@ -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}`);
});

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

View File

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

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

View File

@@ -1402,19 +1402,6 @@ const Profile: React.FC = () => {
</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>