stripe webhooks. removed payout cron. webhook for when amount is deposited into bank. More communication about payout timelines
This commit is contained in:
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
298
backend/services/stripeWebhookService.js
Normal file
298
backend/services/stripeWebhookService.js
Normal file
@@ -0,0 +1,298 @@
|
||||
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
||||
const { User, Rental, Item } = require("../models");
|
||||
const PayoutService = require("./payoutService");
|
||||
const logger = require("../utils/logger");
|
||||
const { Op } = require("sequelize");
|
||||
|
||||
class StripeWebhookService {
|
||||
/**
|
||||
* Verify webhook signature and construct event
|
||||
*/
|
||||
static constructEvent(rawBody, signature, webhookSecret) {
|
||||
return stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle account.updated webhook event.
|
||||
* Triggers payouts for owner when payouts_enabled becomes true.
|
||||
* @param {Object} account - The Stripe account object from the webhook
|
||||
* @returns {Object} - { processed, payoutsTriggered, payoutResults }
|
||||
*/
|
||||
static async handleAccountUpdated(account) {
|
||||
const accountId = account.id;
|
||||
const payoutsEnabled = account.payouts_enabled;
|
||||
|
||||
logger.info("Processing account.updated webhook", {
|
||||
accountId,
|
||||
payoutsEnabled,
|
||||
chargesEnabled: account.charges_enabled,
|
||||
detailsSubmitted: account.details_submitted,
|
||||
});
|
||||
|
||||
// Find user with this Stripe account
|
||||
const user = await User.findOne({
|
||||
where: { stripeConnectedAccountId: accountId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.warn("No user found for Stripe account", { accountId });
|
||||
return { processed: false, reason: "user_not_found" };
|
||||
}
|
||||
|
||||
const previousPayoutsEnabled = user.stripePayoutsEnabled;
|
||||
|
||||
// Update user's payouts_enabled status
|
||||
await user.update({ stripePayoutsEnabled: payoutsEnabled });
|
||||
|
||||
logger.info("Updated user stripePayoutsEnabled", {
|
||||
userId: user.id,
|
||||
accountId,
|
||||
previousPayoutsEnabled,
|
||||
newPayoutsEnabled: payoutsEnabled,
|
||||
});
|
||||
|
||||
// If payouts just became enabled (false -> true), process pending payouts
|
||||
if (payoutsEnabled && !previousPayoutsEnabled) {
|
||||
logger.info("Payouts enabled for user, processing pending payouts", {
|
||||
userId: user.id,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const result = await this.processPayoutsForOwner(user.id);
|
||||
return {
|
||||
processed: true,
|
||||
payoutsTriggered: true,
|
||||
payoutResults: result,
|
||||
};
|
||||
}
|
||||
|
||||
return { processed: true, payoutsTriggered: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all eligible payouts for a specific owner.
|
||||
* Called when owner completes Stripe onboarding.
|
||||
* @param {string} ownerId - The owner's user ID
|
||||
* @returns {Object} - { successful, failed, totalProcessed }
|
||||
*/
|
||||
static async processPayoutsForOwner(ownerId) {
|
||||
const eligibleRentals = await Rental.findAll({
|
||||
where: {
|
||||
ownerId,
|
||||
status: "completed",
|
||||
paymentStatus: "paid",
|
||||
payoutStatus: "pending",
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
where: {
|
||||
stripeConnectedAccountId: { [Op.not]: null },
|
||||
stripePayoutsEnabled: true,
|
||||
},
|
||||
},
|
||||
{ model: Item, as: "item" },
|
||||
],
|
||||
});
|
||||
|
||||
logger.info("Found eligible rentals for owner payout", {
|
||||
ownerId,
|
||||
count: eligibleRentals.length,
|
||||
});
|
||||
|
||||
const results = {
|
||||
successful: [],
|
||||
failed: [],
|
||||
totalProcessed: eligibleRentals.length,
|
||||
};
|
||||
|
||||
for (const rental of eligibleRentals) {
|
||||
try {
|
||||
const result = await PayoutService.processRentalPayout(rental);
|
||||
results.successful.push({
|
||||
rentalId: rental.id,
|
||||
amount: result.amount,
|
||||
transferId: result.transferId,
|
||||
});
|
||||
} catch (error) {
|
||||
results.failed.push({
|
||||
rentalId: rental.id,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Processed payouts for owner", {
|
||||
ownerId,
|
||||
successful: results.successful.length,
|
||||
failed: results.failed.length,
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle payout.paid webhook event.
|
||||
* Updates rentals when funds are deposited to owner's bank account.
|
||||
* @param {Object} payout - The Stripe payout object
|
||||
* @param {string} connectedAccountId - The connected account ID (from event.account)
|
||||
* @returns {Object} - { processed, rentalsUpdated }
|
||||
*/
|
||||
static async handlePayoutPaid(payout, connectedAccountId) {
|
||||
logger.info("Processing payout.paid webhook", {
|
||||
payoutId: payout.id,
|
||||
connectedAccountId,
|
||||
amount: payout.amount,
|
||||
arrivalDate: payout.arrival_date,
|
||||
});
|
||||
|
||||
if (!connectedAccountId) {
|
||||
logger.warn("payout.paid webhook missing connected account ID", {
|
||||
payoutId: payout.id,
|
||||
});
|
||||
return { processed: false, reason: "missing_account_id" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch balance transactions included in this payout
|
||||
// Filter by type 'transfer' to get only our platform transfers
|
||||
const balanceTransactions = await stripe.balanceTransactions.list(
|
||||
{
|
||||
payout: payout.id,
|
||||
type: "transfer",
|
||||
limit: 100,
|
||||
},
|
||||
{ stripeAccount: connectedAccountId }
|
||||
);
|
||||
|
||||
// Extract transfer IDs from balance transactions
|
||||
// The 'source' field contains the transfer ID
|
||||
const transferIds = balanceTransactions.data
|
||||
.map((bt) => bt.source)
|
||||
.filter(Boolean);
|
||||
|
||||
if (transferIds.length === 0) {
|
||||
logger.info("No transfer balance transactions in payout", {
|
||||
payoutId: payout.id,
|
||||
connectedAccountId,
|
||||
});
|
||||
return { processed: true, rentalsUpdated: 0 };
|
||||
}
|
||||
|
||||
logger.info("Found transfers in payout", {
|
||||
payoutId: payout.id,
|
||||
transferCount: transferIds.length,
|
||||
transferIds,
|
||||
});
|
||||
|
||||
// Update all rentals with matching stripeTransferId
|
||||
const [updatedCount] = await Rental.update(
|
||||
{
|
||||
bankDepositStatus: "paid",
|
||||
bankDepositAt: new Date(payout.arrival_date * 1000),
|
||||
stripePayoutId: payout.id,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
stripeTransferId: { [Op.in]: transferIds },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
logger.info("Updated rentals with bank deposit status", {
|
||||
payoutId: payout.id,
|
||||
rentalsUpdated: updatedCount,
|
||||
});
|
||||
|
||||
return { processed: true, rentalsUpdated: updatedCount };
|
||||
} catch (error) {
|
||||
logger.error("Error processing payout.paid webhook", {
|
||||
payoutId: payout.id,
|
||||
connectedAccountId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle payout.failed webhook event.
|
||||
* Updates rentals when bank deposit fails.
|
||||
* @param {Object} payout - The Stripe payout object
|
||||
* @param {string} connectedAccountId - The connected account ID (from event.account)
|
||||
* @returns {Object} - { processed, rentalsUpdated }
|
||||
*/
|
||||
static async handlePayoutFailed(payout, connectedAccountId) {
|
||||
logger.info("Processing payout.failed webhook", {
|
||||
payoutId: payout.id,
|
||||
connectedAccountId,
|
||||
failureCode: payout.failure_code,
|
||||
failureMessage: payout.failure_message,
|
||||
});
|
||||
|
||||
if (!connectedAccountId) {
|
||||
logger.warn("payout.failed webhook missing connected account ID", {
|
||||
payoutId: payout.id,
|
||||
});
|
||||
return { processed: false, reason: "missing_account_id" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch balance transactions included in this payout
|
||||
const balanceTransactions = await stripe.balanceTransactions.list(
|
||||
{
|
||||
payout: payout.id,
|
||||
type: "transfer",
|
||||
limit: 100,
|
||||
},
|
||||
{ stripeAccount: connectedAccountId }
|
||||
);
|
||||
|
||||
const transferIds = balanceTransactions.data
|
||||
.map((bt) => bt.source)
|
||||
.filter(Boolean);
|
||||
|
||||
if (transferIds.length === 0) {
|
||||
logger.info("No transfer balance transactions in failed payout", {
|
||||
payoutId: payout.id,
|
||||
connectedAccountId,
|
||||
});
|
||||
return { processed: true, rentalsUpdated: 0 };
|
||||
}
|
||||
|
||||
// Update all rentals with matching stripeTransferId
|
||||
const [updatedCount] = await Rental.update(
|
||||
{
|
||||
bankDepositStatus: "failed",
|
||||
stripePayoutId: payout.id,
|
||||
bankDepositFailureCode: payout.failure_code || "unknown",
|
||||
},
|
||||
{
|
||||
where: {
|
||||
stripeTransferId: { [Op.in]: transferIds },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
logger.warn("Updated rentals with failed bank deposit status", {
|
||||
payoutId: payout.id,
|
||||
rentalsUpdated: updatedCount,
|
||||
failureCode: payout.failure_code,
|
||||
});
|
||||
|
||||
return { processed: true, rentalsUpdated: updatedCount };
|
||||
} catch (error) {
|
||||
logger.error("Error processing payout.failed webhook", {
|
||||
payoutId: payout.id,
|
||||
connectedAccountId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = StripeWebhookService;
|
||||
Reference in New Issue
Block a user