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

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