822 lines
26 KiB
JavaScript
822 lines
26 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.
|
|
* Tracks requirements, triggers payouts when enabled, and notifies when disabled.
|
|
* @param {Object} account - The Stripe account object from the webhook
|
|
* @returns {Object} - { processed, payoutsTriggered, payoutResults, notificationSent }
|
|
*/
|
|
static async handleAccountUpdated(account) {
|
|
const accountId = account.id;
|
|
const payoutsEnabled = account.payouts_enabled;
|
|
const requirements = account.requirements || {};
|
|
|
|
logger.info("Processing account.updated webhook", {
|
|
accountId,
|
|
payoutsEnabled,
|
|
chargesEnabled: account.charges_enabled,
|
|
detailsSubmitted: account.details_submitted,
|
|
currentlyDue: requirements.currently_due?.length || 0,
|
|
pastDue: requirements.past_due?.length || 0,
|
|
disabledReason: requirements.disabled_reason,
|
|
});
|
|
|
|
// 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" };
|
|
}
|
|
|
|
// Store previous state before update
|
|
const previousPayoutsEnabled = user.stripePayoutsEnabled;
|
|
|
|
// Update user with all account status fields
|
|
await user.update({
|
|
stripePayoutsEnabled: payoutsEnabled,
|
|
stripeRequirementsCurrentlyDue: requirements.currently_due || [],
|
|
stripeRequirementsPastDue: requirements.past_due || [],
|
|
stripeDisabledReason: requirements.disabled_reason || null,
|
|
stripeRequirementsLastUpdated: new Date(),
|
|
});
|
|
|
|
logger.info("Updated user Stripe account status", {
|
|
userId: user.id,
|
|
accountId,
|
|
previousPayoutsEnabled,
|
|
newPayoutsEnabled: payoutsEnabled,
|
|
currentlyDue: requirements.currently_due?.length || 0,
|
|
pastDue: requirements.past_due?.length || 0,
|
|
});
|
|
|
|
const result = {
|
|
processed: true,
|
|
payoutsTriggered: false,
|
|
notificationSent: false,
|
|
};
|
|
|
|
// 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,
|
|
});
|
|
|
|
result.payoutsTriggered = true;
|
|
result.payoutResults = await this.processPayoutsForOwner(user.id);
|
|
}
|
|
|
|
// If payouts just became disabled (true -> false), notify the owner
|
|
if (!payoutsEnabled && previousPayoutsEnabled) {
|
|
logger.warn("Payouts disabled for user", {
|
|
userId: user.id,
|
|
accountId,
|
|
disabledReason: requirements.disabled_reason,
|
|
currentlyDue: requirements.currently_due,
|
|
});
|
|
|
|
try {
|
|
const disabledReason = this.formatDisabledReason(requirements.disabled_reason);
|
|
|
|
await emailServices.payment.sendPayoutsDisabledEmail(user.email, {
|
|
ownerName: user.firstName || user.lastName,
|
|
disabledReason,
|
|
});
|
|
|
|
result.notificationSent = true;
|
|
|
|
logger.info("Sent payouts disabled notification to owner", {
|
|
userId: user.id,
|
|
accountId,
|
|
disabledReason: requirements.disabled_reason,
|
|
});
|
|
} catch (emailError) {
|
|
logger.error("Failed to send payouts disabled notification", {
|
|
userId: user.id,
|
|
accountId,
|
|
error: emailError.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Format Stripe disabled_reason code to user-friendly message.
|
|
* @param {string} reason - Stripe disabled_reason code
|
|
* @returns {string} User-friendly message
|
|
*/
|
|
static formatDisabledReason(reason) {
|
|
const reasonMap = {
|
|
"requirements.past_due":
|
|
"Some required information is past due and must be provided to continue receiving payouts.",
|
|
"requirements.pending_verification":
|
|
"Your submitted information is being verified. This usually takes a few minutes.",
|
|
listed: "Your account has been listed for review due to potential policy concerns.",
|
|
platform_paused:
|
|
"Payouts have been temporarily paused by the platform.",
|
|
rejected_fraud: "Your account was flagged for potential fraudulent activity.",
|
|
rejected_listed: "Your account has been rejected due to policy concerns.",
|
|
rejected_terms_of_service:
|
|
"Your account was rejected due to a terms of service violation.",
|
|
rejected_other: "Your account was rejected. Please contact support for more information.",
|
|
under_review: "Your account is under review. We'll notify you when the review is complete.",
|
|
};
|
|
|
|
return reasonMap[reason] || "Additional verification is required for your account.";
|
|
}
|
|
|
|
/**
|
|
* 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.lastName,
|
|
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,
|
|
stripeRequirementsCurrentlyDue: [],
|
|
stripeRequirementsPastDue: [],
|
|
stripeDisabledReason: null,
|
|
stripeRequirementsLastUpdated: null,
|
|
});
|
|
|
|
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.lastName,
|
|
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", "lastName", "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.lastName,
|
|
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;
|