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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // 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 { 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 { 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 { 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; } }