Compare commits
3 Commits
5eb877b7c2
...
bcb917c959
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcb917c959 | ||
|
|
8b9b92d848 | ||
|
|
550de32a41 |
@@ -0,0 +1,22 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
// Add 'requires_action' to the paymentStatus enum
|
||||||
|
// This status is used when 3DS authentication is required for a payment
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TYPE "enum_Rentals_paymentStatus" ADD VALUE IF NOT EXISTS 'requires_action';
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
// Note: PostgreSQL does not support removing values from ENUMs directly.
|
||||||
|
// The 'requires_action' value will remain in the enum but can be unused.
|
||||||
|
// To fully remove it would require recreating the enum and column,
|
||||||
|
// which is complex and risky for production data.
|
||||||
|
console.log(
|
||||||
|
"Note: PostgreSQL does not support removing ENUM values. " +
|
||||||
|
"'requires_action' will remain in the enum but will not be used."
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
303
backend/migrations/README.md
Normal file
303
backend/migrations/README.md
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
# Database Migrations
|
||||||
|
|
||||||
|
This project uses Sequelize CLI for database migrations. Migrations provide version control for your database schema.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run pending migrations
|
||||||
|
npm run db:migrate
|
||||||
|
|
||||||
|
# Undo last migration
|
||||||
|
npm run db:migrate:undo
|
||||||
|
|
||||||
|
# Undo all migrations
|
||||||
|
npm run db:migrate:undo:all
|
||||||
|
|
||||||
|
# Check migration status
|
||||||
|
npm run db:migrate:status
|
||||||
|
|
||||||
|
# Test all migrations (up, down, up)
|
||||||
|
npm run test:migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Commands
|
||||||
|
|
||||||
|
### `npm run db:migrate`
|
||||||
|
|
||||||
|
**Purpose:** Runs all pending migrations that haven't been applied yet.
|
||||||
|
|
||||||
|
- Checks the `SequelizeMeta` table to see which migrations have already run
|
||||||
|
- Executes the `up` function in each pending migration file in order
|
||||||
|
- Records each successful migration in the `SequelizeMeta` table
|
||||||
|
- **When to use:** Deploy new schema changes to your database
|
||||||
|
|
||||||
|
### `npm run db:migrate:undo`
|
||||||
|
|
||||||
|
**Purpose:** Rolls back the most recent migration.
|
||||||
|
|
||||||
|
- Executes the `down` function of the last applied migration
|
||||||
|
- Removes that migration's entry from `SequelizeMeta`
|
||||||
|
- **When to use:** Quickly revert the last change if something went wrong
|
||||||
|
|
||||||
|
### `npm run db:migrate:undo:all`
|
||||||
|
|
||||||
|
**Purpose:** Rolls back ALL migrations, returning to an empty database.
|
||||||
|
|
||||||
|
- Executes the `down` function of every migration in reverse order
|
||||||
|
- Clears the `SequelizeMeta` table
|
||||||
|
- **When to use:** Reset development database to start fresh, or in testing
|
||||||
|
|
||||||
|
### `npm run db:migrate:status`
|
||||||
|
|
||||||
|
**Purpose:** Shows the status of all migrations.
|
||||||
|
|
||||||
|
- Lists which migrations have been executed (with timestamps)
|
||||||
|
- Shows which migrations are pending
|
||||||
|
- **When to use:** Check what's been applied before deploying or debugging
|
||||||
|
|
||||||
|
### `npm run db:create`
|
||||||
|
|
||||||
|
**Purpose:** Creates the database specified in your config.
|
||||||
|
|
||||||
|
- Reads `DB_NAME` from environment variables
|
||||||
|
- Creates a new PostgreSQL database with that name
|
||||||
|
- **When to use:** Initial setup on a new environment
|
||||||
|
|
||||||
|
### `npm run test:migrations`
|
||||||
|
|
||||||
|
**Purpose:** Automated testing of migrations (to be implemented in Phase 4).
|
||||||
|
|
||||||
|
- Will create a fresh test database
|
||||||
|
- Run all migrations up, then down, then up again
|
||||||
|
- Verify migrations are reversible and idempotent
|
||||||
|
- **When to use:** In CI/CD pipeline before merging migration changes
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
All commands use the environment specified by `NODE_ENV` (dev, test, qa, prod) and load the corresponding `.env` file automatically.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run migrations in development
|
||||||
|
NODE_ENV=dev npm run db:migrate
|
||||||
|
|
||||||
|
# Check status in QA environment
|
||||||
|
NODE_ENV=qa npm run db:migrate:status
|
||||||
|
|
||||||
|
# Run migrations in production
|
||||||
|
NODE_ENV=prod npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a New Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a new migration file
|
||||||
|
npx sequelize-cli migration:generate --name description-of-change
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a timestamped file in `backend/migrations/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
20241125123456-description-of-change.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration File Structure
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
// Schema changes to apply
|
||||||
|
},
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
// How to revert the changes
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
Use descriptive names that indicate the action:
|
||||||
|
|
||||||
|
- `create-users` - Creating a new table
|
||||||
|
- `add-email-to-users` - Adding a column
|
||||||
|
- `remove-legacy-field-from-items` - Removing a column
|
||||||
|
- `add-index-on-users-email` - Adding an index
|
||||||
|
- `change-status-enum-in-rentals` - Modifying a column
|
||||||
|
|
||||||
|
## Zero-Downtime Patterns
|
||||||
|
|
||||||
|
### Adding a Column (Safe)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn("users", "newField", {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true, // Must be nullable or have default
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a NOT NULL Column
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Step 1: Add as nullable
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn("users", "newField", {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: Backfill data (separate migration)
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
`UPDATE users SET "newField" = 'default_value' WHERE "newField" IS NULL`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 3: Add NOT NULL constraint (separate migration, after code deployed)
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.changeColumn("users", "newField", {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Removing a Column (3-Step Process)
|
||||||
|
|
||||||
|
1. **Deploy 1**: Update code to stop reading/writing the column
|
||||||
|
2. **Deploy 2**: Run migration to remove column
|
||||||
|
3. **Deploy 3**: Remove column references from model (cleanup)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Migration in Deploy 2
|
||||||
|
up: async (queryInterface) => {
|
||||||
|
await queryInterface.removeColumn('users', 'oldField');
|
||||||
|
},
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn('users', 'oldField', {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Renaming a Column (3-Step Process)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Deploy 1: Add new column, copy data
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn("users", "newName", {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
});
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
'UPDATE users SET "newName" = "oldName"'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deploy 2: Update code to use newName, keep oldName as fallback
|
||||||
|
// (no migration)
|
||||||
|
|
||||||
|
// Deploy 3: Remove old column
|
||||||
|
up: async (queryInterface) => {
|
||||||
|
await queryInterface.removeColumn("users", "oldName");
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating Indexes (Use CONCURRENTLY)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
up: async (queryInterface) => {
|
||||||
|
await queryInterface.addIndex("users", ["email"], {
|
||||||
|
unique: true,
|
||||||
|
concurrently: true, // Prevents table locking
|
||||||
|
name: "users_email_unique",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
Before committing a migration:
|
||||||
|
|
||||||
|
- [ ] Migration has both `up` and `down` functions
|
||||||
|
- [ ] Tested locally: `npm run db:migrate`
|
||||||
|
- [ ] Tested rollback: `npm run db:migrate:undo`
|
||||||
|
- [ ] Tested re-apply: `npm run db:migrate`
|
||||||
|
- [ ] Full test: `npm run test:migrations`
|
||||||
|
- [ ] Application starts and works correctly
|
||||||
|
- [ ] No data loss in `down` migration (where possible)
|
||||||
|
|
||||||
|
## Deployment Checklist
|
||||||
|
|
||||||
|
Before deploying to production:
|
||||||
|
|
||||||
|
- [ ] Backup production database
|
||||||
|
- [ ] Test migration on copy of production data
|
||||||
|
- [ ] Review migration for safety (no destructive operations)
|
||||||
|
- [ ] Schedule during low-traffic window
|
||||||
|
- [ ] Have rollback plan ready
|
||||||
|
- [ ] Monitor logs after deployment
|
||||||
|
|
||||||
|
## Rollback Procedures
|
||||||
|
|
||||||
|
### Undo Last Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:migrate:undo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Undo Multiple Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Undo last 3 migrations
|
||||||
|
npx sequelize-cli db:migrate:undo --step 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Undo to Specific Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx sequelize-cli db:migrate:undo:all --to 20241124000005-create-rentals.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "Migration file not found"
|
||||||
|
|
||||||
|
Ensure the migration filename in `SequelizeMeta` matches the file on disk.
|
||||||
|
|
||||||
|
### "Column already exists"
|
||||||
|
|
||||||
|
The migration may have partially run. Check the schema and either:
|
||||||
|
|
||||||
|
- Manually fix the schema
|
||||||
|
- Mark migration as complete: `INSERT INTO "SequelizeMeta" VALUES ('filename.js')`
|
||||||
|
|
||||||
|
### "Cannot drop column - dependent objects"
|
||||||
|
|
||||||
|
Drop dependent indexes/constraints first:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await queryInterface.removeIndex("users", "index_name");
|
||||||
|
await queryInterface.removeColumn("users", "column_name");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Foreign Key Constraint Failures
|
||||||
|
|
||||||
|
Ensure data integrity before adding constraints:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Clean orphaned records first
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
'DELETE FROM rentals WHERE "itemId" NOT IN (SELECT id FROM items)'
|
||||||
|
);
|
||||||
|
// Then add constraint
|
||||||
|
await queryInterface.addConstraint("rentals", {
|
||||||
|
fields: ["itemId"],
|
||||||
|
type: "foreign key",
|
||||||
|
references: { table: "items", field: "id" },
|
||||||
|
});
|
||||||
|
```
|
||||||
@@ -67,7 +67,7 @@ const Rental = sequelize.define("Rental", {
|
|||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
paymentStatus: {
|
paymentStatus: {
|
||||||
type: DataTypes.ENUM("pending", "paid", "refunded", "not_required"),
|
type: DataTypes.ENUM("pending", "paid", "refunded", "not_required", "requires_action"),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
payoutStatus: {
|
payoutStatus: {
|
||||||
|
|||||||
@@ -493,6 +493,51 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check if 3DS authentication is required
|
||||||
|
if (paymentResult.requiresAction) {
|
||||||
|
// Store payment intent for later completion
|
||||||
|
await rental.update({
|
||||||
|
stripePaymentIntentId: paymentResult.paymentIntentId,
|
||||||
|
paymentStatus: "requires_action",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email to renter (without direct link for security)
|
||||||
|
try {
|
||||||
|
await emailServices.rentalFlow.sendAuthenticationRequiredEmail(
|
||||||
|
rental.renter.email,
|
||||||
|
{
|
||||||
|
renterName: rental.renter.firstName,
|
||||||
|
itemName: rental.item.name,
|
||||||
|
ownerName: rental.owner.firstName,
|
||||||
|
amount: rental.totalAmount,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Authentication required email sent to renter", {
|
||||||
|
rentalId: rental.id,
|
||||||
|
renterId: rental.renterId,
|
||||||
|
});
|
||||||
|
} catch (emailError) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error(
|
||||||
|
"Failed to send authentication required email",
|
||||||
|
{
|
||||||
|
error: emailError.message,
|
||||||
|
stack: emailError.stack,
|
||||||
|
rentalId: rental.id,
|
||||||
|
renterId: rental.renterId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(402).json({
|
||||||
|
error: "authentication_required",
|
||||||
|
requiresAction: true,
|
||||||
|
message: "The renter's card requires additional authentication.",
|
||||||
|
rentalId: rental.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Update rental with payment completion
|
// Update rental with payment completion
|
||||||
await rental.update({
|
await rental.update({
|
||||||
status: "confirmed",
|
status: "confirmed",
|
||||||
@@ -1652,4 +1697,223 @@ router.put("/:id/payment-method", authenticateToken, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /rentals/:id/payment-client-secret
|
||||||
|
* Returns client secret for 3DS completion (renter only)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/:id/payment-client-secret",
|
||||||
|
authenticateToken,
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rental = await Rental.findByPk(req.params.id, {
|
||||||
|
include: [
|
||||||
|
{ model: User, as: "renter", attributes: ["id", "stripeCustomerId"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rental) {
|
||||||
|
return res.status(404).json({ error: "Rental not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rental.renterId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rental.stripePaymentIntentId) {
|
||||||
|
return res.status(400).json({ error: "No payment intent found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
||||||
|
const paymentIntent = await stripe.paymentIntents.retrieve(
|
||||||
|
rental.stripePaymentIntentId
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
clientSecret: paymentIntent.client_secret,
|
||||||
|
status: paymentIntent.status,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Get client secret error", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
rentalId: req.params.id,
|
||||||
|
userId: req.user.id,
|
||||||
|
});
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /rentals/:id/complete-payment
|
||||||
|
* Called after renter completes 3DS authentication
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/:id/complete-payment",
|
||||||
|
authenticateToken,
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rental = await Rental.findByPk(req.params.id, {
|
||||||
|
include: [
|
||||||
|
{ model: User, as: "renter", attributes: ["id", "firstName", "lastName", "email", "stripeCustomerId"] },
|
||||||
|
{ model: User, as: "owner", attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId", "stripePayoutsEnabled"] },
|
||||||
|
{ model: Item, as: "item" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rental) {
|
||||||
|
return res.status(404).json({ error: "Rental not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rental.renterId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rental.paymentStatus !== "requires_action") {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid state",
|
||||||
|
message: "This rental is not awaiting payment authentication",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve payment intent to check status (expand latest_charge for payment method details)
|
||||||
|
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
||||||
|
const paymentIntent = await stripe.paymentIntents.retrieve(
|
||||||
|
rental.stripePaymentIntentId,
|
||||||
|
{ expand: ['latest_charge.payment_method_details'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (paymentIntent.status !== "succeeded") {
|
||||||
|
return res.status(402).json({
|
||||||
|
error: "payment_incomplete",
|
||||||
|
status: paymentIntent.status,
|
||||||
|
message:
|
||||||
|
paymentIntent.status === "requires_action"
|
||||||
|
? "Authentication not yet completed"
|
||||||
|
: "Payment could not be completed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract payment method details from latest_charge (charges is deprecated)
|
||||||
|
const charge = paymentIntent.latest_charge;
|
||||||
|
const paymentMethodDetails = charge?.payment_method_details;
|
||||||
|
|
||||||
|
let paymentMethodBrand = null;
|
||||||
|
let paymentMethodLast4 = null;
|
||||||
|
if (paymentMethodDetails) {
|
||||||
|
const type = paymentMethodDetails.type;
|
||||||
|
if (type === "card") {
|
||||||
|
paymentMethodBrand = paymentMethodDetails.card?.brand || "card";
|
||||||
|
paymentMethodLast4 = paymentMethodDetails.card?.last4 || null;
|
||||||
|
} else if (type === "us_bank_account") {
|
||||||
|
paymentMethodBrand = "bank_account";
|
||||||
|
paymentMethodLast4 = paymentMethodDetails.us_bank_account?.last4 || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment succeeded - complete rental confirmation
|
||||||
|
await rental.update({
|
||||||
|
status: "confirmed",
|
||||||
|
paymentStatus: "paid",
|
||||||
|
chargedAt: new Date(),
|
||||||
|
paymentMethodBrand,
|
||||||
|
paymentMethodLast4,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send confirmation emails
|
||||||
|
try {
|
||||||
|
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
|
||||||
|
rental.owner,
|
||||||
|
rental.renter,
|
||||||
|
rental
|
||||||
|
);
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Rental approval confirmation sent to owner (after 3DS)", {
|
||||||
|
rentalId: rental.id,
|
||||||
|
ownerId: rental.ownerId,
|
||||||
|
});
|
||||||
|
} catch (emailError) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error(
|
||||||
|
"Failed to send rental approval confirmation email after 3DS",
|
||||||
|
{
|
||||||
|
error: emailError.message,
|
||||||
|
rentalId: rental.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const renterNotification = {
|
||||||
|
type: "rental_confirmed",
|
||||||
|
title: "Rental Confirmed",
|
||||||
|
message: `Your rental of "${rental.item.name}" has been confirmed.`,
|
||||||
|
rentalId: rental.id,
|
||||||
|
userId: rental.renterId,
|
||||||
|
metadata: { rentalStart: rental.startDateTime },
|
||||||
|
};
|
||||||
|
await emailServices.rentalFlow.sendRentalConfirmation(
|
||||||
|
rental.renter.email,
|
||||||
|
renterNotification,
|
||||||
|
rental,
|
||||||
|
rental.renter.firstName,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Rental confirmation sent to renter (after 3DS)", {
|
||||||
|
rentalId: rental.id,
|
||||||
|
renterId: rental.renterId,
|
||||||
|
});
|
||||||
|
} catch (emailError) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error(
|
||||||
|
"Failed to send rental confirmation email after 3DS",
|
||||||
|
{
|
||||||
|
error: emailError.message,
|
||||||
|
rentalId: rental.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger payout if owner has payouts enabled
|
||||||
|
if (rental.owner.stripePayoutsEnabled && rental.owner.stripeConnectedAccountId) {
|
||||||
|
try {
|
||||||
|
await PayoutService.processRentalPayout(rental);
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Payout processed after 3DS completion", {
|
||||||
|
rentalId: rental.id,
|
||||||
|
ownerId: rental.ownerId,
|
||||||
|
});
|
||||||
|
} catch (payoutError) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Payout failed after 3DS completion", {
|
||||||
|
error: payoutError.message,
|
||||||
|
rentalId: rental.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
rental: {
|
||||||
|
id: rental.id,
|
||||||
|
status: "confirmed",
|
||||||
|
paymentStatus: "paid",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Complete payment error", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
rentalId: req.params.id,
|
||||||
|
userId: req.user.id,
|
||||||
|
});
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ class RentalFlowEmailService {
|
|||||||
|
|
||||||
const variables = {
|
const variables = {
|
||||||
ownerName: owner.firstName,
|
ownerName: owner.firstName,
|
||||||
renterName: `${renter.firstName} ${renter.lastName}`.trim() || "A renter",
|
renterName:
|
||||||
|
`${renter.firstName} ${renter.lastName}`.trim() || "A renter",
|
||||||
itemName: rental.item?.name || "your item",
|
itemName: rental.item?.name || "your item",
|
||||||
startDate: rental.startDateTime
|
startDate: rental.startDateTime
|
||||||
? new Date(rental.startDateTime).toLocaleString("en-US", {
|
? new Date(rental.startDateTime).toLocaleString("en-US", {
|
||||||
@@ -227,15 +228,15 @@ class RentalFlowEmailService {
|
|||||||
<table class="info-table">
|
<table class="info-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Total Rental Amount</th>
|
<th>Total Rental Amount</th>
|
||||||
<td>\\$${totalAmount.toFixed(2)}</td>
|
<td>$${totalAmount.toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Community Upkeep Fee (10%)</th>
|
<th>Community Upkeep Fee (10%)</th>
|
||||||
<td>-\\$${platformFee.toFixed(2)}</td>
|
<td>-$${platformFee.toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Your Payout</th>
|
<th>Your Payout</th>
|
||||||
<td class="highlight">\\$${payoutAmount.toFixed(2)}</td>
|
<td class="highlight">$${payoutAmount.toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
`;
|
`;
|
||||||
@@ -248,7 +249,7 @@ class RentalFlowEmailService {
|
|||||||
stripeSection = `
|
stripeSection = `
|
||||||
<div class="warning-box">
|
<div class="warning-box">
|
||||||
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
|
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
|
||||||
<p>To receive your payout of <strong>\\$${payoutAmount.toFixed(
|
<p>To receive your payout of <strong>$${payoutAmount.toFixed(
|
||||||
2
|
2
|
||||||
)}</strong> when this rental completes, you need to set up your earnings account.</p>
|
)}</strong> when this rental completes, you need to set up your earnings account.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,7 +275,7 @@ class RentalFlowEmailService {
|
|||||||
stripeSection = `
|
stripeSection = `
|
||||||
<div class="success-box">
|
<div class="success-box">
|
||||||
<p><strong>✓ Earnings Account Active</strong></p>
|
<p><strong>✓ Earnings Account Active</strong></p>
|
||||||
<p>Your earnings account is set up. You'll automatically receive \\$${payoutAmount.toFixed(
|
<p>Your earnings account is set up. You'll automatically receive $${payoutAmount.toFixed(
|
||||||
2
|
2
|
||||||
)} when this rental completes.</p>
|
)} when this rental completes.</p>
|
||||||
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
|
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
|
||||||
@@ -323,7 +324,10 @@ class RentalFlowEmailService {
|
|||||||
htmlContent
|
htmlContent
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send rental approval confirmation email:", error);
|
console.error(
|
||||||
|
"Failed to send rental approval confirmation email:",
|
||||||
|
error
|
||||||
|
);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1002,7 +1006,7 @@ class RentalFlowEmailService {
|
|||||||
error: emailError.message,
|
error: emailError.message,
|
||||||
stack: emailError.stack,
|
stack: emailError.stack,
|
||||||
renterEmail: renter.email,
|
renterEmail: renter.email,
|
||||||
rentalId: rental.id
|
rentalId: rental.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,15 +1025,15 @@ class RentalFlowEmailService {
|
|||||||
<table class="info-table">
|
<table class="info-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Total Rental Amount</th>
|
<th>Total Rental Amount</th>
|
||||||
<td>\\$${totalAmount.toFixed(2)}</td>
|
<td>$${totalAmount.toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Community Upkeep Fee (10%)</th>
|
<th>Community Upkeep Fee (10%)</th>
|
||||||
<td>-\\$${platformFee.toFixed(2)}</td>
|
<td>-$${platformFee.toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Your Payout</th>
|
<th>Your Payout</th>
|
||||||
<td class="highlight">\\$${payoutAmount.toFixed(2)}</td>
|
<td class="highlight">$${payoutAmount.toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p style="font-size: 14px; color: #6c757d;">
|
<p style="font-size: 14px; color: #6c757d;">
|
||||||
@@ -1045,7 +1049,7 @@ class RentalFlowEmailService {
|
|||||||
stripeSection = `
|
stripeSection = `
|
||||||
<div class="warning-box">
|
<div class="warning-box">
|
||||||
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
|
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
|
||||||
<p>To receive your payout of <strong>\\$${payoutAmount.toFixed(
|
<p>To receive your payout of <strong>$${payoutAmount.toFixed(
|
||||||
2
|
2
|
||||||
)}</strong>, you need to set up your earnings account.</p>
|
)}</strong>, you need to set up your earnings account.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1071,7 +1075,7 @@ class RentalFlowEmailService {
|
|||||||
stripeSection = `
|
stripeSection = `
|
||||||
<div class="success-box">
|
<div class="success-box">
|
||||||
<p><strong>✓ Payout Initiated</strong></p>
|
<p><strong>✓ Payout Initiated</strong></p>
|
||||||
<p>Your earnings of <strong>\\$${payoutAmount.toFixed(
|
<p>Your earnings of <strong>$${payoutAmount.toFixed(
|
||||||
2
|
2
|
||||||
)}</strong> have been transferred to your Stripe account.</p>
|
)}</strong> have been transferred to your Stripe account.</p>
|
||||||
<p style="font-size: 14px; margin-top: 10px;">Funds typically reach your bank within 2-7 business days.</p>
|
<p style="font-size: 14px; margin-top: 10px;">Funds typically reach your bank within 2-7 business days.</p>
|
||||||
@@ -1122,14 +1126,14 @@ class RentalFlowEmailService {
|
|||||||
error: emailError.message,
|
error: emailError.message,
|
||||||
stack: emailError.stack,
|
stack: emailError.stack,
|
||||||
ownerEmail: owner.email,
|
ownerEmail: owner.email,
|
||||||
rentalId: rental.id
|
rentalId: rental.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error sending rental completion emails", {
|
logger.error("Error sending rental completion emails", {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
rentalId: rental?.id
|
rentalId: rental?.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1205,6 +1209,50 @@ class RentalFlowEmailService {
|
|||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send authentication required email to renter when 3DS verification is needed
|
||||||
|
* This is sent when the owner approves a rental but the renter's bank requires
|
||||||
|
* additional verification (3D Secure) to complete the payment.
|
||||||
|
*
|
||||||
|
* @param {string} email - Renter's email address
|
||||||
|
* @param {Object} data - Email data
|
||||||
|
* @param {string} data.renterName - Renter's first name
|
||||||
|
* @param {string} data.itemName - Name of the item being rented
|
||||||
|
* @param {string} data.ownerName - Owner's first name
|
||||||
|
* @param {number} data.amount - Total rental amount
|
||||||
|
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||||
|
*/
|
||||||
|
async sendAuthenticationRequiredEmail(email, data) {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { renterName, itemName, ownerName, amount } = data;
|
||||||
|
|
||||||
|
const variables = {
|
||||||
|
renterName: renterName || "there",
|
||||||
|
itemName: itemName || "the item",
|
||||||
|
ownerName: ownerName || "The owner",
|
||||||
|
amount: typeof amount === "number" ? amount.toFixed(2) : "0.00",
|
||||||
|
};
|
||||||
|
|
||||||
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
|
"authenticationRequiredToRenter",
|
||||||
|
variables
|
||||||
|
);
|
||||||
|
|
||||||
|
return await this.emailClient.sendEmail(
|
||||||
|
email,
|
||||||
|
`Action Required: Complete payment for ${itemName}`,
|
||||||
|
htmlContent
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send authentication required email:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = RentalFlowEmailService;
|
module.exports = RentalFlowEmailService;
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ const logger = require("../utils/logger");
|
|||||||
const { parseStripeError, PaymentError } = require("../utils/stripeErrors");
|
const { parseStripeError, PaymentError } = require("../utils/stripeErrors");
|
||||||
|
|
||||||
class StripeService {
|
class StripeService {
|
||||||
|
|
||||||
static async getCheckoutSession(sessionId) {
|
static async getCheckoutSession(sessionId) {
|
||||||
try {
|
try {
|
||||||
return await stripe.checkout.sessions.retrieve(sessionId, {
|
return await stripe.checkout.sessions.retrieve(sessionId, {
|
||||||
expand: ['setup_intent', 'setup_intent.payment_method']
|
expand: ["setup_intent", "setup_intent.payment_method"],
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error retrieving checkout session", { error: error.message, stack: error.stack });
|
logger.error("Error retrieving checkout session", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,7 +30,10 @@ class StripeService {
|
|||||||
|
|
||||||
return account;
|
return account;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error creating connected account", { error: error.message, stack: error.stack });
|
logger.error("Error creating connected account", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,7 +49,10 @@ class StripeService {
|
|||||||
|
|
||||||
return accountLink;
|
return accountLink;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error creating account link", { error: error.message, stack: error.stack });
|
logger.error("Error creating account link", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +68,10 @@ class StripeService {
|
|||||||
requirements: account.requirements,
|
requirements: account.requirements,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error retrieving account status", { error: error.message, stack: error.stack });
|
logger.error("Error retrieving account status", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,7 +87,10 @@ class StripeService {
|
|||||||
|
|
||||||
return accountSession;
|
return accountSession;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error creating account session", { error: error.message, stack: error.stack });
|
logger.error("Error creating account session", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,7 +111,10 @@ class StripeService {
|
|||||||
|
|
||||||
return transfer;
|
return transfer;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error creating transfer", { error: error.message, stack: error.stack });
|
logger.error("Error creating transfer", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +135,10 @@ class StripeService {
|
|||||||
|
|
||||||
return refund;
|
return refund;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error creating refund", { error: error.message, stack: error.stack });
|
logger.error("Error creating refund", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,12 +147,20 @@ class StripeService {
|
|||||||
try {
|
try {
|
||||||
return await stripe.refunds.retrieve(refundId);
|
return await stripe.refunds.retrieve(refundId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error retrieving refund", { error: error.message, stack: error.stack });
|
logger.error("Error retrieving refund", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async chargePaymentMethod(paymentMethodId, amount, customerId, metadata = {}) {
|
static async chargePaymentMethod(
|
||||||
|
paymentMethodId,
|
||||||
|
amount,
|
||||||
|
customerId,
|
||||||
|
metadata = {}
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
// Create a payment intent with the stored payment method
|
// Create a payment intent with the stored payment method
|
||||||
const paymentIntent = await stripe.paymentIntents.create({
|
const paymentIntent = await stripe.paymentIntents.create({
|
||||||
@@ -142,49 +170,71 @@ class StripeService {
|
|||||||
customer: customerId, // Include customer ID
|
customer: customerId, // Include customer ID
|
||||||
confirm: true, // Automatically confirm the payment
|
confirm: true, // Automatically confirm the payment
|
||||||
off_session: true, // Indicate this is an off-session payment
|
off_session: true, // Indicate this is an off-session payment
|
||||||
return_url: `${process.env.FRONTEND_URL || 'http://localhost:3000'}/payment-complete`,
|
return_url: `${
|
||||||
|
process.env.FRONTEND_URL || "http://localhost:3000"
|
||||||
|
}/complete-payment`,
|
||||||
metadata,
|
metadata,
|
||||||
expand: ['charges.data.payment_method_details'], // Expand to get payment method details
|
expand: ["latest_charge.payment_method_details"], // Expand to get payment method details
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract payment method details from charges
|
// Check if additional authentication is required
|
||||||
const charge = paymentIntent.charges?.data?.[0];
|
if (paymentIntent.status === "requires_action") {
|
||||||
|
return {
|
||||||
|
status: "requires_action",
|
||||||
|
requiresAction: true,
|
||||||
|
paymentIntentId: paymentIntent.id,
|
||||||
|
clientSecret: paymentIntent.client_secret,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract payment method details from latest_charge
|
||||||
|
const charge = paymentIntent.latest_charge;
|
||||||
const paymentMethodDetails = charge?.payment_method_details;
|
const paymentMethodDetails = charge?.payment_method_details;
|
||||||
|
|
||||||
// Build payment method info object
|
// Build payment method info object
|
||||||
let paymentMethod = null;
|
let paymentMethod = null;
|
||||||
if (paymentMethodDetails) {
|
if (paymentMethodDetails) {
|
||||||
const type = paymentMethodDetails.type;
|
const type = paymentMethodDetails.type;
|
||||||
if (type === 'card') {
|
if (type === "card") {
|
||||||
paymentMethod = {
|
paymentMethod = {
|
||||||
type: 'card',
|
type: "card",
|
||||||
brand: paymentMethodDetails.card?.brand || 'card',
|
brand: paymentMethodDetails.card?.brand || "card",
|
||||||
last4: paymentMethodDetails.card?.last4 || '****',
|
last4: paymentMethodDetails.card?.last4 || "****",
|
||||||
};
|
};
|
||||||
} else if (type === 'us_bank_account') {
|
} else if (type === "us_bank_account") {
|
||||||
paymentMethod = {
|
paymentMethod = {
|
||||||
type: 'bank',
|
type: "bank",
|
||||||
brand: 'bank_account',
|
brand: "bank_account",
|
||||||
last4: paymentMethodDetails.us_bank_account?.last4 || '****',
|
last4: paymentMethodDetails.us_bank_account?.last4 || "****",
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
paymentMethod = {
|
paymentMethod = {
|
||||||
type: type || 'unknown',
|
type: type || "unknown",
|
||||||
brand: type || 'payment',
|
brand: type || "payment",
|
||||||
last4: null,
|
last4: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
status: "succeeded",
|
||||||
paymentIntentId: paymentIntent.id,
|
paymentIntentId: paymentIntent.id,
|
||||||
status: paymentIntent.status,
|
|
||||||
clientSecret: paymentIntent.client_secret,
|
clientSecret: paymentIntent.client_secret,
|
||||||
paymentMethod: paymentMethod,
|
paymentMethod: paymentMethod,
|
||||||
chargedAt: new Date(paymentIntent.created * 1000), // Convert Unix timestamp to Date
|
chargedAt: new Date(paymentIntent.created * 1000), // Convert Unix timestamp to Date
|
||||||
amountCharged: amount, // Original amount in dollars
|
amountCharged: amount, // Original amount in dollars
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Handle authentication_required error (thrown for off-session 3DS)
|
||||||
|
if (error.code === "authentication_required") {
|
||||||
|
return {
|
||||||
|
status: "requires_action",
|
||||||
|
requiresAction: true,
|
||||||
|
paymentIntentId: error.payment_intent?.id,
|
||||||
|
clientSecret: error.payment_intent?.client_secret,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Parse Stripe error into structured format
|
// Parse Stripe error into structured format
|
||||||
const parsedError = parseStripeError(error);
|
const parsedError = parseStripeError(error);
|
||||||
|
|
||||||
@@ -213,17 +263,22 @@ class StripeService {
|
|||||||
|
|
||||||
return customer;
|
return customer;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error creating customer", { error: error.message, stack: error.stack });
|
logger.error("Error creating customer", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static async getPaymentMethod(paymentMethodId) {
|
static async getPaymentMethod(paymentMethodId) {
|
||||||
try {
|
try {
|
||||||
return await stripe.paymentMethods.retrieve(paymentMethodId);
|
return await stripe.paymentMethods.retrieve(paymentMethodId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error retrieving payment method", { error: error.message, paymentMethodId });
|
logger.error("Error retrieving payment method", {
|
||||||
|
error: error.message,
|
||||||
|
paymentMethodId,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,19 +287,28 @@ class StripeService {
|
|||||||
try {
|
try {
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
payment_method_types: ['card', 'us_bank_account', 'link'],
|
payment_method_types: ["card", "us_bank_account", "link"],
|
||||||
mode: 'setup',
|
mode: "setup",
|
||||||
ui_mode: 'embedded',
|
ui_mode: "embedded",
|
||||||
redirect_on_completion: 'never',
|
redirect_on_completion: "never",
|
||||||
|
// Configure for off-session usage - triggers 3DS during setup
|
||||||
|
payment_method_options: {
|
||||||
|
card: {
|
||||||
|
request_three_d_secure: "any",
|
||||||
|
},
|
||||||
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
type: 'payment_method_setup',
|
type: "payment_method_setup",
|
||||||
...metadata
|
...metadata,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error creating setup checkout session", { error: error.message, stack: error.stack });
|
logger.error("Error creating setup checkout session", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
296
backend/templates/emails/authenticationRequiredToRenter.html
Normal file
296
backend/templates/emails/authenticationRequiredToRenter.html
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
<!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>Action Required - 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning box */
|
||||||
|
.warning-box {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-box p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table td {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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">Action Required: Complete Your Payment</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Hi {{renterName}},</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Great news! <strong>{{ownerName}}</strong> has approved your rental
|
||||||
|
request for <strong>{{itemName}}</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="warning-box">
|
||||||
|
<p><strong>Your bank requires additional verification</strong></p>
|
||||||
|
<p>
|
||||||
|
To complete the payment and confirm your rental, your bank needs you
|
||||||
|
to verify your identity. This is a security measure to protect your
|
||||||
|
account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Rental Details</h2>
|
||||||
|
<table class="info-table">
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<td>{{itemName}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Amount</th>
|
||||||
|
<td>${{amount}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<p><strong>How to Complete Your Payment</strong></p>
|
||||||
|
<ol style="margin: 10px 0; padding-left: 20px; color: #004085">
|
||||||
|
<li>
|
||||||
|
Go directly to <strong>village-share.com</strong> in your browser
|
||||||
|
</li>
|
||||||
|
<li>Log in to your account</li>
|
||||||
|
<li>Navigate to <strong>My Rentals</strong> from the navigation menu</li>
|
||||||
|
<li>
|
||||||
|
Find the rental for <strong>{{itemName}}</strong>
|
||||||
|
</li>
|
||||||
|
<li>Click <strong>"Complete Payment"</strong> and follow your bank's verification steps</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The verification process usually takes less than a minute. Once
|
||||||
|
complete, your rental will be confirmed and you'll receive a
|
||||||
|
confirmation email.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you did not request this rental, please ignore this email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>Village Share</strong></p>
|
||||||
|
<p>
|
||||||
|
This is a notification about your rental request. You received this
|
||||||
|
message because the owner approved your rental and your bank requires
|
||||||
|
additional verification.
|
||||||
|
</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>
|
||||||
@@ -6,6 +6,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const DECLINE_MESSAGES = {
|
const DECLINE_MESSAGES = {
|
||||||
|
authentication_required: {
|
||||||
|
ownerMessage: "The renter's card requires additional authentication.",
|
||||||
|
renterMessage: "Your card requires authentication to complete this payment.",
|
||||||
|
canOwnerRetry: false,
|
||||||
|
requiresNewPaymentMethod: false,
|
||||||
|
requires3DS: true,
|
||||||
|
},
|
||||||
insufficient_funds: {
|
insufficient_funds: {
|
||||||
ownerMessage: "The renter's card has insufficient funds.",
|
ownerMessage: "The renter's card has insufficient funds.",
|
||||||
renterMessage: "Your card has insufficient funds.",
|
renterMessage: "Your card has insufficient funds.",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import ForumPostDetail from './pages/ForumPostDetail';
|
|||||||
import CreateForumPost from './pages/CreateForumPost';
|
import CreateForumPost from './pages/CreateForumPost';
|
||||||
import MyPosts from './pages/MyPosts';
|
import MyPosts from './pages/MyPosts';
|
||||||
import EarningsDashboard from './pages/EarningsDashboard';
|
import EarningsDashboard from './pages/EarningsDashboard';
|
||||||
|
import CompletePayment from './pages/CompletePayment';
|
||||||
import FAQ from './pages/FAQ';
|
import FAQ from './pages/FAQ';
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
import PrivateRoute from './components/PrivateRoute';
|
import PrivateRoute from './components/PrivateRoute';
|
||||||
@@ -126,6 +127,14 @@ const AppContent: React.FC = () => {
|
|||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/complete-payment/:rentalId"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<CompletePayment />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/owning"
|
path="/owning"
|
||||||
element={
|
element={
|
||||||
|
|||||||
73
frontend/src/components/AuthenticationRequiredModal.tsx
Normal file
73
frontend/src/components/AuthenticationRequiredModal.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface AuthenticationRequiredModalProps {
|
||||||
|
rental: {
|
||||||
|
renter?: { firstName?: string; lastName?: string; email?: string };
|
||||||
|
item?: { name?: string };
|
||||||
|
};
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthenticationRequiredModal: React.FC<
|
||||||
|
AuthenticationRequiredModalProps
|
||||||
|
> = ({ rental, onClose }) => {
|
||||||
|
const renterName =
|
||||||
|
`${rental.renter?.firstName || ""} ${rental.renter?.lastName || ""}`.trim() ||
|
||||||
|
"The renter";
|
||||||
|
const renterEmail = rental.renter?.email || "their email";
|
||||||
|
const itemName = rental.item?.name || "the item";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal fade show d-block"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="modal-dialog modal-dialog-centered">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">
|
||||||
|
<i className="bi bi-shield-lock text-warning me-2"></i>
|
||||||
|
Authentication Required
|
||||||
|
</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close"
|
||||||
|
onClick={onClose}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p>
|
||||||
|
<strong>{renterName}</strong>'s bank requires additional
|
||||||
|
authentication to complete the payment for{" "}
|
||||||
|
<strong>{itemName}</strong>.
|
||||||
|
</p>
|
||||||
|
<div className="alert alert-info mb-3">
|
||||||
|
<i className="bi bi-envelope me-2"></i>
|
||||||
|
We've sent an email to <strong>{renterEmail}</strong> with
|
||||||
|
instructions to complete the authentication.
|
||||||
|
</div>
|
||||||
|
<p className="text-muted mb-0">
|
||||||
|
Once they complete the authentication, the rental will be
|
||||||
|
automatically confirmed and you'll receive a notification.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthenticationRequiredModal;
|
||||||
@@ -46,7 +46,8 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
|
|||||||
colorBackground: "#ffffff",
|
colorBackground: "#ffffff",
|
||||||
colorText: "#212529",
|
colorText: "#212529",
|
||||||
colorDanger: "#dc3545",
|
colorDanger: "#dc3545",
|
||||||
fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
|
fontFamily:
|
||||||
|
"system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
|
||||||
fontSizeBase: "20px",
|
fontSizeBase: "20px",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
spacingUnit: "4px",
|
spacingUnit: "4px",
|
||||||
@@ -120,11 +121,17 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
|
|||||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div className={`modal-dialog ${step === "onboarding" ? "modal-xl" : "modal-lg"}`}>
|
<div
|
||||||
|
className={`modal-dialog ${
|
||||||
|
step === "onboarding" ? "modal-xl" : "modal-lg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="modal-content">
|
<div className="modal-content">
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h5 className="modal-title">
|
<h5 className="modal-title">
|
||||||
{step === "onboarding" ? "Complete Your Earnings Setup" : "Set Up Earnings"}
|
{step === "onboarding"
|
||||||
|
? "Complete Your Earnings Setup"
|
||||||
|
: "Start Receiving Earnings"}
|
||||||
</h5>
|
</h5>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -136,21 +143,17 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
|
|||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
{step === "start" && (
|
{step === "start" && (
|
||||||
<>
|
<>
|
||||||
<div className="text-center mb-4">
|
|
||||||
<div className="text-primary mb-3">
|
|
||||||
<i
|
|
||||||
className="bi bi-cash-coin"
|
|
||||||
style={{ fontSize: "3rem" }}
|
|
||||||
></i>
|
|
||||||
</div>
|
|
||||||
<h4>Start Receiving Earnings</h4>
|
|
||||||
<p className="text-muted">
|
|
||||||
Set up your earnings account to automatically receive
|
|
||||||
payments
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="row text-center mb-4">
|
<div className="row text-center mb-4">
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<i
|
||||||
|
className="bi bi-lightning text-primary"
|
||||||
|
style={{ fontSize: "2rem" }}
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<h6>Quick Setup</h6>
|
||||||
|
<small className="text-muted">Takes about 5 minutes</small>
|
||||||
|
</div>
|
||||||
<div className="col-md-4">
|
<div className="col-md-4">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<i
|
<i
|
||||||
@@ -160,19 +163,7 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<h6>Secure</h6>
|
<h6>Secure</h6>
|
||||||
<small className="text-muted">
|
<small className="text-muted">
|
||||||
Powered by Stripe, trusted by millions
|
Powered by Stripe and trusted by millions
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<i
|
|
||||||
className="bi bi-clock text-primary"
|
|
||||||
style={{ fontSize: "2rem" }}
|
|
||||||
></i>
|
|
||||||
</div>
|
|
||||||
<h6>Instant Payouts</h6>
|
|
||||||
<small className="text-muted">
|
|
||||||
Transferred when rentals complete
|
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4">
|
<div className="col-md-4">
|
||||||
@@ -196,8 +187,6 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
|
|||||||
<ul className="mb-0">
|
<ul className="mb-0">
|
||||||
<li>Verify your identity securely</li>
|
<li>Verify your identity securely</li>
|
||||||
<li>Provide bank account details for deposits</li>
|
<li>Provide bank account details for deposits</li>
|
||||||
<li>The setup process takes about 5 minutes</li>
|
|
||||||
<li>Receive payouts instantly when rentals complete</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -225,17 +214,20 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
|
|||||||
<>
|
<>
|
||||||
{!stripeConnectInstance ? (
|
{!stripeConnectInstance ? (
|
||||||
<div className="text-center py-5">
|
<div className="text-center py-5">
|
||||||
<div className="spinner-border text-primary mb-3" role="status">
|
<div
|
||||||
|
className="spinner-border text-primary mb-3"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
<span className="visually-hidden">Loading...</span>
|
<span className="visually-hidden">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
<h5>Loading onboarding form...</h5>
|
<h5>Loading onboarding form...</h5>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ minHeight: "500px" }}>
|
<div style={{ minHeight: "500px" }}>
|
||||||
<ConnectComponentsProvider connectInstance={stripeConnectInstance}>
|
<ConnectComponentsProvider
|
||||||
<ConnectAccountOnboarding
|
connectInstance={stripeConnectInstance}
|
||||||
onExit={handleOnboardingExit}
|
>
|
||||||
/>
|
<ConnectAccountOnboarding onExit={handleOnboardingExit} />
|
||||||
</ConnectComponentsProvider>
|
</ConnectComponentsProvider>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -278,7 +270,8 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
|
|||||||
{step === "onboarding" && stripeConnectInstance && (
|
{step === "onboarding" && stripeConnectInstance && (
|
||||||
<div className="w-100">
|
<div className="w-100">
|
||||||
<small className="text-muted">
|
<small className="text-muted">
|
||||||
<i className="bi bi-lock"></i> Your information is securely processed by Stripe
|
<i className="bi bi-lock"></i> Your information is securely
|
||||||
|
processed by Stripe
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
221
frontend/src/pages/CompletePayment.tsx
Normal file
221
frontend/src/pages/CompletePayment.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import { useParams, useNavigate, Link } from "react-router-dom";
|
||||||
|
import { loadStripe } from "@stripe/stripe-js";
|
||||||
|
import { rentalAPI } from "../services/api";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
|
const stripePromise = loadStripe(
|
||||||
|
process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY || ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const CompletePayment: React.FC = () => {
|
||||||
|
const { rentalId } = useParams<{ rentalId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
const [status, setStatus] = useState<
|
||||||
|
"loading" | "authenticating" | "success" | "error"
|
||||||
|
>("loading");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const hasProcessed = useRef(false);
|
||||||
|
|
||||||
|
const handleAuthentication = useCallback(async () => {
|
||||||
|
if (!rentalId) {
|
||||||
|
setError("Invalid rental ID");
|
||||||
|
setStatus("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get client secret
|
||||||
|
const secretResponse = await rentalAPI.getPaymentClientSecret(rentalId);
|
||||||
|
const { clientSecret, status: piStatus } = secretResponse.data;
|
||||||
|
|
||||||
|
if (piStatus === "succeeded") {
|
||||||
|
// Already succeeded, just complete on backend
|
||||||
|
await rentalAPI.completePayment(rentalId);
|
||||||
|
setStatus("success");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("authenticating");
|
||||||
|
|
||||||
|
// Initialize Stripe and confirm payment
|
||||||
|
const stripe = await stripePromise;
|
||||||
|
if (!stripe) {
|
||||||
|
throw new Error("Stripe failed to load");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: stripeError, paymentIntent } =
|
||||||
|
await stripe.confirmCardPayment(clientSecret);
|
||||||
|
|
||||||
|
if (stripeError) {
|
||||||
|
setError(stripeError.message || "Authentication failed");
|
||||||
|
setStatus("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentIntent?.status === "succeeded") {
|
||||||
|
await rentalAPI.completePayment(rentalId);
|
||||||
|
setStatus("success");
|
||||||
|
} else {
|
||||||
|
setError("Payment could not be completed. Please try again.");
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message ||
|
||||||
|
err.response?.data?.error ||
|
||||||
|
err.message ||
|
||||||
|
"An error occurred";
|
||||||
|
|
||||||
|
// Handle specific error cases
|
||||||
|
if (err.response?.status === 400) {
|
||||||
|
if (
|
||||||
|
err.response?.data?.message?.includes("not awaiting payment")
|
||||||
|
) {
|
||||||
|
// Rental is not in requires_action state - redirect to rentals
|
||||||
|
navigate("/renting", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(errorMessage);
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}, [rentalId, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Wait for auth to finish loading
|
||||||
|
if (authLoading) return;
|
||||||
|
|
||||||
|
// If not logged in, redirect to login
|
||||||
|
if (!user) {
|
||||||
|
const returnUrl = `/complete-payment/${rentalId}`;
|
||||||
|
navigate(`/?login=true&redirect=${encodeURIComponent(returnUrl)}`, {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent double execution in React StrictMode
|
||||||
|
if (hasProcessed.current) return;
|
||||||
|
hasProcessed.current = true;
|
||||||
|
|
||||||
|
if (rentalId) {
|
||||||
|
handleAuthentication();
|
||||||
|
}
|
||||||
|
}, [rentalId, handleAuthentication, user, authLoading, navigate]);
|
||||||
|
|
||||||
|
// Show loading while auth is initializing
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="container py-5">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-6 text-center">
|
||||||
|
<div className="spinner-border text-primary mb-3" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<h5>Loading...</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "loading" || status === "authenticating") {
|
||||||
|
return (
|
||||||
|
<div className="container py-5">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body text-center py-5">
|
||||||
|
<div className="spinner-border text-primary mb-3" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<h4>
|
||||||
|
{status === "loading"
|
||||||
|
? "Loading payment details..."
|
||||||
|
: "Completing authentication..."}
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted">
|
||||||
|
{status === "authenticating"
|
||||||
|
? "Please complete the authentication when prompted by your bank."
|
||||||
|
: "Please wait while we retrieve your payment information."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "success") {
|
||||||
|
return (
|
||||||
|
<div className="container py-5">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body text-center py-5">
|
||||||
|
<div className="mb-4">
|
||||||
|
<i
|
||||||
|
className="bi bi-check-circle-fill text-success"
|
||||||
|
style={{ fontSize: "4rem" }}
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<h3>Payment Complete!</h3>
|
||||||
|
<p className="text-muted mb-4">
|
||||||
|
Your rental has been confirmed. You'll receive a confirmation
|
||||||
|
email shortly.
|
||||||
|
</p>
|
||||||
|
<Link to="/renting" className="btn btn-primary">
|
||||||
|
View My Rentals
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
return (
|
||||||
|
<div className="container py-5">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body text-center py-5">
|
||||||
|
<div className="mb-4">
|
||||||
|
<i
|
||||||
|
className="bi bi-x-circle-fill text-danger"
|
||||||
|
style={{ fontSize: "4rem" }}
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<h3>Payment Failed</h3>
|
||||||
|
<p className="text-muted mb-4">{error}</p>
|
||||||
|
<div className="d-flex gap-2 justify-content-center">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
hasProcessed.current = false;
|
||||||
|
setStatus("loading");
|
||||||
|
setError(null);
|
||||||
|
handleAuthentication();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
<Link to="/renting" className="btn btn-outline-secondary">
|
||||||
|
View My Rentals
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompletePayment;
|
||||||
@@ -130,10 +130,9 @@ const EarningsDashboard: React.FC = () => {
|
|||||||
<div className="container mt-4">
|
<div className="container mt-4">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
<h1>My Earnings</h1>
|
<h1>Earnings</h1>
|
||||||
<p className="text-muted">
|
<p className="text-muted">
|
||||||
Manage your rental earnings and payment setup. Community Rentals
|
Manage your rental earnings and payment setup.{" "}
|
||||||
charges a 10% Community Upkeep Fee to help keep us running.{" "}
|
|
||||||
<Link to="/faq" target="_blank">
|
<Link to="/faq" target="_blank">
|
||||||
Calculate what you can earn here
|
Calculate what you can earn here
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import ConditionCheckModal from "../components/ConditionCheckModal";
|
|||||||
import ConditionCheckViewerModal from "../components/ConditionCheckViewerModal";
|
import ConditionCheckViewerModal from "../components/ConditionCheckViewerModal";
|
||||||
import ReturnStatusModal from "../components/ReturnStatusModal";
|
import ReturnStatusModal from "../components/ReturnStatusModal";
|
||||||
import PaymentFailedModal from "../components/PaymentFailedModal";
|
import PaymentFailedModal from "../components/PaymentFailedModal";
|
||||||
|
import AuthenticationRequiredModal from "../components/AuthenticationRequiredModal";
|
||||||
|
|
||||||
const Owning: React.FC = () => {
|
const Owning: React.FC = () => {
|
||||||
// Helper function to format time
|
// Helper function to format time
|
||||||
@@ -77,6 +78,8 @@ const Owning: React.FC = () => {
|
|||||||
const [showPaymentFailedModal, setShowPaymentFailedModal] = useState(false);
|
const [showPaymentFailedModal, setShowPaymentFailedModal] = useState(false);
|
||||||
const [paymentFailedError, setPaymentFailedError] = useState<any>(null);
|
const [paymentFailedError, setPaymentFailedError] = useState<any>(null);
|
||||||
const [paymentFailedRental, setPaymentFailedRental] = useState<Rental | null>(null);
|
const [paymentFailedRental, setPaymentFailedRental] = useState<Rental | null>(null);
|
||||||
|
const [showAuthRequiredModal, setShowAuthRequiredModal] = useState(false);
|
||||||
|
const [authRequiredRental, setAuthRequiredRental] = useState<Rental | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchListings();
|
fetchListings();
|
||||||
@@ -220,8 +223,17 @@ const Owning: React.FC = () => {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to accept rental request:", err);
|
console.error("Failed to accept rental request:", err);
|
||||||
|
|
||||||
|
// Check if 3DS authentication is required
|
||||||
|
if (err.response?.data?.error === "authentication_required") {
|
||||||
|
// Find the rental to show in the modal
|
||||||
|
const rental = ownerRentals.find((r) => r.id === rentalId);
|
||||||
|
setAuthRequiredRental(rental || null);
|
||||||
|
setShowAuthRequiredModal(true);
|
||||||
|
// Refresh rentals to update status
|
||||||
|
fetchOwnerRentals();
|
||||||
|
}
|
||||||
// Check if it's a payment failure (HTTP 402 or payment_failed error)
|
// Check if it's a payment failure (HTTP 402 or payment_failed error)
|
||||||
if (
|
else if (
|
||||||
err.response?.status === 402 ||
|
err.response?.status === 402 ||
|
||||||
err.response?.data?.error === "payment_failed"
|
err.response?.data?.error === "payment_failed"
|
||||||
) {
|
) {
|
||||||
@@ -867,6 +879,18 @@ const Owning: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Authentication Required Modal (3DS) */}
|
||||||
|
{showAuthRequiredModal && authRequiredRental && (
|
||||||
|
<AuthenticationRequiredModal
|
||||||
|
rental={authRequiredRental}
|
||||||
|
onClose={() => {
|
||||||
|
setShowAuthRequiredModal(false);
|
||||||
|
setAuthRequiredRental(null);
|
||||||
|
fetchOwnerRentals();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
{/* Delete Confirmation Modal */}
|
||||||
{showDeleteModal && (
|
{showDeleteModal && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -401,6 +401,30 @@ const Renting: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 3DS Authentication Required Alert */}
|
||||||
|
{rental.paymentStatus === "requires_action" && (
|
||||||
|
<div className="alert alert-warning py-2 mb-3">
|
||||||
|
<div className="d-flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<i className="bi bi-shield-lock me-2"></i>
|
||||||
|
<strong>Payment authentication required</strong>
|
||||||
|
<p className="mb-0 small text-muted mt-1">
|
||||||
|
Your bank requires additional verification to
|
||||||
|
complete this payment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-warning btn-sm ms-3"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/complete-payment/${rental.id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Complete Payment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="d-flex flex-column gap-2 mt-3">
|
<div className="d-flex flex-column gap-2 mt-3">
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
{((rental.displayStatus || rental.status) === "pending" ||
|
{((rental.displayStatus || rental.status) === "pending" ||
|
||||||
|
|||||||
@@ -246,6 +246,11 @@ export const rentalAPI = {
|
|||||||
}) => api.post("/rentals/cost-preview", data),
|
}) => api.post("/rentals/cost-preview", data),
|
||||||
updatePaymentMethod: (id: string, stripePaymentMethodId: string) =>
|
updatePaymentMethod: (id: string, stripePaymentMethodId: string) =>
|
||||||
api.put(`/rentals/${id}/payment-method`, { stripePaymentMethodId }),
|
api.put(`/rentals/${id}/payment-method`, { stripePaymentMethodId }),
|
||||||
|
// 3DS authentication endpoints
|
||||||
|
getPaymentClientSecret: (rentalId: string) =>
|
||||||
|
api.get(`/rentals/${rentalId}/payment-client-secret`),
|
||||||
|
completePayment: (rentalId: string) =>
|
||||||
|
api.post(`/rentals/${rentalId}/complete-payment`),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const messageAPI = {
|
export const messageAPI = {
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export interface Rental {
|
|||||||
status: RentalStatus;
|
status: RentalStatus;
|
||||||
// Computed status (includes "active" when confirmed + start time passed)
|
// Computed status (includes "active" when confirmed + start time passed)
|
||||||
displayStatus?: RentalStatus;
|
displayStatus?: RentalStatus;
|
||||||
paymentStatus: "pending" | "paid" | "refunded";
|
paymentStatus: "pending" | "paid" | "refunded" | "requires_action";
|
||||||
// Refund tracking fields
|
// Refund tracking fields
|
||||||
refundAmount?: number;
|
refundAmount?: number;
|
||||||
refundProcessedAt?: string;
|
refundProcessedAt?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user