Files
cbd420_ipn/src/database/paymentService.ts
2025-12-22 06:44:01 +01:00

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