payout retry lambda

This commit is contained in:
jackiettran
2026-01-14 18:05:41 -05:00
parent da82872297
commit 7f2f45b1c2
13 changed files with 1439 additions and 9 deletions

View File

@@ -0,0 +1,107 @@
# Payout Retry Processor Lambda
Retries failed Stripe payouts daily. Triggered by EventBridge Scheduler at 7 AM EST.
## Prerequisites
- Node.js 20.x
- PostgreSQL database with rentals data
- Stripe account with test API key (`sk_test_...`)
## Setup
1. Install shared dependencies:
```bash
cd lambdas/shared
npm install
```
2. Install Lambda dependencies:
```bash
cd lambdas/payoutRetryProcessor
npm install
```
## Environment Variables
| Variable | Description |
| ------------------- | ---------------------------------------------- |
| `DATABASE_URL` | PostgreSQL connection string |
| `STRIPE_SECRET_KEY` | Stripe API key (use `sk_test_...` for testing) |
| `FRONTEND_URL` | For email template links |
| `SES_FROM_EMAIL` | Sender email address |
| `SES_FROM_NAME` | Sender display name |
| `EMAIL_ENABLED` | Set to `true` to send emails |
## Local Testing
Run the Lambda locally using your dev environment:
```bash
cd lambdas/payoutRetryProcessor
node test-local.js
```
This will:
- Query your local database for failed payouts
- Attempt Stripe transfers (use test mode!)
- Send email notifications if `EMAIL_ENABLED=true`
## Creating Test Data
To test the retry logic, create a rental with failed payout status:
```sql
UPDATE "Rentals"
SET "payoutStatus" = 'failed'
WHERE id = '<rental-id>'
AND status = 'completed'
AND "paymentStatus" = 'paid';
```
## Verifying Results
After running the test:
1. Check console output for success/failure counts
2. Query the database:
```sql
SELECT id, "payoutStatus", "stripeTransferId", "payoutProcessedAt"
FROM "Rentals"
WHERE id = '<rental-id>';
```
3. Check Stripe Dashboard for new transfers
4. Check email inbox if emails are enabled
## AWS Deployment
When deploying to AWS:
1. Create Lambda function with Node.js 20.x runtime
2. Set timeout to 60 seconds (batch processing)
3. Set memory to 256 MB
4. Configure EventBridge Scheduler with cron: `cron(0 12 * * ? *)` (7 AM EST = 12:00 UTC)
5. Add environment variables listed above
6. Configure DLQ for failed invocations
## Architecture
```
EventBridge Scheduler (7 AM EST daily)
|
v
Lambda Function
|
+-- Query failed payouts from PostgreSQL
|
+-- For each failed payout:
| +-- Reset status to "pending"
| +-- Create Stripe transfer
| +-- Update rental record
| +-- Send email notification
|
v
Return summary (success/failure counts)
```

View File

