From 76102d48a920d0334814cc77f7dae505bd3fa26b Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:58:23 -0500 Subject: [PATCH] stripe webhooks. removed payout cron. webhook for when amount is deposited into bank. More communication about payout timelines --- backend/jobs/payoutProcessor.js | 39 +-- ...260102000001-add-stripe-payouts-enabled.js | 15 + ...0260103000001-add-bank-deposit-tracking.js | 42 +++ backend/models/Rental.js | 14 + backend/models/User.js | 5 + backend/routes/rentals.js | 54 +--- backend/routes/stripeWebhooks.js | 93 ++++++ backend/server.js | 4 + .../email/domain/RentalFlowEmailService.js | 13 +- backend/services/lateReturnService.js | 13 + backend/services/payoutService.js | 83 +++++ backend/services/stripeWebhookService.js | 298 ++++++++++++++++++ .../emails/payoutReceivedToOwner.html | 18 +- frontend/src/components/EarningsStatus.tsx | 4 +- .../components/StripeConnectOnboarding.tsx | 12 +- frontend/src/pages/EarningsDashboard.tsx | 116 +++++-- frontend/src/pages/FAQ.tsx | 62 +++- frontend/src/pages/Profile.tsx | 14 - frontend/src/services/api.ts | 1 - frontend/src/types/index.ts | 5 + 20 files changed, 770 insertions(+), 135 deletions(-) create mode 100644 backend/migrations/20260102000001-add-stripe-payouts-enabled.js create mode 100644 backend/migrations/20260103000001-add-bank-deposit-tracking.js create mode 100644 backend/routes/stripeWebhooks.js create mode 100644 backend/services/stripeWebhookService.js 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 CompletedDone
    ✓ Transfer InitiatedToday
    ○ Funds in Your Bank2-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;