diff --git a/backend/jobs/payoutProcessor.js b/backend/jobs/payoutProcessor.js
index f55f42c..6930f1a 100644
--- a/backend/jobs/payoutProcessor.js
+++ b/backend/jobs/payoutProcessor.js
@@ -1,40 +1,12 @@
const cron = require("node-cron");
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
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",
- }
- );
+ console.log("Starting payout retry processor...");
const retryJob = cron.schedule(
retrySchedule,
@@ -59,27 +31,22 @@ class PayoutProcessor {
}
);
- // Start the jobs
- payoutJob.start();
+ // Start the job
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",
};
},
diff --git a/backend/migrations/20260102000001-add-stripe-payouts-enabled.js b/backend/migrations/20260102000001-add-stripe-payouts-enabled.js
new file mode 100644
index 0000000..8baf302
--- /dev/null
+++ b/backend/migrations/20260102000001-add-stripe-payouts-enabled.js
@@ -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");
+ },
+};
diff --git a/backend/migrations/20260103000001-add-bank-deposit-tracking.js b/backend/migrations/20260103000001-add-bank-deposit-tracking.js
new file mode 100644
index 0000000..32b2594
--- /dev/null
+++ b/backend/migrations/20260103000001-add-bank-deposit-tracking.js
@@ -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";'
+ );
+ },
+};
diff --git a/backend/models/Rental.js b/backend/models/Rental.js
index 26f9aa3..6160803 100644
--- a/backend/models/Rental.js
+++ b/backend/models/Rental.js
@@ -80,6 +80,20 @@ const Rental = sequelize.define("Rental", {
stripeTransferId: {
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
refundAmount: {
type: DataTypes.DECIMAL(10, 2),
diff --git a/backend/models/User.js b/backend/models/User.js
index 348ca39..f8e11af 100644
--- a/backend/models/User.js
+++ b/backend/models/User.js
@@ -115,6 +115,11 @@ const User = sequelize.define(
type: DataTypes.STRING,
allowNull: true,
},
+ stripePayoutsEnabled: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: false,
+ allowNull: true,
+ },
stripeCustomerId: {
type: DataTypes.STRING,
allowNull: true,
diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js
index 46bb408..2123024 100644
--- a/backend/routes/rentals.js
+++ b/backend/routes/rentals.js
@@ -9,6 +9,7 @@ const FeeCalculator = require("../utils/feeCalculator");
const RentalDurationCalculator = require("../utils/rentalDurationCalculator");
const RefundService = require("../services/refundService");
const LateReturnService = require("../services/lateReturnService");
+const PayoutService = require("../services/payoutService");
const DamageAssessmentService = require("../services/damageAssessmentService");
const emailServices = require("../services/email");
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
router.post("/calculate-fees", authenticateToken, async (req, res) => {
try {
@@ -1270,6 +1226,14 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
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;
case "damaged":
diff --git a/backend/routes/stripeWebhooks.js b/backend/routes/stripeWebhooks.js
new file mode 100644
index 0000000..0e74446
--- /dev/null
+++ b/backend/routes/stripeWebhooks.js
@@ -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;
diff --git a/backend/server.js b/backend/server.js
index 872d9c5..a8838c6 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -25,6 +25,7 @@ const rentalRoutes = require("./routes/rentals");
const messageRoutes = require("./routes/messages");
const forumRoutes = require("./routes/forum");
const stripeRoutes = require("./routes/stripe");
+const stripeWebhookRoutes = require("./routes/stripeWebhooks");
const mapsRoutes = require("./routes/maps");
const conditionCheckRoutes = require("./routes/conditionChecks");
const feedbackRoutes = require("./routes/feedback");
@@ -145,6 +146,9 @@ app.use(
// Health check endpoints (no auth, no rate limiting)
app.use("/health", healthRoutes);
+// Stripe webhooks (no auth, uses signature verification instead)
+app.use("/api/stripe/webhooks", stripeWebhookRoutes);
+
// Root endpoint
app.get("/", (req, res) => {
res.json({ message: "Village Share API is running!" });
diff --git a/backend/services/email/domain/RentalFlowEmailService.js b/backend/services/email/domain/RentalFlowEmailService.js
index 3e59d35..4a93b4f 100644
--- a/backend/services/email/domain/RentalFlowEmailService.js
+++ b/backend/services/email/domain/RentalFlowEmailService.js
@@ -259,7 +259,7 @@ class RentalFlowEmailService {
Automatic payouts when rentals complete
Secure transfers directly to your bank account
Track all earnings in one dashboard
- Fast deposits (typically 2-3 business days)
+ Fast deposits (typically 2-7 business days)
Setup only takes about 5 minutes and you only need to do it once.
@@ -1033,7 +1033,7 @@ class RentalFlowEmailService {
- 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.
`;
}
@@ -1056,7 +1056,7 @@ class RentalFlowEmailService {
Automatic payouts when the rental period ends
Secure transfers directly to your bank account
Track all earnings in one dashboard
- Fast deposits (typically 2-3 business days)
+ Fast deposits (typically 2-7 business days)
Setup only takes about 5 minutes and you only need to do it once.
@@ -1070,10 +1070,11 @@ class RentalFlowEmailService {
} else if (hasStripeAccount && isPaidRental) {
stripeSection = `
-
✓ Earnings Account Active
-
Your earnings account is set up. You'll automatically receive \\$${payoutAmount.toFixed(
+
✓ Payout Initiated
+
Your earnings of \\$${payoutAmount.toFixed(
2
- )} when the rental period ends.
+ )} have been transferred to your Stripe account.
+
Funds typically reach your bank within 2-7 business days.
View your earnings dashboard →
`;
diff --git a/backend/services/lateReturnService.js b/backend/services/lateReturnService.js
index 1429c88..5cad935 100644
--- a/backend/services/lateReturnService.js
+++ b/backend/services/lateReturnService.js
@@ -1,6 +1,7 @@
const { Rental, Item, User } = require("../models");
const emailServices = require("./email");
const { isActive } = require("../utils/rentalStatus");
+const logger = require("../utils/logger");
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 {
rental: updatedRental,
lateCalculation,
diff --git a/backend/services/payoutService.js b/backend/services/payoutService.js
index 7ee7c77..6e899bc 100644
--- a/backend/services/payoutService.js
+++ b/backend/services/payoutService.js
@@ -5,6 +5,87 @@ const logger = require("../utils/logger");
const { Op } = require("sequelize");
class PayoutService {
+ /**
+ * Attempt to process payout for a single rental immediately after completion.
+ * Checks if owner's Stripe account has payouts enabled before attempting.
+ * @param {string} rentalId - The rental ID to process
+ * @returns {Object} - { attempted, success, reason, transferId, amount }
+ */
+ static async triggerPayoutOnCompletion(rentalId) {
+ try {
+ const rental = await Rental.findByPk(rentalId, {
+ include: [
+ {
+ model: User,
+ as: "owner",
+ attributes: ["id", "email", "firstName", "lastName", "stripeConnectedAccountId", "stripePayoutsEnabled"],
+ },
+ { model: Item, as: "item" },
+ ],
+ });
+
+ if (!rental) {
+ logger.warn("Rental not found for payout trigger", { rentalId });
+ return { attempted: false, success: false, reason: "rental_not_found" };
+ }
+
+ // Check eligibility conditions
+ if (rental.paymentStatus !== "paid") {
+ logger.info("Payout skipped: payment not paid", { rentalId, paymentStatus: rental.paymentStatus });
+ return { attempted: false, success: false, reason: "payment_not_paid" };
+ }
+
+ if (rental.payoutStatus !== "pending") {
+ logger.info("Payout skipped: payout not pending", { rentalId, payoutStatus: rental.payoutStatus });
+ return { attempted: false, success: false, reason: "payout_not_pending" };
+ }
+
+ if (!rental.owner?.stripeConnectedAccountId) {
+ logger.info("Payout skipped: owner has no Stripe account", { rentalId, ownerId: rental.ownerId });
+ return { attempted: false, success: false, reason: "no_stripe_account" };
+ }
+
+ // Check if owner has payouts enabled (onboarding complete)
+ if (!rental.owner.stripePayoutsEnabled) {
+ logger.info("Payout deferred: owner payouts not enabled, will process when onboarding completes", {
+ rentalId,
+ ownerId: rental.ownerId,
+ });
+ return { attempted: false, success: false, reason: "payouts_not_enabled" };
+ }
+
+ // Attempt the payout
+ const result = await this.processRentalPayout(rental);
+
+ logger.info("Payout triggered successfully on completion", {
+ rentalId,
+ transferId: result.transferId,
+ amount: result.amount,
+ });
+
+ return {
+ attempted: true,
+ success: true,
+ transferId: result.transferId,
+ amount: result.amount,
+ };
+ } catch (error) {
+ logger.error("Error triggering payout on completion", {
+ error: error.message,
+ stack: error.stack,
+ rentalId,
+ });
+
+ // Payout marked as failed by processRentalPayout, will be retried by daily retry job
+ return {
+ attempted: true,
+ success: false,
+ reason: "payout_failed",
+ error: error.message,
+ };
+ }
+ }
+
static async getEligiblePayouts() {
try {
const eligibleRentals = await Rental.findAll({
@@ -21,6 +102,7 @@ class PayoutService {
stripeConnectedAccountId: {
[Op.not]: null,
},
+ stripePayoutsEnabled: true,
},
},
{
@@ -167,6 +249,7 @@ class PayoutService {
stripeConnectedAccountId: {
[Op.not]: null,
},
+ stripePayoutsEnabled: true,
},
},
{
diff --git a/backend/services/stripeWebhookService.js b/backend/services/stripeWebhookService.js
new file mode 100644
index 0000000..5411644
--- /dev/null
+++ b/backend/services/stripeWebhookService.js
@@ -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;
diff --git a/backend/templates/emails/payoutReceivedToOwner.html b/backend/templates/emails/payoutReceivedToOwner.html
index 365e768..4d83dac 100644
--- a/backend/templates/emails/payoutReceivedToOwner.html
+++ b/backend/templates/emails/payoutReceivedToOwner.html
@@ -381,11 +381,27 @@
+ Payout Timeline
+
+
+ ✓ Rental Completed
+ Done
+
+
+ ✓ Transfer Initiated
+ Today
+
+
+ ○ Funds in Your Bank
+ 2-7 business days
+
+
+
When will I receive the funds?
Funds are typically available in your bank account within
- 2-3 business days from the transfer date.
+ 2-7 business days from the transfer date, depending on your bank and Stripe's payout schedule.
You can track this transfer in your Stripe Dashboard using the
diff --git a/frontend/src/components/EarningsStatus.tsx b/frontend/src/components/EarningsStatus.tsx
index 101dea6..17eee6b 100644
--- a/frontend/src/components/EarningsStatus.tsx
+++ b/frontend/src/components/EarningsStatus.tsx
@@ -66,8 +66,8 @@ const EarningsStatus: React.FC = ({
Earnings Active
- Your earnings are set up and working. You'll receive payments
- automatically.
+ Payouts are sent immediately when rentals complete. Funds reach your
+ bank in 2-7 business days.
diff --git a/frontend/src/components/StripeConnectOnboarding.tsx b/frontend/src/components/StripeConnectOnboarding.tsx
index d17d3aa..a445d39 100644
--- a/frontend/src/components/StripeConnectOnboarding.tsx
+++ b/frontend/src/components/StripeConnectOnboarding.tsx
@@ -47,7 +47,7 @@ const StripeConnectOnboarding: React.FC = ({
colorText: "#212529",
colorDanger: "#dc3545",
fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
- fontSizeBase: "16px",
+ fontSizeBase: "20px",
borderRadius: "8px",
spacingUnit: "4px",
},
@@ -170,9 +170,9 @@ const StripeConnectOnboarding: React.FC = ({
style={{ fontSize: "2rem" }}
>
- Automatic
+ Instant Payouts
- Earnings are processed automatically
+ Transferred when rentals complete
@@ -182,9 +182,9 @@ const StripeConnectOnboarding: React.FC = ({
style={{ fontSize: "2rem" }}
>
- Direct Deposit
+ Fast Deposits
- Funds go directly to your bank
+ In your bank in 2-7 business days
@@ -197,7 +197,7 @@ const StripeConnectOnboarding: React.FC = ({
Verify your identity securely
Provide bank account details for deposits
The setup process takes about 5 minutes
- Start earning immediately after setup
+ Receive payouts instantly when rentals complete
diff --git a/frontend/src/pages/EarningsDashboard.tsx b/frontend/src/pages/EarningsDashboard.tsx
index 722767e..5c42a3f 100644
--- a/frontend/src/pages/EarningsDashboard.tsx
+++ b/frontend/src/pages/EarningsDashboard.tsx
@@ -22,7 +22,9 @@ const EarningsDashboard: React.FC = () => {
const [error, setError] = useState(null);
const [earningsData, setEarningsData] = useState(null);
const [userProfile, setUserProfile] = useState(null);
- const [accountStatus, setAccountStatus] = useState(null);
+ const [accountStatus, setAccountStatus] = useState(
+ null
+ );
const [showOnboarding, setShowOnboarding] = useState(false);
useEffect(() => {
@@ -75,7 +77,7 @@ const EarningsDashboard: React.FC = () => {
);
const pendingEarnings = completedRentals
- .filter((rental: Rental) => rental.payoutStatus === "pending")
+ .filter((rental: Rental) => rental.bankDepositStatus !== "paid")
.reduce(
(sum: number, rental: Rental) =>
sum + parseFloat(rental.payoutAmount?.toString() || "0"),
@@ -83,7 +85,7 @@ const EarningsDashboard: React.FC = () => {
);
const completedEarnings = completedRentals
- .filter((rental: Rental) => rental.payoutStatus === "completed")
+ .filter((rental: Rental) => rental.bankDepositStatus === "paid")
.reduce(
(sum: number, rental: Rental) =>
sum + parseFloat(rental.payoutAmount?.toString() || "0"),
@@ -231,21 +233,57 @@ const EarningsDashboard: React.FC = () => {
-
- {rental.payoutStatus === "completed"
- ? "Paid"
- : rental.payoutStatus === "failed"
- ? "Failed"
- : "Pending"}
-
+ {(() => {
+ // 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 (
+
+ {badgeLabel}
+
+ );
+ })()}
))}
@@ -274,7 +312,7 @@ const EarningsDashboard: React.FC = () => {
{/* Quick Stats */}
-
+
Quick Stats
@@ -301,6 +339,46 @@ const EarningsDashboard: React.FC = () => {
+
+ {/* How Payouts Work */}
+
+
+
How Payouts Work
+
+
+
+
1
+
+
Transfer Initiated
+
+ Immediate when rental is marked complete
+
+
+
+
+
2
+
+
Funds in Stripe
+
+ Instant — view in your Stripe dashboard
+
+
+
+
+
3
+
+
Funds in Bank
+
+ 2-7 business days (Stripe's schedule)
+
+
+
+
+
+ Learn more in our FAQ
+
+
+
diff --git a/frontend/src/pages/FAQ.tsx b/frontend/src/pages/FAQ.tsx
index 23b6b84..e9133e9 100644
--- a/frontend/src/pages/FAQ.tsx
+++ b/frontend/src/pages/FAQ.tsx
@@ -70,17 +70,69 @@ const FAQ: React.FC = () => {
- Payout Timeline: Earnings are processed within 2 business
- days after the rental is completed. Make sure your Stripe account is set
- up to receive payouts.
+ Payout Timeline: 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.
),
},
{
question: "When will I receive my earnings?",
- 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.",
+ answer: (
+
+
The payout process has three stages:
+
+
+
1
+
+
Transfer Initiated
+
(Immediate)
+
+ When the rental is marked complete, we immediately transfer your earnings to your Stripe account.
+
+
+
+
+
2
+
+
Funds in Stripe
+
(Instant)
+
+ The transfer appears in your Stripe dashboard right away.
+
+
+
+
+
3
+
+
Funds in Your Bank
+
(2-7 business days)
+
+ Stripe automatically deposits funds to your bank account. Timing depends on your bank and Stripe's payout schedule.
+
+
+
+
+
+ Note: If you haven't completed Stripe onboarding, payouts are held until you finish setup. Once complete, all pending payouts are processed immediately.
+
+
+ ),
+ },
+ {
+ question: "Why is my payout still pending?",
+ answer: (
+
+
A payout may show as "pending" for a few reasons:
+
+ Rental not yet complete: The owner needs to mark the rental as complete before the payout is initiated.
+ Stripe onboarding incomplete: You need to finish setting up your Stripe account. Visit your Earnings Dashboard to complete setup.
+ Processing: Payouts are initiated immediately but may take a moment to process.
+
+
+ If a payout fails for any reason, we automatically retry daily until it succeeds.
+
+
+ ),
},
{
question: "How do I set up my account to receive payments?",
diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx
index 31f5a27..c30819a 100644
--- a/frontend/src/pages/Profile.tsx
+++ b/frontend/src/pages/Profile.tsx
@@ -312,20 +312,6 @@ const Profile: React.FC = () => {
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 = () => {
fetchRentalHistory(); // Refresh to show updated review status
};
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index b82ea31..1f919e5 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -215,7 +215,6 @@ export const rentalAPI = {
getPendingRequestsCount: () => api.get("/rentals/pending-requests-count"),
updateRentalStatus: (id: string, status: string) =>
api.put(`/rentals/${id}/status`, { status }),
- markAsCompleted: (id: string) => api.post(`/rentals/${id}/mark-completed`),
reviewRenter: (id: string, data: any) =>
api.post(`/rentals/${id}/review-renter`, data),
reviewItem: (id: string, data: any) =>
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 859102b..e5b8d3a 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -146,6 +146,11 @@ export interface Rental {
payoutStatus?: "pending" | "completed" | "failed" | null;
payoutProcessedAt?: 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;
rating?: number;
review?: string;