Files
rentall-app/backend/services/stripeService.js
2026-01-21 19:00:55 -05:00

430 lines
12 KiB
JavaScript

const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
const logger = require("../utils/logger");
const { parseStripeError, PaymentError } = require("../utils/stripeErrors");
const { User } = require("../models");
const emailServices = require("./email");
class StripeService {
static async getCheckoutSession(sessionId) {
try {
return await stripe.checkout.sessions.retrieve(sessionId, {
expand: ["setup_intent", "setup_intent.payment_method"],
});
} catch (error) {
logger.error("Error retrieving checkout session", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
static async createConnectedAccount({ email, country = "US" }) {
try {
const account = await stripe.accounts.create({
type: "express",
email,
country,
capabilities: {
transfers: { requested: true },
},
});
return account;
} catch (error) {
logger.error("Error creating connected account", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
static async createAccountLink(accountId, refreshUrl, returnUrl) {
try {
const accountLink = await stripe.accountLinks.create({
account: accountId,
refresh_url: refreshUrl,
return_url: returnUrl,
type: "account_onboarding",
});
return accountLink;
} catch (error) {
logger.error("Error creating account link", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
static async getAccountStatus(accountId) {
try {
const account = await stripe.accounts.retrieve(accountId);
return {
id: account.id,
details_submitted: account.details_submitted,
payouts_enabled: account.payouts_enabled,
capabilities: account.capabilities,
requirements: account.requirements,
};
} catch (error) {
logger.error("Error retrieving account status", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
static async createAccountSession(accountId) {
try {
const accountSession = await stripe.accountSessions.create({
account: accountId,
components: {
account_onboarding: { enabled: true },
},
});
return accountSession;
} catch (error) {
logger.error("Error creating account session", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
static async createTransfer({
amount,
currency = "usd",
destination,
metadata = {},
}) {
try {
// Generate idempotency key from rental ID to prevent duplicate transfers
const idempotencyKey = metadata?.rentalId
? `transfer_rental_${metadata.rentalId}`
: undefined;
const transfer = await stripe.transfers.create(
{
amount: Math.round(amount * 100), // Convert to cents
currency,
destination,
metadata,
},
idempotencyKey ? { idempotencyKey } : undefined,
);
return transfer;
} catch (error) {
// Check if this is a disconnected account error (fallback for missed webhooks)
if (this.isAccountDisconnectedError(error)) {
logger.warn("Transfer failed - account appears disconnected", {
destination,
errorCode: error.code,
errorType: error.type,
});
// Clean up stale connection data asynchronously (don't block the error)
this.handleDisconnectedAccount(destination).catch((cleanupError) => {
logger.error("Failed to clean up disconnected account", {
destination,
error: cleanupError.message,
});
});
}
logger.error("Error creating transfer", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
/**
* Check if error indicates the connected account is disconnected.
* Used as fallback detection when webhook was missed.
* @param {Error} error - Stripe error object
* @returns {boolean} - True if error indicates disconnected account
*/
static isAccountDisconnectedError(error) {
// Stripe returns these error codes when account is disconnected or invalid
const disconnectedCodes = ["account_invalid", "platform_api_key_expired"];
// Error messages that indicate disconnection
const disconnectedMessages = [
"cannot transfer",
"not connected",
"no longer connected",
"account has been deauthorized",
];
if (disconnectedCodes.includes(error.code)) {
return true;
}
const message = (error.message || "").toLowerCase();
return disconnectedMessages.some((msg) => message.includes(msg));
}
/**
* Handle disconnected account - cleanup and notify.
* Called as fallback when webhook was missed.
* @param {string} accountId - The disconnected Stripe account ID
*/
static async handleDisconnectedAccount(accountId) {
try {
const user = await User.findOne({
where: { stripeConnectedAccountId: accountId },
});
if (!user) {
return;
}
logger.warn("Cleaning up disconnected account (webhook likely missed)", {
userId: user.id,
accountId,
});
// Clear connection
await user.update({
stripeConnectedAccountId: null,
stripePayoutsEnabled: false,
});
// Send notification
await emailServices.payment.sendAccountDisconnectedEmail(user.email, {
ownerName: user.firstName || user.lastName,
hasPendingPayouts: true, // We're in a transfer, so there's at least one
pendingPayoutCount: 1,
});
logger.info("Sent account disconnected notification (fallback)", {
userId: user.id,
});
} catch (cleanupError) {
logger.error("Failed to clean up disconnected account", {
accountId,
error: cleanupError.message,
});
// Don't throw - let original error propagate
}
}
static async createRefund({
paymentIntentId,
amount,
metadata = {},
reason = "requested_by_customer",
}) {
try {
// Generate idempotency key - include amount to allow multiple partial refunds
const idempotencyKey = metadata?.rentalId
? `refund_rental_${metadata.rentalId}_${Math.round(amount * 100)}`
: undefined;
const refund = await stripe.refunds.create(
{
payment_intent: paymentIntentId,
amount: Math.round(amount * 100), // Convert to cents
metadata,
reason,
},
idempotencyKey ? { idempotencyKey } : undefined,
);
return refund;
} catch (error) {
logger.error("Error creating refund", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
static async getRefund(refundId) {
try {
return await stripe.refunds.retrieve(refundId);
} catch (error) {
logger.error("Error retrieving refund", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
static async chargePaymentMethod(
paymentMethodId,
amount,
customerId,
metadata = {},
) {
try {
// Generate idempotency key to prevent duplicate charges for same rental
const idempotencyKey = metadata?.rentalId
? `charge_rental_${metadata.rentalId}`
: undefined;
// Create a payment intent with the stored payment method
const paymentIntent = await stripe.paymentIntents.create(
{
amount: Math.round(amount * 100), // Convert to cents
currency: "usd",
payment_method: paymentMethodId,
customer: customerId, // Include customer ID
confirm: true, // Automatically confirm the payment
off_session: true, // Indicate this is an off-session payment
return_url: `${process.env.FRONTEND_URL}/complete-payment`,
metadata,
expand: ["latest_charge.payment_method_details"], // Expand to get payment method details
},
idempotencyKey ? { idempotencyKey } : undefined,
);
// Check if additional authentication is required
if (paymentIntent.status === "requires_action") {
return {
status: "requires_action",
requiresAction: true,
paymentIntentId: paymentIntent.id,
clientSecret: paymentIntent.client_secret,
};
}
// Extract payment method details from latest_charge
const charge = paymentIntent.latest_charge;
const paymentMethodDetails = charge?.payment_method_details;
// Build payment method info object
let paymentMethod = null;
if (paymentMethodDetails) {
const type = paymentMethodDetails.type;
if (type === "card") {
paymentMethod = {
type: "card",
brand: paymentMethodDetails.card?.brand || "card",
last4: paymentMethodDetails.card?.last4 || "****",
};
} else if (type === "us_bank_account") {
paymentMethod = {
type: "bank",
brand: "bank_account",
last4: paymentMethodDetails.us_bank_account?.last4 || "****",
};
} else {
paymentMethod = {
type: type || "unknown",
brand: type || "payment",
last4: null,
};
}
}
return {
status: "succeeded",
paymentIntentId: paymentIntent.id,
clientSecret: paymentIntent.client_secret,
paymentMethod: paymentMethod,
chargedAt: new Date(paymentIntent.created * 1000), // Convert Unix timestamp to Date
amountCharged: amount, // Original amount in dollars
};
} catch (error) {
// Handle authentication_required error (thrown for off-session 3DS)
if (error.code === "authentication_required") {
return {
status: "requires_action",
requiresAction: true,
paymentIntentId: error.payment_intent?.id,
clientSecret: error.payment_intent?.client_secret,
};
}
// Parse Stripe error into structured format
const parsedError = parseStripeError(error);
logger.error("Payment failed", {
code: parsedError.code,
ownerMessage: parsedError.ownerMessage,
originalError: parsedError._originalMessage,
stripeCode: parsedError._stripeCode,
paymentMethodId,
customerId,
amount,
stack: error.stack,
});
throw new PaymentError(parsedError);
}
}
static async createCustomer({ email, name, metadata = {} }) {
try {
const customer = await stripe.customers.create({
email,
name,
metadata,
});
return customer;
} catch (error) {
logger.error("Error creating customer", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
static async getPaymentMethod(paymentMethodId) {
try {
return await stripe.paymentMethods.retrieve(paymentMethodId);
} catch (error) {
logger.error("Error retrieving payment method", {
error: error.message,
paymentMethodId,
});
throw error;
}
}
static async createSetupCheckoutSession({ customerId, metadata = {} }) {
try {
const session = await stripe.checkout.sessions.create({
customer: customerId,
payment_method_types: ["card", "link"],
mode: "setup",
ui_mode: "embedded",
redirect_on_completion: "never",
// Configure for off-session usage - triggers 3DS during setup
payment_method_options: {
card: {
request_three_d_secure: "any",
},
},
metadata: {
type: "payment_method_setup",
...metadata,
},
});
return session;
} catch (error) {
logger.error("Error creating setup checkout session", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
}
module.exports = StripeService;