Files
rentall-app/backend/services/stripeWebhookService.js
2026-01-08 18:12:58 -05:00

745 lines
23 KiB
JavaScript

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");
const { getPayoutFailureMessage } = require("../utils/payoutErrors");
const emailServices = require("./email");
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 and notifies the owner.
* @param {Object} payout - The Stripe payout object
* @param {string} connectedAccountId - The connected account ID (from event.account)
* @returns {Object} - { processed, rentalsUpdated, notificationSent }
*/
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, notificationSent: false };
}
// 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,
});
// Find owner and send notification
const user = await User.findOne({
where: { stripeConnectedAccountId: connectedAccountId },
});
let notificationSent = false;
if (user) {
// Get user-friendly message
const failureInfo = getPayoutFailureMessage(payout.failure_code);
try {
await emailServices.payment.sendPayoutFailedNotification(user.email, {
ownerName: user.firstName || user.name,
payoutAmount: payout.amount / 100,
failureMessage: failureInfo.message,
actionRequired: failureInfo.action,
failureCode: payout.failure_code || "unknown",
requiresBankUpdate: failureInfo.requiresBankUpdate,
});
notificationSent = true;
logger.info("Sent payout failed notification to owner", {
userId: user.id,
payoutId: payout.id,
failureCode: payout.failure_code,
});
} catch (emailError) {
logger.error("Failed to send payout failed notification", {
userId: user.id,
payoutId: payout.id,
error: emailError.message,
});
}
} else {
logger.warn("No user found for connected account", {
connectedAccountId,
payoutId: payout.id,
});
}
return { processed: true, rentalsUpdated: updatedCount, notificationSent };
} catch (error) {
logger.error("Error processing payout.failed webhook", {
payoutId: payout.id,
connectedAccountId,
error: error.message,
stack: error.stack,
});
throw error;
}
}
/**
* Handle payout.canceled webhook event.
* Stripe can cancel payouts if:
* - They are manually canceled via Dashboard/API before processing
* - The connected account is deactivated
* - Risk review cancels the payout
* @param {Object} payout - The Stripe payout object
* @param {string} connectedAccountId - The connected account ID
* @returns {Object} - { processed, rentalsUpdated }
*/
static async handlePayoutCanceled(payout, connectedAccountId) {
logger.info("Processing payout.canceled webhook", {
payoutId: payout.id,
connectedAccountId,
});
if (!connectedAccountId) {
logger.warn("payout.canceled webhook missing connected account ID", {
payoutId: payout.id,
});
return { processed: false, reason: "missing_account_id" };
}
try {
// Retrieve balance transactions to find associated transfers
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 transfers found for canceled payout", {
payoutId: payout.id,
});
return { processed: true, rentalsUpdated: 0 };
}
// Update all rentals associated with this payout
const [updatedCount] = await Rental.update(
{
bankDepositStatus: "canceled",
stripePayoutId: payout.id,
},
{
where: {
stripeTransferId: { [Op.in]: transferIds },
},
}
);
logger.info("Updated rentals for canceled payout", {
payoutId: payout.id,
rentalsUpdated: updatedCount,
});
return { processed: true, rentalsUpdated: updatedCount };
} catch (error) {
logger.error("Error processing payout.canceled webhook", {
payoutId: payout.id,
connectedAccountId,
error: error.message,
stack: error.stack,
});
throw error;
}
}
/**
* Handle account.application.deauthorized webhook event.
* Triggered when an owner disconnects their Stripe account from our platform.
* @param {string} accountId - The connected account ID that was deauthorized
* @returns {Object} - { processed, userId, pendingPayoutsCount, notificationSent }
*/
static async handleAccountDeauthorized(accountId) {
logger.warn("Processing account.application.deauthorized webhook", {
accountId,
});
if (!accountId) {
logger.warn("account.application.deauthorized webhook missing account ID");
return { processed: false, reason: "missing_account_id" };
}
try {
// Find the user by their connected account ID
const user = await User.findOne({
where: { stripeConnectedAccountId: accountId },
});
if (!user) {
logger.warn("No user found for deauthorized Stripe account", { accountId });
return { processed: false, reason: "user_not_found" };
}
// Clear Stripe connection fields
await user.update({
stripeConnectedAccountId: null,
stripePayoutsEnabled: false,
});
logger.info("Cleared Stripe connection for deauthorized account", {
userId: user.id,
accountId,
});
// Check for pending payouts that will now fail
const pendingRentals = await Rental.findAll({
where: {
ownerId: user.id,
payoutStatus: "pending",
},
});
if (pendingRentals.length > 0) {
logger.warn("Owner disconnected account with pending payouts", {
userId: user.id,
pendingCount: pendingRentals.length,
pendingRentalIds: pendingRentals.map((r) => r.id),
});
}
// Send notification email
let notificationSent = false;
try {
await emailServices.payment.sendAccountDisconnectedEmail(user.email, {
ownerName: user.firstName || user.name,
hasPendingPayouts: pendingRentals.length > 0,
pendingPayoutCount: pendingRentals.length,
});
notificationSent = true;
logger.info("Sent account disconnected notification", { userId: user.id });
} catch (emailError) {
logger.error("Failed to send account disconnected notification", {
userId: user.id,
error: emailError.message,
});
}
return {
processed: true,
userId: user.id,
pendingPayoutsCount: pendingRentals.length,
notificationSent,
};
} catch (error) {
logger.error("Error processing account.application.deauthorized webhook", {
accountId,
error: error.message,
stack: error.stack,
});
throw error;
}
}
/**
* Reconcile payout statuses for an owner by checking Stripe for actual status.
* This handles cases where payout.paid, payout.failed, or payout.canceled webhooks were missed.
*
* Checks paid, failed, and canceled payouts to ensure accurate status tracking.
*
* @param {string} ownerId - The owner's user ID
* @returns {Object} - { reconciled, updated, failed, notificationsSent, errors }
*/
static async reconcilePayoutStatuses(ownerId) {
const results = {
reconciled: 0,
updated: 0,
failed: 0,
notificationsSent: 0,
errors: [],
};
try {
// Find rentals that need reconciliation
const rentalsToReconcile = await Rental.findAll({
where: {
ownerId,
payoutStatus: "completed",
stripeTransferId: { [Op.not]: null },
bankDepositStatus: { [Op.is]: null },
},
include: [
{
model: User,
as: "owner",
attributes: ["id", "email", "firstName", "name", "stripeConnectedAccountId"],
},
],
});
if (rentalsToReconcile.length === 0) {
return results;
}
logger.info("Reconciling payout statuses", {
ownerId,
rentalsCount: rentalsToReconcile.length,
});
// Get the connected account ID (same for all rentals of this owner)
const connectedAccountId = rentalsToReconcile[0].owner?.stripeConnectedAccountId;
if (!connectedAccountId) {
logger.warn("Owner has no connected account ID", { ownerId });
return results;
}
// Fetch recent paid, failed, and canceled payouts
const [paidPayouts, failedPayouts, canceledPayouts] = await Promise.all([
stripe.payouts.list(
{ status: "paid", limit: 20 },
{ stripeAccount: connectedAccountId }
),
stripe.payouts.list(
{ status: "failed", limit: 20 },
{ stripeAccount: connectedAccountId }
),
stripe.payouts.list(
{ status: "canceled", limit: 20 },
{ stripeAccount: connectedAccountId }
),
]);
// Build a map of transfer IDs to failed payouts for quick lookup
const failedPayoutTransferMap = new Map();
for (const payout of failedPayouts.data) {
try {
const balanceTransactions = await stripe.balanceTransactions.list(
{ payout: payout.id, type: "transfer", limit: 100 },
{ stripeAccount: connectedAccountId }
);
for (const bt of balanceTransactions.data) {
if (bt.source) {
failedPayoutTransferMap.set(bt.source, payout);
}
}
} catch (btError) {
logger.warn("Error fetching balance transactions for failed payout", {
payoutId: payout.id,
error: btError.message,
});
}
}
// Build a map of transfer IDs to canceled payouts for quick lookup
const canceledPayoutTransferMap = new Map();
for (const payout of canceledPayouts.data) {
try {
const balanceTransactions = await stripe.balanceTransactions.list(
{ payout: payout.id, type: "transfer", limit: 100 },
{ stripeAccount: connectedAccountId }
);
for (const bt of balanceTransactions.data) {
if (bt.source) {
canceledPayoutTransferMap.set(bt.source, payout);
}
}
} catch (btError) {
logger.warn("Error fetching balance transactions for canceled payout", {
payoutId: payout.id,
error: btError.message,
});
}
}
const owner = rentalsToReconcile[0].owner;
for (const rental of rentalsToReconcile) {
results.reconciled++;
try {
// First check if this transfer is in a failed payout
const failedPayout = failedPayoutTransferMap.get(rental.stripeTransferId);
if (failedPayout) {
// Update rental with failed status
await rental.update({
bankDepositStatus: "failed",
stripePayoutId: failedPayout.id,
bankDepositFailureCode: failedPayout.failure_code || "unknown",
});
results.failed++;
logger.warn("Reconciled rental with failed payout", {
rentalId: rental.id,
payoutId: failedPayout.id,
failureCode: failedPayout.failure_code,
});
// Send failure notification
if (owner?.email) {
try {
const failureInfo = getPayoutFailureMessage(failedPayout.failure_code);
await emailServices.payment.sendPayoutFailedNotification(owner.email, {
ownerName: owner.firstName || owner.name,
payoutAmount: failedPayout.amount / 100,
failureMessage: failureInfo.message,
actionRequired: failureInfo.action,
failureCode: failedPayout.failure_code || "unknown",
requiresBankUpdate: failureInfo.requiresBankUpdate,
});
results.notificationsSent++;
logger.info("Sent reconciled payout failure notification", {
userId: owner.id,
rentalId: rental.id,
payoutId: failedPayout.id,
});
} catch (emailError) {
logger.error("Failed to send reconciled payout failure notification", {
userId: owner.id,
rentalId: rental.id,
error: emailError.message,
});
}
}
continue; // Move to next rental
}
// Check if this transfer is in a canceled payout
const canceledPayout = canceledPayoutTransferMap.get(rental.stripeTransferId);
if (canceledPayout) {
await rental.update({
bankDepositStatus: "canceled",
stripePayoutId: canceledPayout.id,
});
results.canceled = (results.canceled || 0) + 1;
logger.info("Reconciled rental with canceled payout", {
rentalId: rental.id,
payoutId: canceledPayout.id,
});
continue; // Move to next rental
}
// Check for paid payout
const transfer = await stripe.transfers.retrieve(rental.stripeTransferId);
const matchingPaidPayout = paidPayouts.data.find(
(payout) => payout.arrival_date >= transfer.created
);
if (matchingPaidPayout) {
await rental.update({
bankDepositStatus: "paid",
bankDepositAt: new Date(matchingPaidPayout.arrival_date * 1000),
stripePayoutId: matchingPaidPayout.id,
});
results.updated++;
logger.info("Reconciled rental payout status to paid", {
rentalId: rental.id,
payoutId: matchingPaidPayout.id,
arrivalDate: matchingPaidPayout.arrival_date,
});
}
} catch (rentalError) {
results.errors.push({
rentalId: rental.id,
error: rentalError.message,
});
logger.error("Error reconciling rental payout status", {
rentalId: rental.id,
error: rentalError.message,
});
}
}
logger.info("Payout reconciliation complete", {
ownerId,
reconciled: results.reconciled,
updated: results.updated,
failed: results.failed,
canceled: results.canceled || 0,
notificationsSent: results.notificationsSent,
errors: results.errors.length,
});
return results;
} catch (error) {
logger.error("Error in reconcilePayoutStatuses", {
ownerId,
error: error.message,
stack: error.stack,
});
throw error;
}
}
}
module.exports = StripeWebhookService;