@@ -0,0 +1,193 @@
const { logger, stripe, email } = require("../shared");
const queries = require("./queries");
const path = require("path");
/**
* Process all failed payouts by retrying them.
* This is the main handler logic invoked by the Lambda.
* @returns {Promise<Object>} Results summary with successful and failed counts
*/
async function processPayoutRetries() {
logger.info("Starting payout retry process");
// Get all failed payouts with eligible owners
const failedPayouts = await queries.getFailedPayoutsWithOwners();
logger.info("Found failed payouts to retry", {
count: failedPayouts.length,
});
if (failedPayouts.length === 0) {
return {
success: true,
totalProcessed: 0,
successful: [],
failed: [],
message: "No failed payouts to retry",
};
}
const results = {
successful: [],
failed: [],
};
// Process each failed payout
for (const rental of failedPayouts) {
try {
logger.info("Processing payout retry", {
rentalId: rental.id,
ownerId: rental.ownerId,
amount: rental.payoutAmount,
});
// Reset to pending before retry attempt
await queries.resetPayoutToPending(rental.id);
// Attempt to create Stripe transfer
const transfer = await stripe.createTransfer({
amount: rental.payoutAmount,
destination: rental.owner.stripeConnectedAccountId,
metadata: {
rentalId: rental.id,
ownerId: rental.ownerId,
totalAmount: rental.totalAmount.toString(),
platformFee: rental.platformFee.toString(),
startDateTime: rental.startDateTime.toISOString(),
endDateTime: rental.endDateTime.toISOString(),
retryAttempt: "true",
},
});
// Update rental with successful payout
await queries.updatePayoutSuccess(rental.id, transfer.id);
logger.info("Payout retry successful", {
rentalId: rental.id,
transferId: transfer.id,
amount: rental.payoutAmount,
});
// Send success email notification
try {
await sendPayoutSuccessEmail(rental, transfer.id);
} catch (emailError) {
// Log error but don't fail the payout
logger.error("Failed to send payout success email", {
error: emailError.message,
rentalId: rental.id,
});
}
results.successful.push({
rentalId: rental.id,
amount: rental.payoutAmount,
transferId: transfer.id,
});
} catch (error) {
logger.error("Payout retry failed", {
error: error.message,
rentalId: rental.id,
ownerId: rental.ownerId,
});
// Update payout status back to failed
await queries.updatePayoutFailed(rental.id);
// Check if account is disconnected
if (stripe.isAccountDisconnectedError(error)) {
logger.warn("Account appears disconnected, cleaning up", {
accountId: rental.owner.stripeConnectedAccountId,
});
await handleDisconnectedAccount(rental.owner.stripeConnectedAccountId);
}
results.failed.push({
rentalId: rental.id,
error: error.message,
});
}
}
const summary = {
success: true,
totalProcessed: failedPayouts.length,
successfulCount: results.successful.length,
failedCount: results.failed.length,
successful: results.successful,
failed: results.failed,
};
logger.info("Payout retry process complete", {
totalProcessed: summary.totalProcessed,
successful: summary.successfulCount,
failed: summary.failedCount,
});
return summary;
}
/**
* Send payout success email to owner.
* @param {Object} rental - Rental object with owner and item
* @param {string} stripeTransferId - The Stripe transfer ID
*/
async function sendPayoutSuccessEmail(rental, stripeTransferId) {
const templatePath = path.join(
__dirname,
"templates",
"payoutReceivedToOwner.html"
);
const template = await email.loadTemplate(templatePath);
const ownerName = rental.owner.firstName || rental.owner.lastName || "there";
const frontendUrl = process.env.FRONTEND_URL;
const variables = {
ownerName,
payoutAmount: rental.payoutAmount.toFixed(2),
itemName: rental.item.name,
startDate: email.formatEmailDate(rental.startDateTime),
endDate: email.formatEmailDate(rental.endDateTime),
stripeTransferId,
totalAmount: rental.totalAmount.toFixed(2),
platformFee: rental.platformFee.toFixed(2),
earningsDashboardUrl: `${frontendUrl}/dashboard/earnings`,
};
const htmlBody = email.renderTemplate(template, variables);
await email.sendEmail(
rental.owner.email,
"Your earnings have been deposited - Village Share",
htmlBody
);
}
/**
* Handle cleanup when a Stripe account is detected as disconnected.
* @param {string} stripeConnectedAccountId - The disconnected account ID
*/
async function handleDisconnectedAccount(stripeConnectedAccountId) {
try {
const user = await queries.clearDisconnectedAccount(
stripeConnectedAccountId
);
if (user) {
logger.info("Cleaned up disconnected Stripe account", {
userId: user.id,
accountId: stripeConnectedAccountId,
});
}
} catch (error) {
logger.error("Failed to clean up disconnected account", {
accountId: stripeConnectedAccountId,
error: error.message,
});
}
}
module.exports = {
processPayoutRetries,
};

View File

@@ -0,0 +1,46 @@
const { processPayoutRetries } = require("./handler");
const { logger } = require("../shared");
/**
* Lambda handler for payout retry processing.
*
* Invoked by EventBridge Scheduler on a daily schedule (7 AM EST).
* No event payload required - processes all failed payouts.
*
* @param {Object} event - EventBridge Scheduler event (unused)
* @returns {Promise<Object>} Result of the retry processing
*/
exports.handler = async (event) => {
logger.info("Lambda invoked", {
event,
invocationTime: new Date().toISOString(),
});
try {
const result = await processPayoutRetries();
logger.info("Payout retry processing completed", {
totalProcessed: result.totalProcessed,
successful: result.successfulCount,
failed: result.failedCount,
});
return {
statusCode: 200,
body: JSON.stringify(result),
};
} catch (error) {
logger.error("Payout retry processing failed", {
error: error.message,
stack: error.stack,
});
return {
statusCode: 500,
body: JSON.stringify({
success: false,
error: error.message,
}),
};
}
};

