This commit is contained in:
root
2025-12-22 06:44:01 +01:00
parent fc8af8f96b
commit 7c609ec40b
5 changed files with 1898 additions and 5 deletions

View File

@@ -15,6 +15,7 @@ A TypeScript Express application for receiving and processing Instant Payment No
- ✅ Transaction-safe database operations
- ✅ Idempotent IPN processing (handles duplicate callbacks)
- ✅ Error handling and logging
- ✅ Automatic email receipts sent to buyers upon purchase confirmation
## Setup
@@ -47,6 +48,23 @@ Edit `.env` and configure:
- `DB_PASSWORD` - Database password
- `DB_NAME` - Database name (default: cbd420)
**SMTP Configuration (for email receipts):**
- Configure SMTP settings for sending order confirmation emails:
- `SMTP_HOST` - SMTP server hostname (e.g., smtp.gmail.com, smtp.mailgun.org)
- `SMTP_PORT` - SMTP server port (e.g., 587 for STARTTLS, 465 for SSL, 25 for unencrypted)
- **Recommended: 587** (STARTTLS) - most reliable and commonly not blocked
- Port 465 (SSL) may be blocked by some firewalls
- `SMTP_USER` - SMTP username/email
- `SMTP_PASSWORD` - SMTP password or app-specific password
- `SMTP_FROM_EMAIL` - Email address to send from (must match SMTP_USER for most providers)
- `SMTP_FROM_NAME` - Display name for sender (optional, default: "CBD420")
**SMTP Troubleshooting:**
- If you get connection timeouts, try switching from port 465 to 587
- Ensure your firewall allows outbound connections to the SMTP server
- Some providers require app-specific passwords (e.g., Gmail)
- Test SMTP connectivity: `telnet SMTP_HOST SMTP_PORT` or use `nc -zv SMTP_HOST SMTP_PORT`
### 3. Database Migration
The application requires the `expires_at` column in the `pending_orders` table for the 10-minute reservation mechanism. Run the migration:
@@ -141,6 +159,7 @@ When a payment status is `finished` or `confirmed`, the system:
- `waiting`, `confirming` → Acknowledge and wait
4. **Final Inventory Check** - Validates inventory is still available before creating sale
5. **Create Sale Record** - Inserts into `sales` table and deletes from `pending_orders`
6. **Send Email Receipt** - Sends order confirmation email to buyer (after transaction commit)
All operations are performed within a database transaction to ensure data consistency and prevent race conditions.

