payout retry lambda
This commit is contained in:
107
lambdas/payoutRetryProcessor/README.md
Normal file
107
lambdas/payoutRetryProcessor/README.md
Normal 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)
|
||||
```
|
||||
193
lambdas/payoutRetryProcessor/handler.js
Normal file
193
lambdas/payoutRetryProcessor/handler.js
Normal 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,
|
||||
};
|
||||
46
lambdas/payoutRetryProcessor/index.js
Normal file
46
lambdas/payoutRetryProcessor/index.js
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
48
lambdas/payoutRetryProcessor/package-lock.json
generated
Normal file
48
lambdas/payoutRetryProcessor/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
lambdas/payoutRetryProcessor/package.json
Normal file
16
lambdas/payoutRetryProcessor/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
152
lambdas/payoutRetryProcessor/queries.js
Normal file
152
lambdas/payoutRetryProcessor/queries.js
Normal 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,
|
||||
};
|
||||
@@ -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>© 2025 Village Share. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
80
lambdas/payoutRetryProcessor/test-local.js
Normal file
80
lambdas/payoutRetryProcessor/test-local.js
Normal 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();
|
||||
Reference in New Issue
Block a user