View File

@@ -0,0 +1,48 @@
{
"name": "payout-retry-processor",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "payout-retry-processor",
"version": "1.0.0",
"dependencies": {
"@rentall/lambda-shared": "file:../shared"
},
"devDependencies": {
"dotenv": "^16.4.5"
}
},
"../shared": {
"name": "@rentall/lambda-shared",
"version": "1.0.0",
"dependencies": {
"@aws-sdk/client-scheduler": "^3.896.0",
"@aws-sdk/client-ses": "^3.896.0",
"pg": "^8.16.3",
"stripe": "^17.7.0"
},
"devDependencies": {
"jest": "^30.1.3"
}
},
"node_modules/@rentall/lambda-shared": {
"resolved": "../shared",
"link": true
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
}
}
}

View File

@@ -0,0 +1,16 @@
{
"name": "payout-retry-processor",
"version": "1.0.0",
"description": "Lambda function to retry failed payouts via Stripe Connect",
"main": "index.js",
"dependencies": {
"@rentall/lambda-shared": "file:../shared"
},
"devDependencies": {
"dotenv": "^16.4.5"
},
"scripts": {
"test": "jest",
"local": "node -r dotenv/config test-local.js dotenv_config_path=.env.dev"
}
}

View File

@@ -0,0 +1,152 @@
const { db } = require("../shared");
/**
* Get all failed payouts with eligible owners (Stripe enabled).
* Matches the query from backend PayoutService.retryFailedPayouts()
* @returns {Promise<Array>} Array of failed payouts with owner and item details
*/
async function getFailedPayoutsWithOwners() {
const result = await db.query(
`SELECT
r.id,
r."itemId",
r."renterId",
r."ownerId",
r."startDateTime",
r."endDateTime",
r."totalAmount",
r."payoutAmount",
r."platformFee",
r."paymentStatus",
r."payoutStatus",
r.status,
r."stripeTransferId",
r."createdAt",
r."updatedAt",
-- Owner fields
owner.id AS "owner_id",
owner.email AS "owner_email",
owner."firstName" AS "owner_firstName",
owner."lastName" AS "owner_lastName",
owner."stripeConnectedAccountId" AS "owner_stripeConnectedAccountId",
owner."stripePayoutsEnabled" AS "owner_stripePayoutsEnabled",
-- Item fields
item.id AS "item_id",
item.name AS "item_name"
FROM "Rentals" r
INNER JOIN "Users" owner ON r."ownerId" = owner.id
INNER JOIN "Items" item ON r."itemId" = item.id
WHERE r.status = 'completed'
AND r."paymentStatus" = 'paid'
AND r."payoutStatus" = 'failed'
AND owner."stripeConnectedAccountId" IS NOT NULL
AND owner."stripePayoutsEnabled" = true`
);
// Transform flat results into nested structure
return result.rows.map((row) => ({
id: row.id,
itemId: row.itemId,
renterId: row.renterId,
ownerId: row.ownerId,
startDateTime: row.startDateTime,
endDateTime: row.endDateTime,
totalAmount: parseFloat(row.totalAmount),
payoutAmount: parseFloat(row.payoutAmount),
platformFee: parseFloat(row.platformFee),
paymentStatus: row.paymentStatus,
payoutStatus: row.payoutStatus,
status: row.status,
stripeTransferId: row.stripeTransferId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
owner: {
id: row.owner_id,
email: row.owner_email,
firstName: row.owner_firstName,
lastName: row.owner_lastName,
stripeConnectedAccountId: row.owner_stripeConnectedAccountId,
stripePayoutsEnabled: row.owner_stripePayoutsEnabled,
},
item: {
id: row.item_id,
name: row.item_name,
},
}));
}
/**
* Update rental payout status to pending (before retry attempt).
* @param {string} rentalId - UUID of the rental
* @returns {Promise<void>}
*/
async function resetPayoutToPending(rentalId) {
await db.query(
`UPDATE "Rentals"
SET "payoutStatus" = 'pending',
"updatedAt" = NOW()
WHERE id = $1`,
[rentalId]
);
}
/**
* Update rental with successful payout information.
* @param {string} rentalId - UUID of the rental
* @param {string} stripeTransferId - Stripe transfer ID
* @returns {Promise<void>}
*/
async function updatePayoutSuccess(rentalId, stripeTransferId) {
await db.query(
`UPDATE "Rentals"
SET "payoutStatus" = 'completed',
"payoutProcessedAt" = NOW(),
"stripeTransferId" = $2,
"updatedAt" = NOW()
WHERE id = $1`,
[rentalId, stripeTransferId]
);
}
/**
* Update rental payout status to failed.
* @param {string} rentalId - UUID of the rental
* @returns {Promise<void>}
*/
async function updatePayoutFailed(rentalId) {
await db.query(
`UPDATE "Rentals"
SET "payoutStatus" = 'failed',
"updatedAt" = NOW()
WHERE id = $1`,
[rentalId]
);
}
/**
* Clear Stripe connection for a disconnected account.
* Called when we detect an account is no longer connected.
* @param {string} stripeConnectedAccountId - The disconnected account ID
* @returns {Promise<Object|null>} The affected user or null
*/
async function clearDisconnectedAccount(stripeConnectedAccountId) {
const result = await db.query(
`UPDATE "Users"
SET "stripeConnectedAccountId" = NULL,
"stripePayoutsEnabled" = false,
"updatedAt" = NOW()
WHERE "stripeConnectedAccountId" = $1
RETURNING id, email, "firstName", "lastName"`,
[stripeConnectedAccountId]
);
return result.rows[0] || null;
}
module.exports = {
getFailedPayoutsWithOwners,
resetPayoutToPending,
updatePayoutSuccess,
updatePayoutFailed,
clearDisconnectedAccount,
};