1354
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,10 +18,12 @@
"author": "",
"license": "ISC",
"dependencies": {
"@types/nodemailer": "^7.0.4",
"crypto": "^1.0.1",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"mysql2": "^3.16.0"
"mysql2": "^3.16.0",
"nodemailer": "^7.0.11"
},
"devDependencies": {
"@types/express": "^4.17.21",

View File

@@ -398,7 +398,66 @@ export async function movePaymentToSalesWithInventoryCheck(
await connection.commit();
console.log(`✅ Successfully moved pending order ${pendingOrderId} (payment_id: ${paymentId}) from pending_orders to sales (sale_id: ${saleId})`);
return saleRows[0];
const sale = saleRows[0];
// Send email receipt after successful transaction
// This is done outside the transaction to avoid blocking the payment flow
// Email failures are logged but don't affect the sale creation
// Send email asynchronously without awaiting to avoid blocking
setImmediate(async () => {
try {
// Import email service dynamically to avoid circular dependencies
const { sendReceiptEmail, isSMTPConfigured } = await import('../services/emailService');
if (!isSMTPConfigured()) {
console.warn(`⚠️ SMTP not configured - skipping email receipt for sale ${sale.id}`);
return;
}
console.log(`📧 Attempting to send receipt email for sale ${sale.id}...`);
const buyer = await getBuyerById(sale.buyer_id);
const buyerData = await getBuyerDataById(sale.buyer_data_id);
console.log(`📧 Buyer lookup: ${buyer ? `found (${buyer.email})` : 'not found'}, BuyerData lookup: ${buyerData ? 'found' : 'not found'}`);
if (buyer && buyerData) {
// Convert price_amount to number (MySQL DECIMAL returns as string)
const priceAmount = typeof pendingOrder.price_amount === 'string'
? parseFloat(pendingOrder.price_amount)
: Number(pendingOrder.price_amount);
console.log(`📧 Calling sendReceiptEmail with priceAmount: ${priceAmount}, currency: ${pendingOrder.price_currency}`);
await sendReceiptEmail(
sale,
drop,
{
email: buyer.email,
username: buyer.username,
fullname: buyerData.fullname,
address: buyerData.address,
phone: buyerData.phone,
},
priceAmount,
pendingOrder.price_currency
);
console.log(`📧 sendReceiptEmail completed for sale ${sale.id}`);
} else {
console.warn(`Could not send receipt email for sale ${sale.id}: buyer or buyer_data not found`);
}
} catch (emailError) {
// Log but don't throw - email failure shouldn't break the payment flow
console.error(`❌ Error sending receipt email for sale ${sale.id}:`, emailError);
if (emailError instanceof Error) {
console.error(`❌ Error details: ${emailError.message}`);
}
}
});
return sale;
} catch (error) {
// Rollback transaction on error
await connection.rollback();
@@ -443,3 +502,59 @@ export async function paymentExistsInSales(paymentId: string): Promise<boolean>
throw error;
}
}
/**
* Get buyer information by buyer_id
*/
export interface BuyerInfo {
id: number;
email: string;
username: string;
}
export async function getBuyerById(buyerId: number): Promise<BuyerInfo | null> {
try {
const [rows] = await pool.execute(
'SELECT id, email, username FROM buyers WHERE id = ?',
[buyerId]
) as [BuyerInfo[], any];
if (Array.isArray(rows) && rows.length > 0) {
return rows[0];
}
return null;
} catch (error) {
console.error('Error getting buyer by id:', error);
throw error;
}
}
/**
* Get buyer data information by buyer_data_id
*/
export interface BuyerDataInfo {
id: number;
buyer_id: number;
fullname: string;
address: string;
phone: string;
}
export async function getBuyerDataById(buyerDataId: number): Promise<BuyerDataInfo | null> {
try {
const [rows] = await pool.execute(
'SELECT id, buyer_id, fullname, address, phone FROM buyer_data WHERE id = ?',
[buyerDataId]
) as [BuyerDataInfo[], any];
if (Array.isArray(rows) && rows.length > 0) {
return rows[0];
}
return null;
} catch (error) {
console.error('Error getting buyer data by id:', error);
throw error;
}
}

View File

