561 lines
18 KiB
TypeScript
561 lines
18 KiB
TypeScript
import { pool } from './connection';
|
|
import { PendingOrder, Sale, Drop } from './types';
|
|
|
|
/**
|
|
* Find a pending order by payment_id or invoice_id
|
|
* According to the guide, we should check both payment_id and invoice_id fields
|
|
*/
|
|
export async function findPendingOrderByPaymentId(paymentId: string, invoiceId?: string): Promise<PendingOrder | null> {
|
|
try {
|
|
let query: string;
|
|
let params: string[];
|
|
|
|
// Try payment_id first, then invoice_id if provided
|
|
if (invoiceId && invoiceId !== paymentId) {
|
|
query = 'SELECT * FROM pending_orders WHERE payment_id = ? OR payment_id = ?';
|
|
params = [paymentId, invoiceId];
|
|
} else {
|
|
query = 'SELECT * FROM pending_orders WHERE payment_id = ?';
|
|
params = [paymentId];
|
|
}
|
|
|
|
const [rows] = await pool.execute(query, params) as [PendingOrder[], any];
|
|
|
|
if (Array.isArray(rows) && rows.length > 0) {
|
|
return rows[0];
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.error('Error finding pending order by payment_id:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find a pending order by order_id
|
|
*/
|
|
export async function findPendingOrderByOrderId(orderId: string): Promise<PendingOrder | null> {
|
|
try {
|
|
const [rows] = await pool.execute(
|
|
'SELECT * FROM pending_orders WHERE order_id = ?',
|
|
[orderId]
|
|
) as [PendingOrder[], any];
|
|
|
|
if (Array.isArray(rows) && rows.length > 0) {
|
|
return rows[0];
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.error('Error finding pending order by order_id:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a pending order is expired
|
|
*/
|
|
export async function isPendingOrderExpired(pendingOrderId: number): Promise<boolean> {
|
|
try {
|
|
const [rows] = await pool.execute(
|
|
'SELECT * FROM pending_orders WHERE id = ? AND expires_at > NOW()',
|
|
[pendingOrderId]
|
|
) as [PendingOrder[], any];
|
|
|
|
// If no row returned, it means the order is expired (or doesn't exist)
|
|
return !Array.isArray(rows) || rows.length === 0;
|
|
} catch (error) {
|
|
console.error('Error checking pending order expiration:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get drop details by drop_id
|
|
*/
|
|
export async function getDropById(dropId: number): Promise<Drop | null> {
|
|
try {
|
|
const [rows] = await pool.execute(
|
|
'SELECT * FROM drops WHERE id = ?',
|
|
[dropId]
|
|
) as [Drop[], any];
|
|
|
|
if (Array.isArray(rows) && rows.length > 0) {
|
|
return rows[0];
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.error('Error getting drop by id:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate total sales for a drop (in grams)
|
|
*/
|
|
export async function getTotalSalesForDrop(dropId: number): Promise<number> {
|
|
try {
|
|
const [rows] = await pool.execute(
|
|
'SELECT COALESCE(SUM(size), 0) as total FROM sales WHERE drop_id = ?',
|
|
[dropId]
|
|
) as [{ total: number }[], any];
|
|
|
|
if (Array.isArray(rows) && rows.length > 0) {
|
|
return rows[0].total || 0;
|
|
}
|
|
|
|
return 0;
|
|
} catch (error) {
|
|
console.error('Error calculating total sales:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate total pending orders for a drop (in grams), excluding a specific pending order
|
|
*/
|
|
export async function getTotalPendingOrdersForDrop(dropId: number, excludePendingOrderId?: number): Promise<number> {
|
|
try {
|
|
let query: string;
|
|
let params: any[];
|
|
|
|
if (excludePendingOrderId) {
|
|
query = 'SELECT COALESCE(SUM(size), 0) as total FROM pending_orders WHERE drop_id = ? AND id != ? AND expires_at > NOW()';
|
|
params = [dropId, excludePendingOrderId];
|
|
} else {
|
|
query = 'SELECT COALESCE(SUM(size), 0) as total FROM pending_orders WHERE drop_id = ? AND expires_at > NOW()';
|
|
params = [dropId];
|
|
}
|
|
|
|
const [rows] = await pool.execute(query, params) as [{ total: number | string }[], any];
|
|
|
|
if (Array.isArray(rows) && rows.length > 0) {
|
|
// Convert to number (MySQL returns DECIMAL/SUM as string)
|
|
return Number(rows[0].total) || 0;
|
|
}
|
|
|
|
return 0;
|
|
} catch (error) {
|
|
console.error('Error calculating total pending orders:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate available inventory for a drop (in grams)
|
|
* Available = drop.size - (total_sales + total_non_expired_pending_orders)
|
|
* Handles unit conversion (kg to grams)
|
|
*/
|
|
export async function getAvailableInventory(dropId: number, excludePendingOrderId?: number): Promise<number> {
|
|
try {
|
|
const drop = await getDropById(dropId);
|
|
if (!drop) {
|
|
throw new Error(`Drop not found: ${dropId}`);
|
|
}
|
|
|
|
const totalSales = await getTotalSalesForDrop(dropId);
|
|
const totalPending = await getTotalPendingOrdersForDrop(dropId, excludePendingOrderId);
|
|
|
|
// Convert drop.size to grams if needed
|
|
let dropSizeInGrams = drop.size;
|
|
if (drop.unit === 'kg') {
|
|
dropSizeInGrams = drop.size * 1000;
|
|
}
|
|
|
|
// Available inventory in grams
|
|
const available = dropSizeInGrams - (totalSales + totalPending);
|
|
return Math.max(0, available); // Never return negative
|
|
} catch (error) {
|
|
console.error('Error calculating available inventory:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a pending order by id
|
|
*/
|
|
export async function deletePendingOrderById(pendingOrderId: number): Promise<boolean> {
|
|
try {
|
|
const [result] = await pool.execute(
|
|
'DELETE FROM pending_orders WHERE id = ?',
|
|
[pendingOrderId]
|
|
);
|
|
|
|
const deleteResult = result as any;
|
|
return deleteResult.affectedRows > 0;
|
|
} catch (error) {
|
|
console.error('Error deleting pending order by id:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a pending order by order_id
|
|
*/
|
|
export async function deletePendingOrderByOrderId(orderId: string): Promise<boolean> {
|
|
try {
|
|
const [result] = await pool.execute(
|
|
'DELETE FROM pending_orders WHERE order_id = ?',
|
|
[orderId]
|
|
);
|
|
|
|
const deleteResult = result as any;
|
|
return deleteResult.affectedRows > 0;
|
|
} catch (error) {
|
|
console.error('Error deleting pending order by order_id:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a sale record from a pending order
|
|
*/
|
|
export async function createSaleFromPendingOrder(pendingOrder: PendingOrder, paymentId: string): Promise<Sale> {
|
|
try {
|
|
const [result] = await pool.execute(
|
|
'INSERT INTO sales (drop_id, buyer_id, buyer_data_id, size, payment_id, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
|
|
[
|
|
pendingOrder.drop_id,
|
|
pendingOrder.buyer_id,
|
|
pendingOrder.buyer_data_id,
|
|
pendingOrder.size,
|
|
paymentId
|
|
]
|
|
);
|
|
|
|
const insertResult = result as any;
|
|
const saleId = insertResult.insertId;
|
|
|
|
// Fetch the created sale
|
|
const [rows] = await pool.execute(
|
|
'SELECT * FROM sales WHERE id = ?',
|
|
[saleId]
|
|
) as [Sale[], any];
|
|
|
|
if (Array.isArray(rows) && rows.length > 0) {
|
|
return rows[0];
|
|
}
|
|
|
|
throw new Error('Failed to retrieve created sale');
|
|
} catch (error) {
|
|
console.error('Error creating sale:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Move a payment from pending_orders to sales with inventory validation
|
|
* This follows the guide's Step 4 and Step 5 logic
|
|
*/
|
|
export async function movePaymentToSalesWithInventoryCheck(
|
|
pendingOrderId: number,
|
|
paymentId: string
|
|
): Promise<Sale> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
// Start transaction
|
|
await connection.beginTransaction();
|
|
|
|
// Get the pending order with FOR UPDATE lock
|
|
const [pendingRows] = await connection.execute(
|
|
'SELECT * FROM pending_orders WHERE id = ? FOR UPDATE',
|
|
[pendingOrderId]
|
|
) as [PendingOrder[], any];
|
|
|
|
if (!Array.isArray(pendingRows) || pendingRows.length === 0) {
|
|
throw new Error(`Pending order not found for id: ${pendingOrderId}`);
|
|
}
|
|
|
|
const pendingOrder = pendingRows[0];
|
|
|
|
// Check expiration
|
|
const [expiredCheckRows] = await connection.execute(
|
|
'SELECT * FROM pending_orders WHERE id = ? AND expires_at > NOW()',
|
|
[pendingOrderId]
|
|
) as [PendingOrder[], any];
|
|
|
|
if (!Array.isArray(expiredCheckRows) || expiredCheckRows.length === 0) {
|
|
// Order expired, delete it and throw error
|
|
await connection.execute('DELETE FROM pending_orders WHERE id = ?', [pendingOrderId]);
|
|
await connection.rollback();
|
|
throw new Error(`Pending order ${pendingOrderId} has expired`);
|
|
}
|
|
|
|
// Final inventory check (Step 4 from guide) - within transaction
|
|
// Get drop details
|
|
const [dropRows] = await connection.execute(
|
|
'SELECT * FROM drops WHERE id = ?',
|
|
[pendingOrder.drop_id]
|
|
) as [Drop[], any];
|
|
|
|
if (!Array.isArray(dropRows) || dropRows.length === 0) {
|
|
throw new Error(`Drop not found: ${pendingOrder.drop_id}`);
|
|
}
|
|
|
|
const drop = dropRows[0];
|
|
|
|
// Calculate total sales (in grams)
|
|
const [salesRows] = await connection.execute(
|
|
'SELECT COALESCE(SUM(size), 0) as total FROM sales WHERE drop_id = ?',
|
|
[pendingOrder.drop_id]
|
|
) as [{ total: number | string }[], any];
|
|
// Convert to number (MySQL returns DECIMAL/SUM as string)
|
|
const totalSales = Number(salesRows[0]?.total) || 0;
|
|
|
|
// Also get the fill field for comparison/validation
|
|
const dropFill = Number((drop as any).fill) || 0;
|
|
|
|
// Calculate other pending orders (excluding current one, non-expired) - in grams
|
|
const [otherPendingRows] = await connection.execute(
|
|
'SELECT COALESCE(SUM(size), 0) as total FROM pending_orders WHERE drop_id = ? AND id != ? AND expires_at > NOW()',
|
|
[pendingOrder.drop_id, pendingOrderId]
|
|
) as [{ total: number | string }[], any];
|
|
// Convert to number (MySQL returns DECIMAL/SUM as string)
|
|
const totalPending = Number(otherPendingRows[0]?.total) || 0;
|
|
|
|
// Convert drop.size to grams if needed
|
|
let dropSizeInGrams = drop.size;
|
|
if (drop.unit === 'kg') {
|
|
dropSizeInGrams = drop.size * 1000;
|
|
}
|
|
|
|
// Calculate available inventory (in grams)
|
|
const availableInventory = dropSizeInGrams - (totalSales + totalPending);
|
|
|
|
// Log detailed inventory calculation for debugging
|
|
console.log(`Inventory calculation for drop ${pendingOrder.drop_id}:`, {
|
|
drop_id: pendingOrder.drop_id,
|
|
drop_size: drop.size,
|
|
drop_unit: drop.unit,
|
|
drop_size_in_grams: dropSizeInGrams,
|
|
drop_fill: dropFill,
|
|
total_sales_grams: totalSales,
|
|
total_pending_grams: totalPending,
|
|
available_inventory_grams: availableInventory,
|
|
requested_size: pendingOrder.size,
|
|
note: dropFill !== totalSales ? `WARNING: fill field (${dropFill}) differs from sum of sales table (${totalSales})` : 'fill matches sales sum'
|
|
});
|
|
|
|
if (pendingOrder.size > availableInventory) {
|
|
// Insufficient inventory, delete pending order and throw error
|
|
await connection.execute('DELETE FROM pending_orders WHERE id = ?', [pendingOrderId]);
|
|
await connection.rollback();
|
|
throw new Error(`Insufficient inventory for drop ${pendingOrder.drop_id}. Drop size: ${drop.size}${drop.unit} (${dropSizeInGrams}g), Total sold: ${totalSales}g, Total pending: ${totalPending}g, Available: ${availableInventory}g, Requested: ${pendingOrder.size}g`);
|
|
}
|
|
|
|
// Validate all required fields are present
|
|
if (pendingOrder.drop_id === undefined || pendingOrder.drop_id === null) {
|
|
throw new Error(`Pending order missing drop_id for id: ${pendingOrderId}`);
|
|
}
|
|
if (pendingOrder.buyer_id === undefined || pendingOrder.buyer_id === null) {
|
|
throw new Error(`Pending order missing buyer_id for id: ${pendingOrderId}`);
|
|
}
|
|
if (pendingOrder.buyer_data_id === undefined || pendingOrder.buyer_data_id === null) {
|
|
throw new Error(`Pending order missing buyer_data_id for id: ${pendingOrderId}`);
|
|
}
|
|
if (pendingOrder.size === undefined || pendingOrder.size === null) {
|
|
throw new Error(`Pending order missing size for id: ${pendingOrderId}`);
|
|
}
|
|
if (!paymentId) {
|
|
throw new Error(`Payment ID is required but was ${paymentId} for id: ${pendingOrderId}`);
|
|
}
|
|
|
|
// Create sale record (Step 5 from guide)
|
|
const [insertResult] = await connection.execute(
|
|
'INSERT INTO sales (drop_id, buyer_id, buyer_data_id, size, payment_id, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
|
|
[
|
|
pendingOrder.drop_id,
|
|
pendingOrder.buyer_id,
|
|
pendingOrder.buyer_data_id,
|
|
pendingOrder.size,
|
|
paymentId
|
|
]
|
|
);
|
|
|
|
const insert = insertResult as any;
|
|
const saleId = insert.insertId;
|
|
|
|
// Delete pending order
|
|
await connection.execute(
|
|
'DELETE FROM pending_orders WHERE id = ?',
|
|
[pendingOrderId]
|
|
);
|
|
|
|
// Fetch the created sale
|
|
const [saleRows] = await connection.execute(
|
|
'SELECT * FROM sales WHERE id = ?',
|
|
[saleId]
|
|
) as [Sale[], any];
|
|
|
|
if (!Array.isArray(saleRows) || saleRows.length === 0) {
|
|
throw new Error('Failed to retrieve created sale');
|
|
}
|
|
|
|
// Commit transaction
|
|
await connection.commit();
|
|
|
|
console.log(`✅ Successfully moved pending order ${pendingOrderId} (payment_id: ${paymentId}) from pending_orders to sales (sale_id: ${saleId})`);
|
|
|
|
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();
|
|
console.error('Error moving payment to sales:', error);
|
|
throw error;
|
|
} finally {
|
|
// Release connection back to pool
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Legacy function - kept for backward compatibility
|
|
* Move a payment from pending_orders to sales by order_id
|
|
*/
|
|
export async function movePaymentToSales(orderId: string, paymentId: string): Promise<Sale> {
|
|
// First find the pending order by order_id
|
|
const pendingOrder = await findPendingOrderByOrderId(orderId);
|
|
|
|
if (!pendingOrder) {
|
|
throw new Error(`Pending order not found for order_id: ${orderId}`);
|
|
}
|
|
|
|
// Use the new function with inventory check
|
|
return movePaymentToSalesWithInventoryCheck(pendingOrder.id, paymentId);
|
|
}
|
|
|
|
/**
|
|
* Check if a payment already exists in sales (idempotency check)
|
|
*/
|
|
export async function paymentExistsInSales(paymentId: string): Promise<boolean> {
|
|
try {
|
|
const [rows] = await pool.execute(
|
|
'SELECT COUNT(*) as count FROM sales WHERE payment_id = ?',
|
|
[paymentId]
|
|
);
|
|
|
|
const result = rows as any[];
|
|
return result[0]?.count > 0;
|
|
} catch (error) {
|
|
console.error('Error checking sale existence:', error);
|
|
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;
|
|
}
|
|
}
|