From 7f2f45b1c2d53e39e8f0789e82a3f169f6b2bfd8 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:05:41 -0500 Subject: [PATCH] payout retry lambda --- backend/server.js | 5 - lambdas/payoutRetryProcessor/README.md | 107 +++++ lambdas/payoutRetryProcessor/handler.js | 193 ++++++++ lambdas/payoutRetryProcessor/index.js | 46 ++ .../payoutRetryProcessor/package-lock.json | 48 ++ lambdas/payoutRetryProcessor/package.json | 16 + lambdas/payoutRetryProcessor/queries.js | 152 ++++++ .../templates/payoutReceivedToOwner.html | 435 ++++++++++++++++++ lambdas/payoutRetryProcessor/test-local.js | 80 ++++ lambdas/shared/index.js | 2 + lambdas/shared/package-lock.json | 281 ++++++++++- lambdas/shared/package.json | 3 +- lambdas/shared/stripe/client.js | 80 ++++ 13 files changed, 1439 insertions(+), 9 deletions(-) create mode 100644 lambdas/payoutRetryProcessor/README.md create mode 100644 lambdas/payoutRetryProcessor/handler.js create mode 100644 lambdas/payoutRetryProcessor/index.js create mode 100644 lambdas/payoutRetryProcessor/package-lock.json create mode 100644 lambdas/payoutRetryProcessor/package.json create mode 100644 lambdas/payoutRetryProcessor/queries.js create mode 100644 lambdas/payoutRetryProcessor/templates/payoutReceivedToOwner.html create mode 100644 lambdas/payoutRetryProcessor/test-local.js create mode 100644 lambdas/shared/stripe/client.js diff --git a/backend/server.js b/backend/server.js index f9b674d..b19a13f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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, diff --git a/lambdas/payoutRetryProcessor/README.md b/lambdas/payoutRetryProcessor/README.md new file mode 100644 index 0000000..82f93fe --- /dev/null +++ b/lambdas/payoutRetryProcessor/README.md @@ -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 = '' + 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 = ''; + ``` +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) +``` diff --git a/lambdas/payoutRetryProcessor/handler.js b/lambdas/payoutRetryProcessor/handler.js new file mode 100644 index 0000000..f267696 --- /dev/null +++ b/lambdas/payoutRetryProcessor/handler.js @@ -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} 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, +}; diff --git a/lambdas/payoutRetryProcessor/index.js b/lambdas/payoutRetryProcessor/index.js new file mode 100644 index 0000000..7eaaf34 --- /dev/null +++ b/lambdas/payoutRetryProcessor/index.js @@ -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} 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, + }), + }; + } +}; diff --git a/lambdas/payoutRetryProcessor/package-lock.json b/lambdas/payoutRetryProcessor/package-lock.json new file mode 100644 index 0000000..0180c07 --- /dev/null +++ b/lambdas/payoutRetryProcessor/package-lock.json @@ -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" + } + } + } +} diff --git a/lambdas/payoutRetryProcessor/package.json b/lambdas/payoutRetryProcessor/package.json new file mode 100644 index 0000000..c071996 --- /dev/null +++ b/lambdas/payoutRetryProcessor/package.json @@ -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" + } +} diff --git a/lambdas/payoutRetryProcessor/queries.js b/lambdas/payoutRetryProcessor/queries.js new file mode 100644 index 0000000..d6f1c94 --- /dev/null +++ b/lambdas/payoutRetryProcessor/queries.js @@ -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 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} + */ +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} + */ +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} + */ +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} 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, +}; diff --git a/lambdas/payoutRetryProcessor/templates/payoutReceivedToOwner.html b/lambdas/payoutRetryProcessor/templates/payoutReceivedToOwner.html new file mode 100644 index 0000000..4d83dac --- /dev/null +++ b/lambdas/payoutRetryProcessor/templates/payoutReceivedToOwner.html @@ -0,0 +1,435 @@ + + + + + + + Earnings Received - Village Share + + + + + + diff --git a/lambdas/payoutRetryProcessor/test-local.js b/lambdas/payoutRetryProcessor/test-local.js new file mode 100644 index 0000000..ea05f41 --- /dev/null +++ b/lambdas/payoutRetryProcessor/test-local.js @@ -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 = '';`); + } + } catch (error) { + console.error("\nLambda execution failed:"); + console.error(error); + } + + console.log("\n===========================================\n"); + process.exit(0); +} + +runTest(); diff --git a/lambdas/shared/index.js b/lambdas/shared/index.js index 03ba782..8f32a8d 100644 --- a/lambdas/shared/index.js +++ b/lambdas/shared/index.js @@ -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, }; diff --git a/lambdas/shared/package-lock.json b/lambdas/shared/package-lock.json index 001c12f..557a69a 100644 --- a/lambdas/shared/package-lock.json +++ b/lambdas/shared/package-lock.json @@ -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": { diff --git a/lambdas/shared/package.json b/lambdas/shared/package.json index 1c7f6a2..f5f70fe 100644 --- a/lambdas/shared/package.json +++ b/lambdas/shared/package.json @@ -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" diff --git a/lambdas/shared/stripe/client.js b/lambdas/shared/stripe/client.js new file mode 100644 index 0000000..0d69a1b --- /dev/null +++ b/lambdas/shared/stripe/client.js @@ -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} 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, +};