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 || "http://localhost:3000" }/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;