stripe webhooks. removed payout cron. webhook for when amount is deposited into bank. More communication about payout timelines

This commit is contained in:
jackiettran
2026-01-03 19:58:23 -05:00
parent 493921b723
commit 76102d48a9
20 changed files with 770 additions and 135 deletions

View File

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

View File

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

View File

@@ -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";'
);
},
};

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -259,7 +259,7 @@ class RentalFlowEmailService {
<li><strong>Automatic payouts</strong> when rentals complete</li>
<li><strong>Secure transfers</strong> directly to your bank account</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>
<p>Setup only takes about 5 minutes and you only need to do it once.</p>
</div>
@@ -1033,7 +1033,7 @@ class RentalFlowEmailService {
</tr>
</table>
<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>
`;
}
@@ -1056,7 +1056,7 @@ class RentalFlowEmailService {
<li><strong>Automatic payouts</strong> when the rental period ends</li>
<li><strong>Secure transfers</strong> directly to your bank account</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>
<p>Setup only takes about 5 minutes and you only need to do it once.</p>
</div>
@@ -1070,10 +1070,11 @@ class RentalFlowEmailService {
} else if (hasStripeAccount && isPaidRental) {
stripeSection = `
<div class="success-box">
<p><strong>✓ Earnings Account Active</strong></p>
<p>Your earnings account is set up. You'll automatically receive \\$${payoutAmount.toFixed(
<p><strong>✓ Payout Initiated</strong></p>
<p>Your earnings of <strong>\\$${payoutAmount.toFixed(
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>
</div>
`;

View File

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

View File

@@ -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,
},
},
{

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

View File

@@ -381,11 +381,27 @@
</tr>
</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">
<p><strong>When will I receive the funds?</strong></p>
<p>
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>
You can track this transfer in your Stripe Dashboard using the

View File

@@ -66,8 +66,8 @@ const EarningsStatus: React.FC<EarningsStatusProps> = ({
</div>
<h6 className="text-success">Earnings Active</h6>
<p className="text-muted small mb-3">
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.
</p>
<div className="small text-start">

View File

@@ -47,7 +47,7 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
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<StripeConnectOnboardingProps> = ({
style={{ fontSize: "2rem" }}
></i>
</div>
<h6>Automatic</h6>
<h6>Instant Payouts</h6>
<small className="text-muted">
Earnings are processed automatically
Transferred when rentals complete
</small>
</div>
<div className="col-md-4">
@@ -182,9 +182,9 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
style={{ fontSize: "2rem" }}
></i>
</div>
<h6>Direct Deposit</h6>
<h6>Fast Deposits</h6>
<small className="text-muted">
Funds go directly to your bank
In your bank in 2-7 business days
</small>
</div>
</div>
@@ -197,7 +197,7 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
<li>Verify your identity securely</li>
<li>Provide bank account details for deposits</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>
</div>

View File

@@ -22,7 +22,9 @@ const EarningsDashboard: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [earningsData, setEarningsData] = useState<EarningsData | 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);
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 = () => {
</strong>
</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
className={`badge ${
rental.payoutStatus === "completed"
? "bg-success"
: rental.payoutStatus === "failed"
? "bg-danger"
: "bg-secondary"
}`}
className={`badge ${badgeClass}`}
title={badgeTooltip}
style={{ cursor: "help" }}
>
{rental.payoutStatus === "completed"
? "Paid"
: rental.payoutStatus === "failed"
? "Failed"
: "Pending"}
{badgeLabel}
</span>
);
})()}
</td>
</tr>
))}
@@ -274,7 +312,7 @@ const EarningsDashboard: React.FC = () => {
</div>
{/* Quick Stats */}
<div className="card">
<div className="card mb-4">
<div className="card-header">
<h5 className="mb-0">Quick Stats</h5>
</div>
@@ -301,6 +339,46 @@ const EarningsDashboard: React.FC = () => {
</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>

View File

@@ -70,17 +70,69 @@ const FAQ: React.FC = () => {
</div>
</div>
<p className="small text-muted">
<strong>Payout Timeline:</strong> Earnings are processed within 2 business
days after the rental is completed. Make sure your Stripe account is set
up to receive payouts.
<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.
</p>
</div>
),
},
{
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: (
<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?",

View File

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

View File

@@ -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) =>

View File

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