@@ -0,0 +1,409 @@
import nodemailer from 'nodemailer';
import { Sale, Drop } from '../database/types';
/**
* SMTP configuration from environment variables
*/
interface SMTPConfig {
host: string;
port: number;
secure: boolean;
auth: {
user: string;
password: string;
};
from: string;
fromName: string;
}
/**
* Check if SMTP is configured
*/
export function isSMTPConfigured(): boolean {
const host = process.env.SMTP_HOST;
const port = process.env.SMTP_PORT;
const user = process.env.SMTP_USER;
const password = process.env.SMTP_PASSWORD;
const from = process.env.SMTP_FROM_EMAIL;
return !!(host && port && user && password && from);
}
/**
* Get SMTP configuration from environment variables
*/
function getSMTPConfig(): SMTPConfig {
const host = process.env.SMTP_HOST;
const port = process.env.SMTP_PORT;
const user = process.env.SMTP_USER;
const password = process.env.SMTP_PASSWORD;
const from = process.env.SMTP_FROM_EMAIL;
const fromName = process.env.SMTP_FROM_NAME || 'CBD420';
if (!host || !port || !user || !password || !from) {
throw new Error(
'SMTP configuration incomplete. Required env variables: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM_EMAIL'
);
}
// Validate password is not empty
if (!password || password.trim() === '') {
throw new Error('SMTP_PASSWORD is required and cannot be empty');
}
// Determine if secure (TLS) based on port
const portNum = parseInt(port, 10);
// Port 465 uses SSL (secure: true), port 587 uses STARTTLS (secure: false, requireTLS: true)
const secure = portNum === 465;
return {
host,
port: portNum,
secure,
auth: {
user: user.trim(),
password: password.trim(),
},
from,
fromName,
};
}
/**
* Create nodemailer transporter
*/
function createTransporter() {
const config = getSMTPConfig();
// Port 465 uses SSL (implicit TLS) - direct SSL connection
// Port 587 uses STARTTLS (explicit TLS) - upgrade after connection
const isSSL = config.port === 465;
const transporterOptions: any = {
host: config.host,
port: config.port,
secure: isSSL, // true for 465 (SSL), false for 587 (STARTTLS)
auth: {
user: config.auth.user,
pass: config.auth.password, // nodemailer uses 'pass' not 'password'
},
connectionTimeout: 20000, // 20 seconds
greetingTimeout: 10000, // 10 seconds
socketTimeout: 20000, // 20 seconds
// Disable DNS lookup timeout issues
dnsTimeout: 10000,
};
if (isSSL) {
// Port 465: SSL/TLS (implicit encryption from the start)
// This matches your provider's "Secure SSL/TLS Settings (Recommended)"
transporterOptions.secure = true;
transporterOptions.tls = {
rejectUnauthorized: false, // Set to true in production with valid certificates
minVersion: 'TLSv1', // Support TLSv1, TLSv1.1, TLSv1.2
};
} else {
// Port 587: STARTTLS (upgrade to TLS after connection)
// This matches your provider's "Non-SSL Settings (NOT Recommended)"
transporterOptions.secure = false;
transporterOptions.requireTLS = true;
transporterOptions.tls = {
rejectUnauthorized: false,
minVersion: 'TLSv1',
};
}
console.log(`📧 [createTransporter] Created transporter with options:`, {
host: config.host,
port: config.port,
secure: transporterOptions.secure,
requireTLS: transporterOptions.requireTLS,
authUser: config.auth.user,
hasPassword: !!config.auth.password && config.auth.password.length > 0,
});
return nodemailer.createTransport(transporterOptions);
}
/**
* Buyer information for email
*/
export interface BuyerInfo {
email: string;
username: string;
fullname: string;
address: string;
phone: string;
}
/**
* Generate email receipt HTML
*/
function generateReceiptHTML(
sale: Sale,
drop: Drop,
buyerInfo: BuyerInfo,
priceAmount: number | string,
priceCurrency: string
): string {
const orderDate = new Date(sale.created_at).toLocaleString();
const unit = drop.unit === 'kg' ? 'kg' : 'g';
const sizeDisplay = drop.unit === 'kg' ? (sale.size / 1000).toFixed(3) : sale.size;
// Ensure priceAmount is a number
const priceAmountNum = typeof priceAmount === 'string' ? parseFloat(priceAmount) : Number(priceAmount);
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #4CAF50;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}
.content {
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
}
.order-info {
background-color: white;
padding: 15px;
margin: 15px 0;
border-radius: 5px;
border-left: 4px solid #4CAF50;
}
.order-details {
margin: 20px 0;
}
.order-details table {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}
.order-details th,
.order-details td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.order-details th {
background-color: #f2f2f2;
font-weight: bold;
}
.total {
font-size: 1.2em;
font-weight: bold;
color: #4CAF50;
margin-top: 15px;
}
.footer {
text-align: center;
margin-top: 20px;
padding: 15px;
color: #666;
font-size: 0.9em;
}
</style>
</head>
<body>
<div class="header">
<h1>Order Confirmation</h1>
<p>Thank you for your purchase!</p>
</div>
<div class="content">
<p>Dear ${buyerInfo.fullname || buyerInfo.username},</p>
<p>Your order has been confirmed and payment has been received. Below are your order details:</p>
<div class="order-info">
<h3>Order Information</h3>
<p><strong>Order ID:</strong> ${sale.payment_id}</p>
<p><strong>Sale ID:</strong> #${sale.id}</p>
<p><strong>Order Date:</strong> ${orderDate}</p>
</div>
<div class="order-details">
<h3>Order Details</h3>
<table>
<tr>
<th>Item</th>
<td>${drop.item}</td>
</tr>
<tr>
<th>Quantity</th>
<td>${sizeDisplay} ${unit}</td>
</tr>
<tr>
<th>Price per Unit</th>
<td>${drop.ppu} ${priceCurrency}</td>
</tr>
<tr>
<th>Total Amount</th>
<td>${priceAmountNum.toFixed(2)} ${priceCurrency.toUpperCase()}</td>
</tr>
</table>
</div>
<div class="order-info">
<h3>Shipping Information</h3>
<p><strong>Name:</strong> ${buyerInfo.fullname}</p>
<p><strong>Address:</strong> ${buyerInfo.address}</p>
<p><strong>Phone:</strong> ${buyerInfo.phone}</p>
</div>
<p>We will process your order and send you a shipping confirmation once it's on its way.</p>
<p>If you have any questions, please don't hesitate to contact us.</p>
<p>Best regards,<br>CBD420 Team</p>
</div>
<div class="footer">
<p>This is an automated receipt. Please save this email for your records.</p>
</div>
</body>
</html>
`.trim();
}
/**
* Generate plain text receipt
*/
function generateReceiptText(
sale: Sale,
drop: Drop,
buyerInfo: BuyerInfo,
priceAmount: number | string,
priceCurrency: string
): string {
const orderDate = new Date(sale.created_at).toLocaleString();
const unit = drop.unit === 'kg' ? 'kg' : 'g';
const sizeDisplay = drop.unit === 'kg' ? (sale.size / 1000).toFixed(3) : sale.size;
// Ensure priceAmount is a number
const priceAmountNum = typeof priceAmount === 'string' ? parseFloat(priceAmount) : Number(priceAmount);
return `
ORDER CONFIRMATION
Dear ${buyerInfo.fullname || buyerInfo.username},
Your order has been confirmed and payment has been received.
ORDER INFORMATION
------------------
Order ID: ${sale.payment_id}
Sale ID: #${sale.id}
Order Date: ${orderDate}
ORDER DETAILS
-------------
Item: ${drop.item}
Quantity: ${sizeDisplay} ${unit}
Price per Unit: ${drop.ppu} ${priceCurrency}
Total Amount: ${priceAmountNum.toFixed(2)} ${priceCurrency.toUpperCase()}
SHIPPING INFORMATION
--------------------
Name: ${buyerInfo.fullname}
Address: ${buyerInfo.address}
Phone: ${buyerInfo.phone}
We will process your order and send you a shipping confirmation once it's on its way.
If you have any questions, please don't hesitate to contact us.
Best regards,
CBD420 Team
---
This is an automated receipt. Please save this email for your records.
`.trim();
}
/**
* Send email receipt to buyer
*/
export async function sendReceiptEmail(
sale: Sale,
drop: Drop,
buyerInfo: BuyerInfo,
priceAmount: number | string,
priceCurrency: string
): Promise<void> {
try {
console.log(`📧 [sendReceiptEmail] Starting email send for sale ${sale.id} to ${buyerInfo.email}`);
console.log(`📧 [sendReceiptEmail] Getting SMTP config...`);
const config = getSMTPConfig();
console.log(`📧 [sendReceiptEmail] SMTP config retrieved: host=${config.host}, port=${config.port}, from=${config.from}`);
console.log(`📧 [sendReceiptEmail] Creating transporter...`);
const transporter = createTransporter();
console.log(`📧 [sendReceiptEmail] Transporter created`);
console.log(`📧 [sendReceiptEmail] Generating email content...`);
const html = generateReceiptHTML(sale, drop, buyerInfo, priceAmount, priceCurrency);
const text = generateReceiptText(sale, drop, buyerInfo, priceAmount, priceCurrency);
console.log(`📧 [sendReceiptEmail] Email content generated (HTML: ${html.length} chars, Text: ${text.length} chars)`);
const mailOptions = {
from: `"${config.fromName}" <${config.from}>`,
to: buyerInfo.email,
subject: `Order Confirmation - ${sale.payment_id}`,
text,
html,
};
// Skip verification if connection is timing out - try sending directly
// Some SMTP servers don't respond well to verify() but work fine with sendMail()
console.log(`📧 [sendReceiptEmail] Skipping connection verification (will attempt direct send)...`);
console.log(`📧 [sendReceiptEmail] Sending email via SMTP (host: ${config.host}, port: ${config.port})...`);
// Try sending with a timeout
const sendPromise = transporter.sendMail(mailOptions);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error(`Email send timeout after 30 seconds (host: ${config.host}, port: ${config.port})`)), 30000);
});
try {
const info = await Promise.race([sendPromise, timeoutPromise]) as any;
console.log(`✅ Receipt email sent to ${buyerInfo.email} for sale ${sale.id}. Message ID: ${info.messageId}`);
} catch (sendError) {
// Provide more helpful error message
if (sendError instanceof Error && sendError.message.includes('timeout')) {
throw new Error(`SMTP connection timeout to ${config.host}:${config.port}. Check firewall rules and ensure the SMTP server is accessible from this network.`);
}
throw sendError;
}
} catch (error) {
console.error('❌ [sendReceiptEmail] Error sending receipt email:', error);
// Don't throw - email failure shouldn't break the payment flow
// Log the error for manual review
if (error instanceof Error) {
console.error(`❌ [sendReceiptEmail] Failed to send email to ${buyerInfo.email}: ${error.message}`);
console.error(`❌ [sendReceiptEmail] Stack trace:`, error.stack);
} else {
console.error(`❌ [sendReceiptEmail] Unknown error type:`, error);
}
// Re-throw to be caught by the caller's try-catch
throw error;
}
}