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();