diff --git a/backend/services/stripeWebhookService.js b/backend/services/stripeWebhookService.js index 5411644..740b537 100644 --- a/backend/services/stripeWebhookService.js +++ b/backend/services/stripeWebhookService.js @@ -293,6 +293,127 @@ class StripeWebhookService { throw error; } } + + /** + * Reconcile payout statuses for an owner by checking Stripe for actual status. + * This handles cases where the payout.paid webhook was missed or failed. + * + * Simplified approach: Since Stripe automatic payouts sweep the entire available + * balance, if there's been a paid payout after our transfer was created, our + * funds were included. + * + * @param {string} ownerId - The owner's user ID + * @returns {Object} - { reconciled, updated, errors } + */ + static async reconcilePayoutStatuses(ownerId) { + const results = { + reconciled: 0, + updated: 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: ["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 payouts once for all rentals + const paidPayouts = await stripe.payouts.list( + { status: "paid", limit: 20 }, + { stripeAccount: connectedAccountId } + ); + + if (paidPayouts.data.length === 0) { + logger.info("No paid payouts found for connected account", { connectedAccountId }); + return results; + } + + for (const rental of rentalsToReconcile) { + results.reconciled++; + + try { + // Get the transfer to find when it was created + const transfer = await stripe.transfers.retrieve(rental.stripeTransferId); + + // Find a payout that arrived after the transfer was created + const matchingPayout = paidPayouts.data.find( + (payout) => payout.arrival_date >= transfer.created + ); + + if (matchingPayout) { + await rental.update({ + bankDepositStatus: "paid", + bankDepositAt: new Date(matchingPayout.arrival_date * 1000), + stripePayoutId: matchingPayout.id, + }); + + results.updated++; + + logger.info("Reconciled rental payout status", { + rentalId: rental.id, + payoutId: matchingPayout.id, + arrivalDate: matchingPayout.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, + 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;