432 lines
12 KiB
JavaScript
432 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.name,
|
|
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", "us_bank_account", "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;
|