payout retry lambda
This commit is contained in:
@@ -32,7 +32,6 @@ const feedbackRoutes = require("./routes/feedback");
|
||||
const uploadRoutes = require("./routes/upload");
|
||||
const healthRoutes = require("./routes/health");
|
||||
|
||||
const PayoutProcessor = require("./jobs/payoutProcessor");
|
||||
const emailServices = require("./services/email");
|
||||
const s3Service = require("./services/s3Service");
|
||||
|
||||
@@ -228,10 +227,6 @@ sequelize
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Start the payout processor
|
||||
const payoutJobs = PayoutProcessor.startScheduledPayouts();
|
||||
logger.info("Payout processor started");
|
||||
|
||||
server.listen(PORT, () => {
|
||||
logger.info(`Server is running on port ${PORT}`, {
|
||||
port: PORT,
|
||||
|
||||
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();
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
281
lambdas/shared/package-lock.json
generated
281
lambdas/shared/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
80
lambdas/shared/stripe/client.js
Normal file
80
lambdas/shared/stripe/client.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user