Stripe error handling and now you can rent an item for a different time while having an upcoming or active rental

This commit is contained in:
jackiettran
2026-01-10 13:29:09 -05:00
parent 8aea3c38ed
commit 860b6d6160
10 changed files with 178 additions and 196 deletions

View File

@@ -0,0 +1,15 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
// Add paymentFailedReason - stores the user-friendly error message for payment failures
await queryInterface.addColumn("Rentals", "paymentFailedReason", {
type: Sequelize.TEXT,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn("Rentals", "paymentFailedReason");
},
};

View File

@@ -172,6 +172,9 @@ const Rental = sequelize.define("Rental", {
paymentFailedNotifiedAt: {
type: DataTypes.DATE,
},
paymentFailedReason: {
type: DataTypes.TEXT,
},
// Payment method update rate limiting
paymentMethodUpdatedAt: {
type: DataTypes.DATE,

View File

@@ -654,8 +654,11 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
? paymentError.renterMessage
: "Your payment could not be processed. Please try a different payment method.";
// Track payment failure timestamp
await rental.update({ paymentFailedNotifiedAt: new Date() });
// Track payment failure timestamp and reason
await rental.update({
paymentFailedNotifiedAt: new Date(),
paymentFailedReason: renterMessage,
});
// Auto-send payment declined email to renter
try {

View File

@@ -401,7 +401,7 @@ class StripeService {
try {
const session = await stripe.checkout.sessions.create({
customer: customerId,
payment_method_types: ["card", "us_bank_account", "link"],
payment_method_types: ["card", "link"],
mode: "setup",
ui_mode: "embedded",
redirect_on_completion: "never",

View File

@@ -882,7 +882,7 @@ describe('StripeService', () => {
expect(mockStripeCheckoutSessionsCreate).toHaveBeenCalledWith({
customer: 'cus_123456789',
payment_method_types: ['card', 'us_bank_account', 'link'],
payment_method_types: ['card', 'link'],
mode: 'setup',
ui_mode: 'embedded',
redirect_on_completion: 'never',
@@ -915,7 +915,7 @@ describe('StripeService', () => {
expect(mockStripeCheckoutSessionsCreate).toHaveBeenCalledWith({
customer: 'cus_123456789',
payment_method_types: ['card', 'us_bank_account', 'link'],
payment_method_types: ['card', 'link'],
mode: 'setup',
ui_mode: 'embedded',
redirect_on_completion: 'never',

View File

@@ -21,42 +21,6 @@ describe('Stripe Errors Utility', () => {
}
});
describe('ACH decline codes', () => {
const achCodes = [
'bank_account_closed',
'bank_account_frozen',
'bank_account_restricted',
'bank_account_unusable',
'bank_account_invalid_details',
'debit_not_authorized',
];
test.each(achCodes)('%s exists in DECLINE_MESSAGES', (code) => {
expect(DECLINE_MESSAGES).toHaveProperty(code);
});
test.each(achCodes)('%s has all required properties', (code) => {
const messages = DECLINE_MESSAGES[code];
expect(messages).toHaveProperty('ownerMessage');
expect(messages).toHaveProperty('renterMessage');
expect(messages).toHaveProperty('canOwnerRetry');
expect(messages).toHaveProperty('requiresNewPaymentMethod');
});
test.each(achCodes)('%s requires new payment method', (code) => {
expect(DECLINE_MESSAGES[code].requiresNewPaymentMethod).toBe(true);
});
test.each(achCodes)('%s does not allow owner retry', (code) => {
expect(DECLINE_MESSAGES[code].canOwnerRetry).toBe(false);
});
test.each(achCodes)('%s has non-empty messages', (code) => {
expect(DECLINE_MESSAGES[code].ownerMessage.length).toBeGreaterThan(0);
expect(DECLINE_MESSAGES[code].renterMessage.length).toBeGreaterThan(0);
});
});
describe('Additional card decline codes', () => {
const additionalCardCodes = [
'call_issuer',
@@ -189,51 +153,6 @@ describe('Stripe Errors Utility', () => {
expect(result.canOwnerRetry).toBe(true);
});
test('parses ACH decline error - bank_account_closed', () => {
const error = {
type: 'StripeCardError',
code: 'card_declined',
decline_code: 'bank_account_closed',
message: 'The bank account has been closed.',
};
const result = parseStripeError(error);
expect(result.code).toBe('bank_account_closed');
expect(result.ownerMessage).toBe("The renter's bank account has been closed.");
expect(result.renterMessage).toBe('Your bank account has been closed. Please add a different payment method.');
expect(result.requiresNewPaymentMethod).toBe(true);
});
test('parses ACH decline error - bank_account_frozen', () => {
const error = {
type: 'StripeCardError',
code: 'card_declined',
decline_code: 'bank_account_frozen',
message: 'The bank account is frozen.',
};
const result = parseStripeError(error);
expect(result.code).toBe('bank_account_frozen');
expect(result.ownerMessage).toBe("The renter's bank account is frozen.");
expect(result.renterMessage).toContain('frozen');
});
test('parses ACH decline error - debit_not_authorized', () => {
const error = {
type: 'StripeCardError',
code: 'card_declined',
decline_code: 'debit_not_authorized',
message: 'Debit not authorized.',
};
const result = parseStripeError(error);
expect(result.code).toBe('debit_not_authorized');
expect(result.renterMessage).toContain('not authorized');
});
test('returns default error for unknown decline code', () => {
const error = {
type: 'StripeCardError',
@@ -279,7 +198,7 @@ describe('Stripe Errors Utility', () => {
const error = {
type: 'StripeCardError',
code: 'card_declined',
decline_code: 'bank_account_frozen',
decline_code: 'insufficient_funds',
message: 'Original Stripe message',
};
@@ -293,11 +212,11 @@ describe('Stripe Errors Utility', () => {
describe('PaymentError', () => {
test('creates PaymentError with all properties', () => {
const parsedError = {
code: 'bank_account_frozen',
ownerMessage: "The renter's bank account is frozen.",
renterMessage: 'Your bank account is frozen.',
code: 'insufficient_funds',
ownerMessage: "The renter's card has insufficient funds.",
renterMessage: 'Your card has insufficient funds.',
canOwnerRetry: false,
requiresNewPaymentMethod: true,
requiresNewPaymentMethod: false,
_originalMessage: 'Original message',
_stripeCode: 'card_declined',
};
@@ -306,17 +225,17 @@ describe('Stripe Errors Utility', () => {
expect(error).toBeInstanceOf(Error);
expect(error.name).toBe('PaymentError');
expect(error.code).toBe('bank_account_frozen');
expect(error.ownerMessage).toBe("The renter's bank account is frozen.");
expect(error.renterMessage).toBe('Your bank account is frozen.');
expect(error.requiresNewPaymentMethod).toBe(true);
expect(error.code).toBe('insufficient_funds');
expect(error.ownerMessage).toBe("The renter's card has insufficient funds.");
expect(error.renterMessage).toBe('Your card has insufficient funds.');
expect(error.requiresNewPaymentMethod).toBe(false);
});
test('toJSON returns serializable object', () => {
const parsedError = {
code: 'bank_account_closed',
ownerMessage: "The renter's bank account has been closed.",
renterMessage: 'Your bank account has been closed.',
code: 'expired_card',
ownerMessage: "The renter's card has expired.",
renterMessage: 'Your card has expired.',
canOwnerRetry: false,
requiresNewPaymentMethod: true,
_originalMessage: 'Should not appear',
@@ -326,7 +245,7 @@ describe('Stripe Errors Utility', () => {
const error = new PaymentError(parsedError);
const json = error.toJSON();
expect(json).toHaveProperty('code', 'bank_account_closed');
expect(json).toHaveProperty('code', 'expired_card');
expect(json).toHaveProperty('ownerMessage');
expect(json).toHaveProperty('renterMessage');
expect(json).not.toHaveProperty('_originalMessage');

View File

@@ -5,6 +5,8 @@
* Provides structured error information for frontend handling.
*/
const logger = require('./logger');
const DECLINE_MESSAGES = {
authentication_required: {
ownerMessage: "The renter's card requires additional authentication.",
@@ -218,50 +220,6 @@ const DECLINE_MESSAGES = {
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
// ACH/Bank Account Declines
bank_account_closed: {
ownerMessage: "The renter's bank account has been closed.",
renterMessage:
"Your bank account has been closed. Please add a different payment method.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
bank_account_frozen: {
ownerMessage: "The renter's bank account is frozen.",
renterMessage:
"Your bank account is frozen. Please contact your bank or use a different payment method.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
bank_account_restricted: {
ownerMessage: "The renter's bank account has restrictions.",
renterMessage:
"Your bank account has restrictions. Please contact your bank or use a different payment method.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
bank_account_unusable: {
ownerMessage: "The renter's bank account cannot be used.",
renterMessage:
"Your bank account cannot be used for payments. Please add a different payment method.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
bank_account_invalid_details: {
ownerMessage: "The renter's bank account details are invalid.",
renterMessage:
"Your bank account details appear to be invalid. Please re-enter your bank information.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
debit_not_authorized: {
ownerMessage: "The renter's bank account is not authorized for debits.",
renterMessage:
"Your bank account is not authorized for automatic debits. Please enable ACH payments or use a card.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
};
// Default error for unknown decline codes
@@ -273,6 +231,37 @@ const DEFAULT_ERROR = {
requiresNewPaymentMethod: true,
};
// Mapping for StripeInvalidRequestError codes
const INVALID_REQUEST_MESSAGES = {
resource_missing: {
ownerMessage: "The renter's payment method is no longer valid.",
renterMessage:
"Your payment method is no longer valid. Please add a new payment method.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
payment_method_invalid: {
ownerMessage: "The renter's payment method is invalid.",
renterMessage:
"Your payment method is invalid. Please add a new payment method.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
payment_intent_unexpected_state: {
ownerMessage: "This payment is in an unexpected state.",
renterMessage: "There was an issue with your payment. Please try again.",
canOwnerRetry: true,
requiresNewPaymentMethod: false,
},
customer_deleted: {
ownerMessage: "The renter's payment profile has been deleted.",
renterMessage:
"Your payment profile needs to be set up again. Please add a new payment method.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
};
/**
* Parse a Stripe error and return structured error information
* @param {Error} error - The error object from Stripe
@@ -284,6 +273,16 @@ function parseStripeError(error) {
const declineCode = error.decline_code || error.code || "card_declined";
const errorInfo = DECLINE_MESSAGES[declineCode] || DEFAULT_ERROR;
// Log if we're falling back to default for an unknown decline code
if (!DECLINE_MESSAGES[declineCode]) {
logger.warn("[StripeErrors] Unknown decline code - please add mapping", {
declineCode,
errorCode: error.code,
errorType: error.type,
errorMessage: error.message,
});
}
return {
code: declineCode,
ownerMessage: errorInfo.ownerMessage,
@@ -298,8 +297,62 @@ function parseStripeError(error) {
// Handle other Stripe error types
if (error.type === "StripeInvalidRequestError") {
// First, check if there's a decline_code in the error (some bank errors include this)
if (error.decline_code && DECLINE_MESSAGES[error.decline_code]) {
const errorInfo = DECLINE_MESSAGES[error.decline_code];
return {
code: "invalid_request",
code: error.decline_code,
ownerMessage: errorInfo.ownerMessage,
renterMessage: errorInfo.renterMessage,
canOwnerRetry: errorInfo.canOwnerRetry,
requiresNewPaymentMethod: errorInfo.requiresNewPaymentMethod,
_originalMessage: error.message,
_stripeCode: error.code,
};
}
// Check if error.code has a specific mapping
const errorCode = error.code;
if (errorCode && INVALID_REQUEST_MESSAGES[errorCode]) {
const errorInfo = INVALID_REQUEST_MESSAGES[errorCode];
return {
code: errorCode,
ownerMessage: errorInfo.ownerMessage,
renterMessage: errorInfo.renterMessage,
canOwnerRetry: errorInfo.canOwnerRetry,
requiresNewPaymentMethod: errorInfo.requiresNewPaymentMethod,
_originalMessage: error.message,
_stripeCode: error.code,
};
}
// Also check DECLINE_MESSAGES for the error code (some codes appear in both contexts)
if (errorCode && DECLINE_MESSAGES[errorCode]) {
const errorInfo = DECLINE_MESSAGES[errorCode];
return {
code: errorCode,
ownerMessage: errorInfo.ownerMessage,
renterMessage: errorInfo.renterMessage,
canOwnerRetry: errorInfo.canOwnerRetry,
requiresNewPaymentMethod: errorInfo.requiresNewPaymentMethod,
_originalMessage: error.message,
_stripeCode: error.code,
};
}
// Log unhandled StripeInvalidRequestError codes for future mapping
logger.warn(
"[StripeErrors] Unhandled StripeInvalidRequestError - please add mapping",
{
errorCode: error.code,
declineCode: error.decline_code,
errorMessage: error.message,
param: error.param,
}
);
return {
code: errorCode || "invalid_request",
ownerMessage: "There was a problem processing this payment.",
renterMessage: "There was a problem with your payment method.",
canOwnerRetry: false,
@@ -337,6 +390,13 @@ function parseStripeError(error) {
}
// Default fallback for unknown errors
logger.warn("[StripeErrors] Unknown error type - please investigate", {
errorType: error.type,
errorCode: error.code,
declineCode: error.decline_code,
errorMessage: error.message,
});
return {
code: "unknown_error",
...DEFAULT_ERROR,

View File

@@ -660,9 +660,15 @@ const ItemDetail: React.FC = () => {
})()}
{/* Rental Period Selection - Only show for non-owners */}
{!isOwner && item.isAvailable && !isAlreadyRenting && (
{!isOwner && item.isAvailable && (
<>
<hr />
{isAlreadyRenting && (
<div className="alert alert-warning py-2 mb-3" role="alert">
<i className="bi bi-exclamation-triangle-fill me-2"></i>
<small>You already have an active rental for this item</small>
</div>
)}
<div className="text-start">
{dateValidationError && (
<div
@@ -793,7 +799,7 @@ const ItemDetail: React.FC = () => {
)}
{/* Action Buttons */}
{!isOwner && item.isAvailable && !isAlreadyRenting && (
{!isOwner && item.isAvailable && (
<div className="d-grid">
<button
className="btn btn-primary btn-lg"
@@ -810,17 +816,6 @@ const ItemDetail: React.FC = () => {
</button>
</div>
)}
{!isOwner && isAlreadyRenting && (
<div className="d-grid">
<button
className="btn btn-success btn-lg"
disabled
style={{ opacity: 0.8 }}
>
Renting
</button>
</div>
)}
</div>
</div>
</div>
@@ -906,9 +901,15 @@ const ItemDetail: React.FC = () => {
})()}
{/* Rental Period Selection - Only show for non-owners */}
{!isOwner && item.isAvailable && !isAlreadyRenting && (
{!isOwner && item.isAvailable && (
<>
<hr />
{isAlreadyRenting && (
<div className="alert alert-warning py-2 mb-3" role="alert">
<i className="bi bi-exclamation-triangle-fill me-2"></i>
<small>You already have an active rental for this item</small>
</div>
)}
<div className="text-start">
{dateValidationError && (
<div
@@ -1031,7 +1032,7 @@ const ItemDetail: React.FC = () => {
)}
{/* Action Buttons */}
{!isOwner && item.isAvailable && !isAlreadyRenting && (
{!isOwner && item.isAvailable && (
<div className="d-grid">
<button
className="btn btn-primary"
@@ -1048,17 +1049,6 @@ const ItemDetail: React.FC = () => {
</button>
</div>
)}
{!isOwner && isAlreadyRenting && (
<div className="d-grid">
<button
className="btn btn-success"
disabled
style={{ opacity: 0.8 }}
>
Renting
</button>
</div>
)}
</div>
</div>
</div>
@@ -1120,15 +1110,6 @@ const ItemDetail: React.FC = () => {
);
})()}
</div>
{isAlreadyRenting ? (
<button
className="btn btn-success"
disabled
style={{ opacity: 0.8 }}
>
Renting
</button>
) : (
<button
className="btn btn-primary btn-lg"
onClick={() => {
@@ -1143,7 +1124,6 @@ const ItemDetail: React.FC = () => {
>
Check Availability
</button>
)}
</div>
</div>
)}

View File

@@ -388,8 +388,9 @@ const Renting: React.FC = () => {
<div className="alert alert-warning py-2 mb-3">
<div className="d-flex align-items-center justify-content-between">
<small>
<strong>Payment issue:</strong> Please update your
payment method so the owner can approve your request.
<strong>Payment issue:</strong>{" "}
{rental.paymentFailedReason ||
"Please update your payment method so the owner can approve your request."}
</small>
<button
className="btn btn-sm btn-warning ms-2"

View File

@@ -158,6 +158,7 @@ export interface Rental {
bankDepositFailureCode?: string;
// Payment failure tracking
paymentFailedNotifiedAt?: string;
paymentFailedReason?: string;
paymentMethodUpdatedAt?: string;
intendedUse?: string;
rating?: number;