View File

@@ -0,0 +1,435 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Earnings Received - Village Share</title>
<style>
/* Reset styles */
body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e9ecef;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Earnings amount display */
.earnings-display {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 30px;
border-radius: 8px;
text-align: center;
margin: 30px 0;
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
}
.earnings-label {
color: #ffffff;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
opacity: 0.9;
}
.earnings-amount {
color: #ffffff;
font-size: 48px;
font-weight: 700;
margin: 0;
line-height: 1;
}
.earnings-subtitle {
color: #ffffff;
font-size: 14px;
margin-top: 10px;
opacity: 0.9;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Success box */
.success-box {
background-color: #d4edda;
border-left: 4px solid #28a745;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.success-box p {
margin: 0 0 10px 0;
color: #155724;
}
.success-box p:last-child {
margin-bottom: 0;
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 10px 0;
color: #004085;
}
.info-box p:last-child {
margin-bottom: 0;
}
/* Info table */
.info-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
background-color: #f8f9fa;
border-radius: 6px;
overflow: hidden;
}
.info-table th,
.info-table td {
padding: 15px;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
.info-table th {
background-color: #e9ecef;
font-weight: 600;
color: #495057;
width: 40%;
}
.info-table td {
color: #6c757d;
}
.info-table tr:last-child td {
border-bottom: none;
}
/* Breakdown table with emphasis */
.breakdown-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
background-color: #ffffff;
border: 1px solid #dee2e6;
border-radius: 6px;
overflow: hidden;
}
.breakdown-table td {
padding: 12px 15px;
border-bottom: 1px solid #e9ecef;
}
.breakdown-table tr:last-child td {
border-bottom: none;
}
.breakdown-label {
color: #6c757d;
font-size: 14px;
}
.breakdown-amount {
color: #495057;
font-weight: 600;
text-align: right;
}
.breakdown-earnings {
background-color: #d4edda;
font-weight: 700;
}
.breakdown-earnings .breakdown-label {
color: #155724;
font-size: 16px;
}
.breakdown-earnings .breakdown-amount {
color: #155724;
font-size: 18px;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.earnings-amount {
font-size: 36px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
.info-table th,
.info-table td {
padding: 10px;
font-size: 14px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Earnings Received</div>
</div>
<div class="content">
<p>Hi {{ownerName}},</p>
<p>Great news! Your earnings have been transferred to your account.</p>
<div class="earnings-display">
<div class="earnings-label">You Earned</div>
<div class="earnings-amount">${{payoutAmount}}</div>
<div class="earnings-subtitle">
From rental of {{itemName}}
</div>
</div>
<h2>Rental Details</h2>
<table class="info-table">
<tr>
<th>Item Rented</th>
<td>{{itemName}}</td>
</tr>
<tr>
<th>Rental Period</th>
<td>{{startDate}} to {{endDate}}</td>
</tr>
<tr>
<th>Transfer ID</th>
<td>{{stripeTransferId}}</td>
</tr>
</table>
<h2>Earnings Breakdown</h2>
<table class="breakdown-table">
<tr>
<td class="breakdown-label">Rental Amount (charged to renter)</td>
<td class="breakdown-amount">${{totalAmount}}</td>
</tr>
<tr>
<td class="breakdown-label">Community Upkeep Fee (10%)</td>
<td class="breakdown-amount">-${{platformFee}}</td>
</tr>
<tr class="breakdown-earnings">
<td class="breakdown-label">Your Earnings</td>
<td class="breakdown-amount">${{payoutAmount}}</td>
</tr>
</table>
<h2>Payout Timeline</h2>
<table class="info-table">
<tr>
<th style="color: #28a745;">✓ Rental Completed</th>
<td>Done</td>
</tr>
<tr>
<th style="color: #28a745;">✓ Transfer Initiated</th>
<td>Today</td>
</tr>
<tr>
<th style="color: #0066cc;">○ Funds in Your Bank</th>
<td>2-7 business days</td>
</tr>
</table>
<div class="info-box">
<p><strong>When will I receive the funds?</strong></p>
<p>
Funds are typically available in your bank account within
<strong>2-7 business days</strong> from the transfer date, depending on your bank and Stripe's payout schedule.
</p>
<p>
You can track this transfer in your Stripe Dashboard using the
Transfer ID above.
</p>
</div>
<div style="text-align: center">
<a href="{{earningsDashboardUrl}}" class="button"
>View Earnings Dashboard</a
>
</div>
<p>
Thank you for being a valued member of the Village Share community! Keep
sharing your items to earn more.
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a notification about your earnings. You received this message
because a payout was successfully processed for your rental.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,80 @@
/**
* Local test script for the Payout Retry Processor Lambda.
*
* Usage:
* cd lambdas/payoutRetryProcessor
* npm run local
*/
// Import the handler
const { handler } = require("./index");
async function runTest() {
console.log("===========================================");
console.log(" Payout Retry Processor - Local Test");
console.log("===========================================\n");
console.log("Environment:");
console.log(` DATABASE_URL: ${process.env.DATABASE_URL ? "***configured***" : "NOT SET"}`);
console.log(` STRIPE_SECRET_KEY: ${process.env.STRIPE_SECRET_KEY?.startsWith("sk_test") ? "test mode" : process.env.STRIPE_SECRET_KEY ? "LIVE MODE!" : "NOT SET"}`);
console.log(` EMAIL_ENABLED: ${process.env.EMAIL_ENABLED}`);
console.log("");
// Warn if using live Stripe key
if (process.env.STRIPE_SECRET_KEY && !process.env.STRIPE_SECRET_KEY.startsWith("sk_test")) {
console.log("\n⚠ WARNING: You are using a LIVE Stripe key!");
console.log(" Real transfers will be created. Press Ctrl+C to abort.\n");
await new Promise((resolve) => setTimeout(resolve, 3000));
}
// Simulate EventBridge event (empty for scheduled trigger)
const event = {
source: "local-test",
time: new Date().toISOString(),
};
console.log("Invoking Lambda handler...\n");
try {
const result = await handler(event);
console.log("\n===========================================");
console.log(" Result");
console.log("===========================================");
console.log(`Status Code: ${result.statusCode}`);
console.log("");
const body = JSON.parse(result.body);
console.log(`Total Processed: ${body.totalProcessed || 0}`);
console.log(`Successful: ${body.successfulCount || 0}`);
console.log(`Failed: ${body.failedCount || 0}`);
if (body.successful && body.successful.length > 0) {
console.log("\nSuccessful Payouts:");
body.successful.forEach((p) => {
console.log(` - Rental ${p.rentalId}: $${p.amount} (Transfer: ${p.transferId})`);
});
}
if (body.failed && body.failed.length > 0) {
console.log("\nFailed Payouts:");
body.failed.forEach((p) => {
console.log(` - Rental ${p.rentalId}: ${p.error}`);
});
}
if (body.totalProcessed === 0) {
console.log("\nNo failed payouts found to retry.");
console.log("To create test data, run:");
console.log(` UPDATE "Rentals" SET "payoutStatus" = 'failed' WHERE id = '<rental-id>';`);
}
} catch (error) {
console.error("\nLambda execution failed:");
console.error(error);
}
console.log("\n===========================================\n");
process.exit(0);
}
runTest();

View File

@@ -6,10 +6,12 @@ const db = require("./db/connection");
const queries = require("./db/queries");
const email = require("./email/client");
const logger = require("./utils/logger");
const stripe = require("./stripe/client");
module.exports = {
db,
queries,
email,
logger,
stripe,
};

View File

@@ -10,7 +10,8 @@
"dependencies": {
"@aws-sdk/client-scheduler": "^3.896.0",
"@aws-sdk/client-ses": "^3.896.0",
"pg": "^8.16.3"
"pg": "^8.16.3",
"stripe": "^17.7.0"
},
"devDependencies": {
"jest": "^30.1.3"
@@ -2431,7 +2432,6 @@
"version": "25.0.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz",
"integrity": "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
@@ -3002,6 +3002,35 @@
"dev": true,
"license": "MIT"
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -3291,6 +3320,20 @@
"node": ">=8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -3335,6 +3378,36 @@
"is-arrayish": "^0.2.1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -3529,6 +3602,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -3549,6 +3631,30 @@
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
@@ -3559,6 +3665,19 @@
"node": ">=8.0.0"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@@ -3593,6 +3712,18 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -3610,6 +3741,30 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -4572,6 +4727,15 @@
"tmpl": "1.0.5"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -4696,6 +4860,18 @@
"node": ">=8"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -5073,6 +5249,21 @@
],
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -5146,6 +5337,78 @@
"node": ">=8"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -5393,6 +5656,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "17.7.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-17.7.0.tgz",
"integrity": "sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==",
"license": "MIT",
"dependencies": {
"@types/node": ">=8.1.0",
"qs": "^6.11.0"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/strnum": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
@@ -5548,7 +5824,6 @@
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {

View File

@@ -6,7 +6,8 @@
"dependencies": {
"@aws-sdk/client-ses": "^3.896.0",
"@aws-sdk/client-scheduler": "^3.896.0",
"pg": "^8.16.3"
"pg": "^8.16.3",
"stripe": "^17.7.0"
},
"devDependencies": {
"jest": "^30.1.3"

View File

@@ -0,0 +1,80 @@
const Stripe = require("stripe");
let stripeClient = null;
/**
* Get or create a Stripe client.
* Reuses client across Lambda invocations for better performance.
*/
function getStripeClient() {
if (!stripeClient) {
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
throw new Error("STRIPE_SECRET_KEY environment variable is required");
}
stripeClient = new Stripe(secretKey);
}
return stripeClient;
}
/**
* Create a Stripe transfer to a connected account.
* @param {Object} params - Transfer parameters
* @param {number} params.amount - Amount in dollars (will be converted to cents)
* @param {string} params.destination - Stripe connected account ID
* @param {string} [params.currency='usd'] - Currency code
* @param {Object} [params.metadata={}] - Additional metadata for the transfer
* @returns {Promise<Object>} Stripe transfer object
*/
async function createTransfer({ amount, destination, currency = "usd", metadata = {} }) {
const stripe = getStripeClient();
// 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;
}
/**
* Check if an 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
*/
function 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));
}
module.exports = {
getStripeClient,
createTransfer,
isAccountDisconnectedError,
};