stripe webhooks. removed payout cron. webhook for when amount is deposited into bank. More communication about payout timelines
This commit is contained in:
@@ -1,40 +1,12 @@
|
|||||||
const cron = require("node-cron");
|
const cron = require("node-cron");
|
||||||
const PayoutService = require("../services/payoutService");
|
const PayoutService = require("../services/payoutService");
|
||||||
|
|
||||||
const paymentsSchedule = "0 * * * *"; // Run every hour at minute 0
|
// Daily retry job for failed payouts (hourly job removed - payouts are now triggered immediately on completion)
|
||||||
const retrySchedule = "0 7 * * *"; // Retry failed payouts once daily at 7 AM
|
const retrySchedule = "0 7 * * *"; // Retry failed payouts once daily at 7 AM
|
||||||
|
|
||||||
class PayoutProcessor {
|
class PayoutProcessor {
|
||||||
static startScheduledPayouts() {
|
static startScheduledPayouts() {
|
||||||
console.log("Starting automated payout processor...");
|
console.log("Starting payout retry 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(
|
const retryJob = cron.schedule(
|
||||||
retrySchedule,
|
retrySchedule,
|
||||||
@@ -59,27 +31,22 @@ class PayoutProcessor {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start the jobs
|
// Start the job
|
||||||
payoutJob.start();
|
|
||||||
retryJob.start();
|
retryJob.start();
|
||||||
|
|
||||||
console.log("Payout processor jobs scheduled:");
|
console.log("Payout processor jobs scheduled:");
|
||||||
console.log("- Hourly payout processing: " + paymentsSchedule);
|
|
||||||
console.log("- Daily retry processing: " + retrySchedule);
|
console.log("- Daily retry processing: " + retrySchedule);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
payoutJob,
|
|
||||||
retryJob,
|
retryJob,
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
payoutJob.stop();
|
|
||||||
retryJob.stop();
|
retryJob.stop();
|
||||||
console.log("Payout processor jobs stopped");
|
console.log("Payout processor jobs stopped");
|
||||||
},
|
},
|
||||||
|
|
||||||
getStatus() {
|
getStatus() {
|
||||||
return {
|
return {
|
||||||
payoutJobRunning: payoutJob.getStatus() === "scheduled",
|
|
||||||
retryJobRunning: retryJob.getStatus() === "scheduled",
|
retryJobRunning: retryJob.getStatus() === "scheduled",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn("Users", "stripePayoutsEnabled", {
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.removeColumn("Users", "stripePayoutsEnabled");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
// Add bankDepositStatus enum column
|
||||||
|
await queryInterface.addColumn("Rentals", "bankDepositStatus", {
|
||||||
|
type: Sequelize.ENUM("pending", "in_transit", "paid", "failed", "canceled"),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add bankDepositAt timestamp
|
||||||
|
await queryInterface.addColumn("Rentals", "bankDepositAt", {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add stripePayoutId to track which Stripe payout included this transfer
|
||||||
|
await queryInterface.addColumn("Rentals", "stripePayoutId", {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add bankDepositFailureCode for failed deposits
|
||||||
|
await queryInterface.addColumn("Rentals", "bankDepositFailureCode", {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.removeColumn("Rentals", "bankDepositFailureCode");
|
||||||
|
await queryInterface.removeColumn("Rentals", "stripePayoutId");
|
||||||
|
await queryInterface.removeColumn("Rentals", "bankDepositAt");
|
||||||
|
await queryInterface.removeColumn("Rentals", "bankDepositStatus");
|
||||||
|
|
||||||
|
// Drop the enum type (PostgreSQL specific)
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
'DROP TYPE IF EXISTS "enum_Rentals_bankDepositStatus";'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -80,6 +80,20 @@ const Rental = sequelize.define("Rental", {
|
|||||||
stripeTransferId: {
|
stripeTransferId: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
},
|
},
|
||||||
|
// Bank deposit tracking fields (for tracking when Stripe deposits to owner's bank)
|
||||||
|
bankDepositStatus: {
|
||||||
|
type: DataTypes.ENUM("pending", "in_transit", "paid", "failed", "canceled"),
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
bankDepositAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
},
|
||||||
|
stripePayoutId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
},
|
||||||
|
bankDepositFailureCode: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
},
|
||||||
// Refund tracking fields
|
// Refund tracking fields
|
||||||
refundAmount: {
|
refundAmount: {
|
||||||
type: DataTypes.DECIMAL(10, 2),
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ const User = sequelize.define(
|
|||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
},
|
},
|
||||||
|
stripePayoutsEnabled: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
stripeCustomerId: {
|
stripeCustomerId: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const FeeCalculator = require("../utils/feeCalculator");
|
|||||||
const RentalDurationCalculator = require("../utils/rentalDurationCalculator");
|
const RentalDurationCalculator = require("../utils/rentalDurationCalculator");
|
||||||
const RefundService = require("../services/refundService");
|
const RefundService = require("../services/refundService");
|
||||||
const LateReturnService = require("../services/lateReturnService");
|
const LateReturnService = require("../services/lateReturnService");
|
||||||
|
const PayoutService = require("../services/payoutService");
|
||||||
const DamageAssessmentService = require("../services/damageAssessmentService");
|
const DamageAssessmentService = require("../services/damageAssessmentService");
|
||||||
const emailServices = require("../services/email");
|
const emailServices = require("../services/email");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
@@ -877,51 +878,6 @@ router.post("/:id/review-item", authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark rental as completed (owner only)
|
|
||||||
router.post("/:id/mark-completed", authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const rental = await Rental.findByPk(req.params.id);
|
|
||||||
|
|
||||||
if (!rental) {
|
|
||||||
return res.status(404).json({ error: "Rental not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rental.ownerId !== req.user.id) {
|
|
||||||
return res
|
|
||||||
.status(403)
|
|
||||||
.json({ error: "Only owners can mark rentals as completed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isActive(rental)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: "Can only mark active rentals as completed",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await rental.update({ status: "completed", payoutStatus: "pending" });
|
|
||||||
|
|
||||||
const updatedRental = await Rental.findByPk(rental.id, {
|
|
||||||
include: [
|
|
||||||
{ model: Item, as: "item" },
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: "owner",
|
|
||||||
attributes: ["id", "firstName", "lastName"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: "renter",
|
|
||||||
attributes: ["id", "firstName", "lastName"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(updatedRental);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ error: "Failed to update rental" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate fees for rental pricing display
|
// Calculate fees for rental pricing display
|
||||||
router.post("/calculate-fees", authenticateToken, async (req, res) => {
|
router.post("/calculate-fees", authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -1270,6 +1226,14 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
|
|||||||
rentalId,
|
rentalId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger immediate payout attempt (non-blocking)
|
||||||
|
PayoutService.triggerPayoutOnCompletion(rentalId).catch((err) => {
|
||||||
|
logger.error("Error triggering payout on mark-return", {
|
||||||
|
rentalId,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "damaged":
|
case "damaged":
|
||||||
|
|||||||
93
backend/routes/stripeWebhooks.js
Normal file
93
backend/routes/stripeWebhooks.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const StripeWebhookService = require("../services/stripeWebhookService");
|
||||||
|
const logger = require("../utils/logger");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /stripe/webhooks
|
||||||
|
* Stripe webhook endpoint - receives events from Stripe.
|
||||||
|
* Must use raw body for signature verification.
|
||||||
|
*/
|
||||||
|
router.post("/", async (req, res) => {
|
||||||
|
const signature = req.headers["stripe-signature"];
|
||||||
|
|
||||||
|
if (!signature) {
|
||||||
|
logger.warn("Webhook request missing stripe-signature header");
|
||||||
|
return res.status(400).json({ error: "Missing signature" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!WEBHOOK_SECRET) {
|
||||||
|
logger.error("STRIPE_WEBHOOK_SECRET not configured");
|
||||||
|
return res.status(500).json({ error: "Webhook not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let event;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use rawBody stored by bodyParser in server.js
|
||||||
|
event = StripeWebhookService.constructEvent(
|
||||||
|
req.rawBody,
|
||||||
|
signature,
|
||||||
|
WEBHOOK_SECRET
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Webhook signature verification failed", {
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
return res.status(400).json({ error: "Invalid signature" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log event receipt for debugging
|
||||||
|
// For Connect account events, event.account contains the connected account ID
|
||||||
|
logger.info("Stripe webhook received", {
|
||||||
|
eventId: event.id,
|
||||||
|
eventType: event.type,
|
||||||
|
connectedAccount: event.account || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (event.type) {
|
||||||
|
case "account.updated":
|
||||||
|
await StripeWebhookService.handleAccountUpdated(event.data.object);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "payout.paid":
|
||||||
|
// Payout to connected account's bank succeeded
|
||||||
|
await StripeWebhookService.handlePayoutPaid(
|
||||||
|
event.data.object,
|
||||||
|
event.account
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "payout.failed":
|
||||||
|
// Payout to connected account's bank failed
|
||||||
|
await StripeWebhookService.handlePayoutFailed(
|
||||||
|
event.data.object,
|
||||||
|
event.account
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.info("Unhandled webhook event type", { type: event.type });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always return 200 to acknowledge receipt
|
||||||
|
res.json({ received: true, eventId: event.id });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error processing webhook", {
|
||||||
|
eventId: event.id,
|
||||||
|
eventType: event.type,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Still return 200 to prevent Stripe retries for processing errors
|
||||||
|
// Failed payouts will be handled by retry job
|
||||||
|
res.json({ received: true, eventId: event.id, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -25,6 +25,7 @@ const rentalRoutes = require("./routes/rentals");
|
|||||||
const messageRoutes = require("./routes/messages");
|
const messageRoutes = require("./routes/messages");
|
||||||
const forumRoutes = require("./routes/forum");
|
const forumRoutes = require("./routes/forum");
|
||||||
const stripeRoutes = require("./routes/stripe");
|
const stripeRoutes = require("./routes/stripe");
|
||||||
|
const stripeWebhookRoutes = require("./routes/stripeWebhooks");
|
||||||
const mapsRoutes = require("./routes/maps");
|
const mapsRoutes = require("./routes/maps");
|
||||||
const conditionCheckRoutes = require("./routes/conditionChecks");
|
const conditionCheckRoutes = require("./routes/conditionChecks");
|
||||||
const feedbackRoutes = require("./routes/feedback");
|
const feedbackRoutes = require("./routes/feedback");
|
||||||
@@ -145,6 +146,9 @@ app.use(
|
|||||||
// Health check endpoints (no auth, no rate limiting)
|
// Health check endpoints (no auth, no rate limiting)
|
||||||
app.use("/health", healthRoutes);
|
app.use("/health", healthRoutes);
|
||||||
|
|
||||||
|
// Stripe webhooks (no auth, uses signature verification instead)
|
||||||
|
app.use("/api/stripe/webhooks", stripeWebhookRoutes);
|
||||||
|
|
||||||
// Root endpoint
|
// Root endpoint
|
||||||
app.get("/", (req, res) => {
|
app.get("/", (req, res) => {
|
||||||
res.json({ message: "Village Share API is running!" });
|
res.json({ message: "Village Share API is running!" });
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ class RentalFlowEmailService {
|
|||||||
<li><strong>Automatic payouts</strong> when rentals complete</li>
|
<li><strong>Automatic payouts</strong> when rentals complete</li>
|
||||||
<li><strong>Secure transfers</strong> directly to your bank account</li>
|
<li><strong>Secure transfers</strong> directly to your bank account</li>
|
||||||
<li><strong>Track all earnings</strong> in one dashboard</li>
|
<li><strong>Track all earnings</strong> in one dashboard</li>
|
||||||
<li><strong>Fast deposits</strong> (typically 2-3 business days)</li>
|
<li><strong>Fast deposits</strong> (typically 2-7 business days)</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Setup only takes about 5 minutes and you only need to do it once.</p>
|
<p>Setup only takes about 5 minutes and you only need to do it once.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1033,7 +1033,7 @@ class RentalFlowEmailService {
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p style="font-size: 14px; color: #6c757d;">
|
<p style="font-size: 14px; color: #6c757d;">
|
||||||
Your earnings will be automatically transferred to your account when the rental period ends and any dispute windows close.
|
Your earnings are transferred immediately when the rental is marked complete. Funds typically reach your bank within 2-7 business days.
|
||||||
</p>
|
</p>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -1056,7 +1056,7 @@ class RentalFlowEmailService {
|
|||||||
<li><strong>Automatic payouts</strong> when the rental period ends</li>
|
<li><strong>Automatic payouts</strong> when the rental period ends</li>
|
||||||
<li><strong>Secure transfers</strong> directly to your bank account</li>
|
<li><strong>Secure transfers</strong> directly to your bank account</li>
|
||||||
<li><strong>Track all earnings</strong> in one dashboard</li>
|
<li><strong>Track all earnings</strong> in one dashboard</li>
|
||||||
<li><strong>Fast deposits</strong> (typically 2-3 business days)</li>
|
<li><strong>Fast deposits</strong> (typically 2-7 business days)</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Setup only takes about 5 minutes and you only need to do it once.</p>
|
<p>Setup only takes about 5 minutes and you only need to do it once.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1070,10 +1070,11 @@ class RentalFlowEmailService {
|
|||||||
} else if (hasStripeAccount && isPaidRental) {
|
} else if (hasStripeAccount && isPaidRental) {
|
||||||
stripeSection = `
|
stripeSection = `
|
||||||
<div class="success-box">
|
<div class="success-box">
|
||||||
<p><strong>✓ Earnings Account Active</strong></p>
|
<p><strong>✓ Payout Initiated</strong></p>
|
||||||
<p>Your earnings account is set up. You'll automatically receive \\$${payoutAmount.toFixed(
|
<p>Your earnings of <strong>\\$${payoutAmount.toFixed(
|
||||||
2
|
2
|
||||||
)} when the rental period ends.</p>
|
)}</strong> have been transferred to your Stripe account.</p>
|
||||||
|
<p style="font-size: 14px; margin-top: 10px;">Funds typically reach your bank within 2-7 business days.</p>
|
||||||
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
|
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const { Rental, Item, User } = require("../models");
|
const { Rental, Item, User } = require("../models");
|
||||||
const emailServices = require("./email");
|
const emailServices = require("./email");
|
||||||
const { isActive } = require("../utils/rentalStatus");
|
const { isActive } = require("../utils/rentalStatus");
|
||||||
|
const logger = require("../utils/logger");
|
||||||
|
|
||||||
class LateReturnService {
|
class LateReturnService {
|
||||||
/**
|
/**
|
||||||
@@ -100,6 +101,18 @@ class LateReturnService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger immediate payout if rental is verified to be actually completed not late
|
||||||
|
if (!lateCalculation.isLate) {
|
||||||
|
// Import here to avoid circular dependency
|
||||||
|
const PayoutService = require("./payoutService");
|
||||||
|
PayoutService.triggerPayoutOnCompletion(rentalId).catch((err) => {
|
||||||
|
logger.error("Error triggering payout on late return processing", {
|
||||||
|
rentalId,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rental: updatedRental,
|
rental: updatedRental,
|
||||||
lateCalculation,
|
lateCalculation,
|
||||||
|
|||||||
@@ -5,6 +5,87 @@ const logger = require("../utils/logger");
|
|||||||
const { Op } = require("sequelize");
|
const { Op } = require("sequelize");
|
||||||
|
|
||||||
class PayoutService {
|
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() {
|
static async getEligiblePayouts() {
|
||||||
try {
|
try {
|
||||||
const eligibleRentals = await Rental.findAll({
|
const eligibleRentals = await Rental.findAll({
|
||||||
@@ -21,6 +102,7 @@ class PayoutService {
|
|||||||
stripeConnectedAccountId: {
|
stripeConnectedAccountId: {
|
||||||
[Op.not]: null,
|
[Op.not]: null,
|
||||||
},
|
},
|
||||||
|
stripePayoutsEnabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -167,6 +249,7 @@ class PayoutService {
|
|||||||
stripeConnectedAccountId: {
|
stripeConnectedAccountId: {
|
||||||
[Op.not]: null,
|
[Op.not]: null,
|
||||||
},
|
},
|
||||||
|
stripePayoutsEnabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
298
backend/services/stripeWebhookService.js
Normal file
298
backend/services/stripeWebhookService.js
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
||||||
|
const { User, Rental, Item } = require("../models");
|
||||||
|
const PayoutService = require("./payoutService");
|
||||||
|
const logger = require("../utils/logger");
|
||||||
|
const { Op } = require("sequelize");
|
||||||
|
|
||||||
|
class StripeWebhookService {
|
||||||
|
/**
|
||||||
|
* Verify webhook signature and construct event
|
||||||
|
*/
|
||||||
|
static constructEvent(rawBody, signature, webhookSecret) {
|
||||||
|
return stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle account.updated webhook event.
|
||||||
|
* Triggers payouts for owner when payouts_enabled becomes true.
|
||||||
|
* @param {Object} account - The Stripe account object from the webhook
|
||||||
|
* @returns {Object} - { processed, payoutsTriggered, payoutResults }
|
||||||
|
*/
|
||||||
|
static async handleAccountUpdated(account) {
|
||||||
|
const accountId = account.id;
|
||||||
|
const payoutsEnabled = account.payouts_enabled;
|
||||||
|
|
||||||
|
logger.info("Processing account.updated webhook", {
|
||||||
|
accountId,
|
||||||
|
payoutsEnabled,
|
||||||
|
chargesEnabled: account.charges_enabled,
|
||||||
|
detailsSubmitted: account.details_submitted,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find user with this Stripe account
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: { stripeConnectedAccountId: accountId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.warn("No user found for Stripe account", { accountId });
|
||||||
|
return { processed: false, reason: "user_not_found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousPayoutsEnabled = user.stripePayoutsEnabled;
|
||||||
|
|
||||||
|
// Update user's payouts_enabled status
|
||||||
|
await user.update({ stripePayoutsEnabled: payoutsEnabled });
|
||||||
|
|
||||||
|
logger.info("Updated user stripePayoutsEnabled", {
|
||||||
|
userId: user.id,
|
||||||
|
accountId,
|
||||||
|
previousPayoutsEnabled,
|
||||||
|
newPayoutsEnabled: payoutsEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If payouts just became enabled (false -> true), process pending payouts
|
||||||
|
if (payoutsEnabled && !previousPayoutsEnabled) {
|
||||||
|
logger.info("Payouts enabled for user, processing pending payouts", {
|
||||||
|
userId: user.id,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await this.processPayoutsForOwner(user.id);
|
||||||
|
return {
|
||||||
|
processed: true,
|
||||||
|
payoutsTriggered: true,
|
||||||
|
payoutResults: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { processed: true, payoutsTriggered: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all eligible payouts for a specific owner.
|
||||||
|
* Called when owner completes Stripe onboarding.
|
||||||
|
* @param {string} ownerId - The owner's user ID
|
||||||
|
* @returns {Object} - { successful, failed, totalProcessed }
|
||||||
|
*/
|
||||||
|
static async processPayoutsForOwner(ownerId) {
|
||||||
|
const eligibleRentals = await Rental.findAll({
|
||||||
|
where: {
|
||||||
|
ownerId,
|
||||||
|
status: "completed",
|
||||||
|
paymentStatus: "paid",
|
||||||
|
payoutStatus: "pending",
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "owner",
|
||||||
|
where: {
|
||||||
|
stripeConnectedAccountId: { [Op.not]: null },
|
||||||
|
stripePayoutsEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ model: Item, as: "item" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Found eligible rentals for owner payout", {
|
||||||
|
ownerId,
|
||||||
|
count: eligibleRentals.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
successful: [],
|
||||||
|
failed: [],
|
||||||
|
totalProcessed: eligibleRentals.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rental of eligibleRentals) {
|
||||||
|
try {
|
||||||
|
const result = await PayoutService.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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Processed payouts for owner", {
|
||||||
|
ownerId,
|
||||||
|
successful: results.successful.length,
|
||||||
|
failed: results.failed.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle payout.paid webhook event.
|
||||||
|
* Updates rentals when funds are deposited to owner's bank account.
|
||||||
|
* @param {Object} payout - The Stripe payout object
|
||||||
|
* @param {string} connectedAccountId - The connected account ID (from event.account)
|
||||||
|
* @returns {Object} - { processed, rentalsUpdated }
|
||||||
|
*/
|
||||||
|
static async handlePayoutPaid(payout, connectedAccountId) {
|
||||||
|
logger.info("Processing payout.paid webhook", {
|
||||||
|
payoutId: payout.id,
|
||||||
|
connectedAccountId,
|
||||||
|
amount: payout.amount,
|
||||||
|
arrivalDate: payout.arrival_date,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connectedAccountId) {
|
||||||
|
logger.warn("payout.paid webhook missing connected account ID", {
|
||||||
|
payoutId: payout.id,
|
||||||
|
});
|
||||||
|
return { processed: false, reason: "missing_account_id" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch balance transactions included in this payout
|
||||||
|
// Filter by type 'transfer' to get only our platform transfers
|
||||||
|
const balanceTransactions = await stripe.balanceTransactions.list(
|
||||||
|
{
|
||||||
|
payout: payout.id,
|
||||||
|
type: "transfer",
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
{ stripeAccount: connectedAccountId }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract transfer IDs from balance transactions
|
||||||
|
// The 'source' field contains the transfer ID
|
||||||
|
const transferIds = balanceTransactions.data
|
||||||
|
.map((bt) => bt.source)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (transferIds.length === 0) {
|
||||||
|
logger.info("No transfer balance transactions in payout", {
|
||||||
|
payoutId: payout.id,
|
||||||
|
connectedAccountId,
|
||||||
|
});
|
||||||
|
return { processed: true, rentalsUpdated: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Found transfers in payout", {
|
||||||
|
payoutId: payout.id,
|
||||||
|
transferCount: transferIds.length,
|
||||||
|
transferIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update all rentals with matching stripeTransferId
|
||||||
|
const [updatedCount] = await Rental.update(
|
||||||
|
{
|
||||||
|
bankDepositStatus: "paid",
|
||||||
|
bankDepositAt: new Date(payout.arrival_date * 1000),
|
||||||
|
stripePayoutId: payout.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
stripeTransferId: { [Op.in]: transferIds },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Updated rentals with bank deposit status", {
|
||||||
|
payoutId: payout.id,
|
||||||
|
rentalsUpdated: updatedCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { processed: true, rentalsUpdated: updatedCount };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error processing payout.paid webhook", {
|
||||||
|
payoutId: payout.id,
|
||||||
|
connectedAccountId,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle payout.failed webhook event.
|
||||||
|
* Updates rentals when bank deposit fails.
|
||||||
|
* @param {Object} payout - The Stripe payout object
|
||||||
|
* @param {string} connectedAccountId - The connected account ID (from event.account)
|
||||||
|
* @returns {Object} - { processed, rentalsUpdated }
|
||||||
|
*/
|
||||||
|
static async handlePayoutFailed(payout, connectedAccountId) {
|
||||||
|
logger.info("Processing payout.failed webhook", {
|
||||||
|
payoutId: payout.id,
|
||||||
|
connectedAccountId,
|
||||||
|
failureCode: payout.failure_code,
|
||||||
|
failureMessage: payout.failure_message,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connectedAccountId) {
|
||||||
|
logger.warn("payout.failed webhook missing connected account ID", {
|
||||||
|
payoutId: payout.id,
|
||||||
|
});
|
||||||
|
return { processed: false, reason: "missing_account_id" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch balance transactions included in this payout
|
||||||
|
const balanceTransactions = await stripe.balanceTransactions.list(
|
||||||
|
{
|
||||||
|
payout: payout.id,
|
||||||
|
type: "transfer",
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
{ stripeAccount: connectedAccountId }
|
||||||
|
);
|
||||||
|
|
||||||
|
const transferIds = balanceTransactions.data
|
||||||
|
.map((bt) => bt.source)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (transferIds.length === 0) {
|
||||||
|
logger.info("No transfer balance transactions in failed payout", {
|
||||||
|
payoutId: payout.id,
|
||||||
|
connectedAccountId,
|
||||||
|
});
|
||||||
|
return { processed: true, rentalsUpdated: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all rentals with matching stripeTransferId
|
||||||
|
const [updatedCount] = await Rental.update(
|
||||||
|
{
|
||||||
|
bankDepositStatus: "failed",
|
||||||
|
stripePayoutId: payout.id,
|
||||||
|
bankDepositFailureCode: payout.failure_code || "unknown",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
stripeTransferId: { [Op.in]: transferIds },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.warn("Updated rentals with failed bank deposit status", {
|
||||||
|
payoutId: payout.id,
|
||||||
|
rentalsUpdated: updatedCount,
|
||||||
|
failureCode: payout.failure_code,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { processed: true, rentalsUpdated: updatedCount };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error processing payout.failed webhook", {
|
||||||
|
payoutId: payout.id,
|
||||||
|
connectedAccountId,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = StripeWebhookService;
|
||||||
@@ -381,11 +381,27 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<h2>Payout Timeline</h2>
|
||||||
|
<table class="info-table">
|
||||||
|
<tr>
|
||||||
|
<th style="color: #28a745;">✓ Rental Completed</th>
|
||||||
|
<td>Done</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th style="color: #28a745;">✓ Transfer Initiated</th>
|
||||||
|
<td>Today</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th style="color: #0066cc;">○ Funds in Your Bank</th>
|
||||||
|
<td>2-7 business days</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<p><strong>When will I receive the funds?</strong></p>
|
<p><strong>When will I receive the funds?</strong></p>
|
||||||
<p>
|
<p>
|
||||||
Funds are typically available in your bank account within
|
Funds are typically available in your bank account within
|
||||||
<strong>2-3 business days</strong> from the transfer date.
|
<strong>2-7 business days</strong> from the transfer date, depending on your bank and Stripe's payout schedule.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
You can track this transfer in your Stripe Dashboard using the
|
You can track this transfer in your Stripe Dashboard using the
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ const EarningsStatus: React.FC<EarningsStatusProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<h6 className="text-success">Earnings Active</h6>
|
<h6 className="text-success">Earnings Active</h6>
|
||||||
<p className="text-muted small mb-3">
|
<p className="text-muted small mb-3">
|
||||||
Your earnings are set up and working. You'll receive payments
|
Payouts are sent immediately when rentals complete. Funds reach your
|
||||||
automatically.
|
bank in 2-7 business days.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="small text-start">
|
<div className="small text-start">
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
|
|||||||
colorText: "#212529",
|
colorText: "#212529",
|
||||||
colorDanger: "#dc3545",
|
colorDanger: "#dc3545",
|
||||||
fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
|
fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
|
||||||
fontSizeBase: "16px",
|
fontSizeBase: "20px",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
spacingUnit: "4px",
|
spacingUnit: "4px",
|
||||||
},
|
},
|
||||||
@@ -170,9 +170,9 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
|
|||||||
style={{ fontSize: "2rem" }}
|
style={{ fontSize: "2rem" }}
|
||||||
></i>
|
></i>
|
||||||
</div>
|
</div>
|
||||||
<h6>Automatic</h6>
|
<h6>Instant Payouts</h6>
|
||||||
<small className="text-muted">
|
<small className="text-muted">
|
||||||
Earnings are processed automatically
|
Transferred when rentals complete
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4">
|
<div className="col-md-4">
|
||||||
@@ -182,9 +182,9 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
|
|||||||
style={{ fontSize: "2rem" }}
|
style={{ fontSize: "2rem" }}
|
||||||
></i>
|
></i>
|
||||||
</div>
|
</div>
|
||||||
<h6>Direct Deposit</h6>
|
<h6>Fast Deposits</h6>
|
||||||
<small className="text-muted">
|
<small className="text-muted">
|
||||||
Funds go directly to your bank
|
In your bank in 2-7 business days
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,7 +197,7 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
|
|||||||
<li>Verify your identity securely</li>
|
<li>Verify your identity securely</li>
|
||||||
<li>Provide bank account details for deposits</li>
|
<li>Provide bank account details for deposits</li>
|
||||||
<li>The setup process takes about 5 minutes</li>
|
<li>The setup process takes about 5 minutes</li>
|
||||||
<li>Start earning immediately after setup</li>
|
<li>Receive payouts instantly when rentals complete</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ const EarningsDashboard: React.FC = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [earningsData, setEarningsData] = useState<EarningsData | null>(null);
|
const [earningsData, setEarningsData] = useState<EarningsData | null>(null);
|
||||||
const [userProfile, setUserProfile] = useState<User | null>(null);
|
const [userProfile, setUserProfile] = useState<User | null>(null);
|
||||||
const [accountStatus, setAccountStatus] = useState<AccountStatus | null>(null);
|
const [accountStatus, setAccountStatus] = useState<AccountStatus | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,7 +77,7 @@ const EarningsDashboard: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const pendingEarnings = completedRentals
|
const pendingEarnings = completedRentals
|
||||||
.filter((rental: Rental) => rental.payoutStatus === "pending")
|
.filter((rental: Rental) => rental.bankDepositStatus !== "paid")
|
||||||
.reduce(
|
.reduce(
|
||||||
(sum: number, rental: Rental) =>
|
(sum: number, rental: Rental) =>
|
||||||
sum + parseFloat(rental.payoutAmount?.toString() || "0"),
|
sum + parseFloat(rental.payoutAmount?.toString() || "0"),
|
||||||
@@ -83,7 +85,7 @@ const EarningsDashboard: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const completedEarnings = completedRentals
|
const completedEarnings = completedRentals
|
||||||
.filter((rental: Rental) => rental.payoutStatus === "completed")
|
.filter((rental: Rental) => rental.bankDepositStatus === "paid")
|
||||||
.reduce(
|
.reduce(
|
||||||
(sum: number, rental: Rental) =>
|
(sum: number, rental: Rental) =>
|
||||||
sum + parseFloat(rental.payoutAmount?.toString() || "0"),
|
sum + parseFloat(rental.payoutAmount?.toString() || "0"),
|
||||||
@@ -231,21 +233,57 @@ const EarningsDashboard: React.FC = () => {
|
|||||||
</strong>
|
</strong>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
{(() => {
|
||||||
|
// Determine badge based on bank deposit and payout status
|
||||||
|
let badgeClass = "bg-secondary";
|
||||||
|
let badgeLabel = "Pending";
|
||||||
|
let badgeTooltip =
|
||||||
|
"Waiting for rental to complete or Stripe setup.";
|
||||||
|
|
||||||
|
if (rental.bankDepositStatus === "paid") {
|
||||||
|
badgeClass = "bg-success";
|
||||||
|
badgeLabel = "Deposited";
|
||||||
|
badgeTooltip = rental.bankDepositAt
|
||||||
|
? `Deposited to your bank on ${new Date(
|
||||||
|
rental.bankDepositAt
|
||||||
|
).toLocaleDateString()}`
|
||||||
|
: "Funds deposited to your bank account.";
|
||||||
|
} else if (
|
||||||
|
rental.bankDepositStatus === "failed"
|
||||||
|
) {
|
||||||
|
badgeClass = "bg-danger";
|
||||||
|
badgeLabel = "Deposit Failed";
|
||||||
|
badgeTooltip =
|
||||||
|
"Bank deposit failed. Please check your Stripe dashboard.";
|
||||||
|
} else if (
|
||||||
|
rental.bankDepositStatus === "in_transit"
|
||||||
|
) {
|
||||||
|
badgeClass = "bg-info";
|
||||||
|
badgeLabel = "In Transit to Bank";
|
||||||
|
badgeTooltip =
|
||||||
|
"Funds are on their way to your bank.";
|
||||||
|
} else if (rental.payoutStatus === "completed") {
|
||||||
|
badgeClass = "bg-info";
|
||||||
|
badgeLabel = "Transferred to Stripe";
|
||||||
|
badgeTooltip =
|
||||||
|
"In your Stripe balance. Bank deposit in 2-7 business days.";
|
||||||
|
} else if (rental.payoutStatus === "failed") {
|
||||||
|
badgeClass = "bg-danger";
|
||||||
|
badgeLabel = "Transfer Failed";
|
||||||
|
badgeTooltip =
|
||||||
|
"Transfer failed. We'll retry automatically.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<span
|
<span
|
||||||
className={`badge ${
|
className={`badge ${badgeClass}`}
|
||||||
rental.payoutStatus === "completed"
|
title={badgeTooltip}
|
||||||
? "bg-success"
|
style={{ cursor: "help" }}
|
||||||
: rental.payoutStatus === "failed"
|
|
||||||
? "bg-danger"
|
|
||||||
: "bg-secondary"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{rental.payoutStatus === "completed"
|
{badgeLabel}
|
||||||
? "Paid"
|
|
||||||
: rental.payoutStatus === "failed"
|
|
||||||
? "Failed"
|
|
||||||
: "Pending"}
|
|
||||||
</span>
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -274,7 +312,7 @@ const EarningsDashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
<div className="card">
|
<div className="card mb-4">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h5 className="mb-0">Quick Stats</h5>
|
<h5 className="mb-0">Quick Stats</h5>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,6 +339,46 @@ const EarningsDashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* How Payouts Work */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h5 className="mb-0">How Payouts Work</h5>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-start mb-3">
|
||||||
|
<span className="badge bg-success me-2">1</span>
|
||||||
|
<div>
|
||||||
|
<strong>Transfer Initiated</strong>
|
||||||
|
<p className="mb-0 small text-muted">
|
||||||
|
Immediate when rental is marked complete
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-start mb-3">
|
||||||
|
<span className="badge bg-success me-2">2</span>
|
||||||
|
<div>
|
||||||
|
<strong>Funds in Stripe</strong>
|
||||||
|
<p className="mb-0 small text-muted">
|
||||||
|
Instant — view in your Stripe dashboard
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-start">
|
||||||
|
<span className="badge bg-primary me-2">3</span>
|
||||||
|
<div>
|
||||||
|
<strong>Funds in Bank</strong>
|
||||||
|
<p className="mb-0 small text-muted">
|
||||||
|
2-7 business days (Stripe's schedule)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<p className="small text-muted mb-0">
|
||||||
|
<Link to="/faq#earnings">Learn more in our FAQ</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -70,17 +70,69 @@ const FAQ: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="small text-muted">
|
<p className="small text-muted">
|
||||||
<strong>Payout Timeline:</strong> Earnings are processed within 2 business
|
<strong>Payout Timeline:</strong> Earnings are transferred immediately when the rental is marked complete. Funds typically reach your bank within 2-7 business days. Make sure your Stripe account is set up to receive payouts.
|
||||||
days after the rental is completed. Make sure your Stripe account is set
|
|
||||||
up to receive payouts.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "When will I receive my earnings?",
|
question: "When will I receive my earnings?",
|
||||||
answer:
|
answer: (
|
||||||
"Earnings are typically processed within 2 business days after a rental is completed. The exact timing depends on your Stripe account settings and your bank's processing times.",
|
<div>
|
||||||
|
<p>The payout process has three stages:</p>
|
||||||
|
<div className="bg-light rounded p-3 mb-3">
|
||||||
|
<div className="d-flex align-items-start mb-3">
|
||||||
|
<span className="badge bg-success me-2">1</span>
|
||||||
|
<div>
|
||||||
|
<strong>Transfer Initiated</strong>
|
||||||
|
<span className="text-success ms-2">(Immediate)</span>
|
||||||
|
<p className="mb-0 small text-muted">
|
||||||
|
When the rental is marked complete, we immediately transfer your earnings to your Stripe account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-start mb-3">
|
||||||
|
<span className="badge bg-success me-2">2</span>
|
||||||
|
<div>
|
||||||
|
<strong>Funds in Stripe</strong>
|
||||||
|
<span className="text-success ms-2">(Instant)</span>
|
||||||
|
<p className="mb-0 small text-muted">
|
||||||
|
The transfer appears in your Stripe dashboard right away.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-start">
|
||||||
|
<span className="badge bg-primary me-2">3</span>
|
||||||
|
<div>
|
||||||
|
<strong>Funds in Your Bank</strong>
|
||||||
|
<span className="text-primary ms-2">(2-7 business days)</span>
|
||||||
|
<p className="mb-0 small text-muted">
|
||||||
|
Stripe automatically deposits funds to your bank account. Timing depends on your bank and Stripe's payout schedule.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="small text-muted mb-0">
|
||||||
|
<strong>Note:</strong> If you haven't completed Stripe onboarding, payouts are held until you finish setup. Once complete, all pending payouts are processed immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Why is my payout still pending?",
|
||||||
|
answer: (
|
||||||
|
<div>
|
||||||
|
<p>A payout may show as "pending" for a few reasons:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Rental not yet complete:</strong> The owner needs to mark the rental as complete before the payout is initiated.</li>
|
||||||
|
<li><strong>Stripe onboarding incomplete:</strong> You need to finish setting up your Stripe account. Visit your <Link to="/earnings">Earnings Dashboard</Link> to complete setup.</li>
|
||||||
|
<li><strong>Processing:</strong> Payouts are initiated immediately but may take a moment to process.</li>
|
||||||
|
</ul>
|
||||||
|
<p className="small text-muted mb-0">
|
||||||
|
If a payout fails for any reason, we automatically retry daily until it succeeds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "How do I set up my account to receive payments?",
|
question: "How do I set up my account to receive payments?",
|
||||||
|
|||||||
@@ -312,20 +312,6 @@ const Profile: React.FC = () => {
|
|||||||
alert("Thank you for your review!");
|
alert("Thank you for your review!");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCompleteClick = async (rental: Rental) => {
|
|
||||||
try {
|
|
||||||
await rentalAPI.markAsCompleted(rental.id);
|
|
||||||
setSelectedRentalForReview(rental);
|
|
||||||
setShowReviewRenterModal(true);
|
|
||||||
fetchRentalHistory(); // Refresh rental history
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(
|
|
||||||
"Failed to mark rental as completed: " +
|
|
||||||
(err.response?.data?.error || err.message)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReviewRenterSuccess = () => {
|
const handleReviewRenterSuccess = () => {
|
||||||
fetchRentalHistory(); // Refresh to show updated review status
|
fetchRentalHistory(); // Refresh to show updated review status
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -215,7 +215,6 @@ export const rentalAPI = {
|
|||||||
getPendingRequestsCount: () => api.get("/rentals/pending-requests-count"),
|
getPendingRequestsCount: () => api.get("/rentals/pending-requests-count"),
|
||||||
updateRentalStatus: (id: string, status: string) =>
|
updateRentalStatus: (id: string, status: string) =>
|
||||||
api.put(`/rentals/${id}/status`, { status }),
|
api.put(`/rentals/${id}/status`, { status }),
|
||||||
markAsCompleted: (id: string) => api.post(`/rentals/${id}/mark-completed`),
|
|
||||||
reviewRenter: (id: string, data: any) =>
|
reviewRenter: (id: string, data: any) =>
|
||||||
api.post(`/rentals/${id}/review-renter`, data),
|
api.post(`/rentals/${id}/review-renter`, data),
|
||||||
reviewItem: (id: string, data: any) =>
|
reviewItem: (id: string, data: any) =>
|
||||||
|
|||||||
@@ -146,6 +146,11 @@ export interface Rental {
|
|||||||
payoutStatus?: "pending" | "completed" | "failed" | null;
|
payoutStatus?: "pending" | "completed" | "failed" | null;
|
||||||
payoutProcessedAt?: string;
|
payoutProcessedAt?: string;
|
||||||
stripeTransferId?: string;
|
stripeTransferId?: string;
|
||||||
|
// Bank deposit tracking (Stripe payout to owner's bank)
|
||||||
|
bankDepositStatus?: "pending" | "in_transit" | "paid" | "failed" | "canceled" | null;
|
||||||
|
bankDepositAt?: string;
|
||||||
|
stripePayoutId?: string;
|
||||||
|
bankDepositFailureCode?: string;
|
||||||
intendedUse?: string;
|
intendedUse?: string;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
review?: string;
|
review?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user