From 4686aa1ef32525209d372c5976381527d82fc436 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 21 Dec 2025 09:57:56 +0100 Subject: [PATCH] race condition --- README.md | 116 ++++-- .../add_expires_at_to_pending_orders.sql | 25 ++ src/database/paymentService.ts | 358 +++++++++++++++--- src/database/types.ts | 12 + src/routes/ipn.ts | 252 ++++++------ 5 files changed, 585 insertions(+), 178 deletions(-) create mode 100644 migrations/add_expires_at_to_pending_orders.sql diff --git a/README.md b/README.md index fb8eae9..fcec8d8 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,12 @@ A TypeScript Express application for receiving and processing Instant Payment No - ✅ TypeScript for type safety - ✅ Handles all NOWPayments payment statuses - ✅ MySQL/MariaDB database integration -- ✅ Automatic payment processing: moves finished payments from `pending_orders` to `sales` +- ✅ Automatic payment processing: moves finished/confirmed payments from `pending_orders` to `sales` +- ✅ 10-minute reservation mechanism to prevent overselling +- ✅ Expiration checking and automatic cleanup of expired orders +- ✅ Final inventory validation before creating sales - ✅ Transaction-safe database operations +- ✅ Idempotent IPN processing (handles duplicate callbacks) - ✅ Error handling and logging ## Setup @@ -43,13 +47,32 @@ Edit `.env` and configure: - `DB_PASSWORD` - Database password - `DB_NAME` - Database name (default: cbd420) -### 3. Build the Project +### 3. Database Migration + +The application requires the `expires_at` column in the `pending_orders` table for the 10-minute reservation mechanism. Run the migration: + +```bash +mysql -u your_user -p your_database < migrations/add_expires_at_to_pending_orders.sql +``` + +Or manually add the column: + +```sql +ALTER TABLE `pending_orders` + ADD COLUMN `expires_at` datetime NOT NULL DEFAULT (DATE_ADD(NOW(), INTERVAL 10 MINUTE)) + AFTER `created_at`; + +ALTER TABLE `pending_orders` + ADD INDEX `idx_expires_at` (`expires_at`); +``` + +### 4. Build the Project ```bash npm run build ``` -### 4. Run the Server +### 5. Run the Server **Development mode (with auto-reload):** ```bash @@ -104,47 +127,92 @@ The listener handles the following payment statuses: ## Database Integration -The application automatically integrates with your MySQL/MariaDB database. When a payment status is `finished`, the system will: +The application implements a **10-minute reservation mechanism** to prevent race conditions when multiple buyers attempt to purchase the last available units simultaneously. -1. **Validate the payment** - Check if the payment exists in `pending_orders` -2. **Create a sale record** - Insert the payment into the `sales` table -3. **Remove pending order** - Delete the record from `pending_orders` +### Payment Processing Flow -All operations are performed within a database transaction to ensure data consistency. +When a payment status is `finished` or `confirmed`, the system: + +1. **Find Pending Order** - Looks up the pending order by `payment_id` or `invoice_id` +2. **Check Expiration** - Verifies the pending order hasn't expired (10-minute window) +3. **Validate Payment Status** - Processes based on status: + - `finished` or `confirmed` → Proceed to create sale + - `failed` or `expired` → Delete pending order + - `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` + +All operations are performed within a database transaction to ensure data consistency and prevent race conditions. ### Database Schema -The application expects the following tables (as defined in `cbd420(1).sql`): +The application expects the following tables (as defined in `cbd420(1).sql` + migration): -- **`pending_orders`** - Stores pending payment orders - - `payment_id` (unique) - NOWPayments payment ID - - `order_id` (unique) - Your order ID +- **`pending_orders`** - Stores pending payment orders with 10-minute reservations + - `payment_id` (unique) - NOWPayments payment/invoice ID + - `order_id` (unique) - Internal order ID (format: SALE-{timestamp}-{drop_id}-{buyer_id}) - `drop_id` - Reference to drops table - `buyer_id` - Reference to buyers table - - `size` - Order size + - `size` - Order size (in grams) - `price_amount` - Payment amount - - `price_currency` - Payment currency + - `price_currency` - Payment currency (default: 'chf') + - `created_at` - Order creation timestamp + - `expires_at` - Expiration timestamp (10 minutes from creation) - **REQUIRED** - **`sales`** - Stores completed sales - `drop_id` - Reference to drops table - `buyer_id` - Reference to buyers table - - `size` - Sale size - - `payment_id` - NOWPayments payment ID + - `size` - Sale size (in grams) + - `payment_id` - NOWPayments payment ID (matches pending_orders.payment_id) + - `created_at` - Sale creation timestamp -### Payment Processing Flow +- **`drops`** - Product drop information + - `id` - Drop ID + - `size` - Available size + - `unit` - Unit of measurement ('g' or 'kg') -1. Payment is created → Record inserted into `pending_orders` -2. IPN notification received → Signature validated -3. Payment status `finished` → Record moved from `pending_orders` to `sales` -4. Transaction committed → Payment processing complete +### Inventory Calculation -The system includes idempotency checks to prevent duplicate processing if the same IPN notification is received multiple times. +Available inventory is calculated as: +``` +Available = drop.size - (SUM(sales.size) + SUM(pending_orders.size WHERE expires_at > NOW())) +``` + +The system automatically handles unit conversion (kg to grams) when calculating inventory. + +### Key Features + +- **Expiration Handling**: Pending orders expire after 10 minutes and are automatically excluded from inventory calculations +- **Inventory Validation**: Final inventory check ensures no overselling occurs between payment initiation and completion +- **Idempotency**: IPN callbacks can be processed multiple times safely (checks for existing sales) +- **Transaction Safety**: All database operations use transactions to ensure atomicity + +## IPN Callback Processing + +The IPN handler follows this flow for each callback: + +1. **Find Pending Order** - Searches by `payment_id` or `invoice_id` (tries both) +2. **Check Expiration** - Verifies `expires_at > NOW()` - expired orders are rejected +3. **Payment Status Processing**: + - `finished` or `confirmed` → Creates sale (after inventory check) + - `failed` or `expired` → Deletes pending order + - Other statuses → Acknowledged, waiting for final status +4. **Final Inventory Check** - Re-validates inventory before creating sale (within transaction) +5. **Create Sale** - Atomically creates sale and deletes pending order + +### Important Notes + +- IPN callbacks may be sent multiple times - the system handles this with idempotency checks +- Expired pending orders are automatically excluded from inventory calculations +- Always returns HTTP 200 to NOWPayments (even on errors) to prevent retries +- Inventory is checked within a database transaction to prevent race conditions ## Security - All IPN requests are validated using HMAC SHA512 signature verification - Invalid signatures are rejected with 400 Bad Request - The IPN secret key should never be committed to version control +- Database transactions ensure data consistency and prevent race conditions ## Testing @@ -161,6 +229,10 @@ curl -X POST http://localhost:3000/ipn \ -d '{"payment_id":"test123","payment_status":"waiting","price_amount":100,"price_currency":"usd"}' ``` +## Documentation + +For detailed implementation guide and database schema, refer to the **IPN Callback Integration Guide** provided with this application. + ## License ISC diff --git a/migrations/add_expires_at_to_pending_orders.sql b/migrations/add_expires_at_to_pending_orders.sql new file mode 100644 index 0000000..7b9dc5c --- /dev/null +++ b/migrations/add_expires_at_to_pending_orders.sql @@ -0,0 +1,25 @@ +-- Migration: Add expires_at column to pending_orders table +-- This migration adds the expires_at column required for the 10-minute reservation mechanism +-- as described in the IPN Callback Integration Guide + +-- Step 1: Add the column (nullable first for existing records) +ALTER TABLE `pending_orders` + ADD COLUMN `expires_at` datetime NULL + AFTER `created_at`; + +-- Step 2: Update existing records to set expires_at based on created_at + 10 minutes +UPDATE `pending_orders` +SET `expires_at` = DATE_ADD(`created_at`, INTERVAL 10 MINUTE) +WHERE `expires_at` IS NULL; + +-- Step 3: Make the column NOT NULL now that all records have values +ALTER TABLE `pending_orders` + MODIFY COLUMN `expires_at` datetime NOT NULL; + +-- Step 4: Add index for cleanup queries (as recommended in the guide) +ALTER TABLE `pending_orders` + ADD INDEX `idx_expires_at` (`expires_at`); + +-- Note: The application should set expires_at = NOW() + 10 minutes when creating new pending orders +-- Example: INSERT INTO pending_orders (..., expires_at) VALUES (..., DATE_ADD(NOW(), INTERVAL 10 MINUTE)) + diff --git a/src/database/paymentService.ts b/src/database/paymentService.ts index deb5cc9..a034d94 100644 --- a/src/database/paymentService.ts +++ b/src/database/paymentService.ts @@ -1,5 +1,36 @@ import { pool } from './connection'; -import { PendingOrder, Sale } from './types'; +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 @@ -17,7 +48,163 @@ export async function findPendingOrderByOrderId(orderId: string): 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; } } @@ -25,7 +212,7 @@ export async function findPendingOrderByOrderId(orderId: string): Promise { +export async function createSaleFromPendingOrder(pendingOrder: PendingOrder, paymentId: string): Promise { try { const [result] = await pool.execute( 'INSERT INTO sales (drop_id, buyer_id, size, payment_id, created_at) VALUES (?, ?, ?, ?, NOW())', @@ -33,7 +220,7 @@ export async function createSaleFromPendingOrder(pendingOrder: PendingOrder): Pr pendingOrder.drop_id, pendingOrder.buyer_id, pendingOrder.size, - pendingOrder.payment_id + paymentId ] ); @@ -58,61 +245,121 @@ export async function createSaleFromPendingOrder(pendingOrder: PendingOrder): Pr } /** - * Delete a pending order by order_id + * 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 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:', error); - throw error; - } -} - -/** - * Move a payment from pending_orders to sales - * This is the main function that handles the complete transaction - */ -export async function movePaymentToSales(orderId: string, paymentId: string): Promise { +export async function movePaymentToSalesWithInventoryCheck( + pendingOrderId: number, + paymentId: string +): Promise { const connection = await pool.getConnection(); try { // Start transaction await connection.beginTransaction(); - // Find the pending order by order_id + // Get the pending order with FOR UPDATE lock const [pendingRows] = await connection.execute( - 'SELECT * FROM pending_orders WHERE order_id = ? FOR UPDATE', - [orderId] + '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 order_id: ${orderId}`); + throw new Error(`Pending order not found for id: ${pendingOrderId}`); } const pendingOrder = pendingRows[0]; - // Validate all required fields are present - if (pendingOrder.drop_id === undefined || pendingOrder.drop_id === null) { - throw new Error(`Pending order missing drop_id for order_id: ${orderId}`); - } - if (pendingOrder.buyer_id === undefined || pendingOrder.buyer_id === null) { - throw new Error(`Pending order missing buyer_id for order_id: ${orderId}`); - } - if (pendingOrder.size === undefined || pendingOrder.size === null) { - throw new Error(`Pending order missing size for order_id: ${orderId}`); - } - if (!paymentId) { - throw new Error(`Payment ID is required but was ${paymentId} for order_id: ${orderId}`); + // 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`); } - // Create sale record + // 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.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, size, payment_id, created_at) VALUES (?, ?, ?, ?, NOW())', [ @@ -126,10 +373,10 @@ export async function movePaymentToSales(orderId: string, paymentId: string): Pr const insert = insertResult as any; const saleId = insert.insertId; - // Delete pending order by order_id + // Delete pending order await connection.execute( - 'DELETE FROM pending_orders WHERE order_id = ?', - [orderId] + 'DELETE FROM pending_orders WHERE id = ?', + [pendingOrderId] ); // Fetch the created sale @@ -145,7 +392,7 @@ export async function movePaymentToSales(orderId: string, paymentId: string): Pr // Commit transaction await connection.commit(); - console.log(`✅ Successfully moved order ${orderId} (payment_id: ${paymentId}) from pending_orders to sales (sale_id: ${saleId})`); + console.log(`✅ Successfully moved pending order ${pendingOrderId} (payment_id: ${paymentId}) from pending_orders to sales (sale_id: ${saleId})`); return saleRows[0]; } catch (error) { // Rollback transaction on error @@ -159,7 +406,23 @@ export async function movePaymentToSales(orderId: string, paymentId: string): Pr } /** - * Check if a payment already exists in sales + * 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 { @@ -175,4 +438,3 @@ export async function paymentExistsInSales(paymentId: string): Promise throw error; } } - diff --git a/src/database/types.ts b/src/database/types.ts index 64d45e8..d29e0e9 100644 --- a/src/database/types.ts +++ b/src/database/types.ts @@ -12,6 +12,7 @@ export interface PendingOrder { price_amount: number; price_currency: string; created_at: Date; + expires_at: Date; } export interface Sale { @@ -23,3 +24,14 @@ export interface Sale { created_at: Date; } +export interface Drop { + id: number; + item: string; + size: number; + fill: number; + unit: string; // 'g' or 'kg' + image_url: string | null; + ppu: number; + created_at: Date; +} + diff --git a/src/routes/ipn.ts b/src/routes/ipn.ts index 63a31d3..0c569fa 100644 --- a/src/routes/ipn.ts +++ b/src/routes/ipn.ts @@ -1,7 +1,14 @@ import { Router, Request, Response } from 'express'; import { ipnValidationMiddleware } from '../middleware/ipnValidation'; import { NOWPaymentsIPNPayload, PaymentStatus } from '../types/nowpayments'; -import { movePaymentToSales, paymentExistsInSales, findPendingOrderByOrderId } from '../database/paymentService'; +import { + findPendingOrderByPaymentId, + findPendingOrderByOrderId, + isPendingOrderExpired, + movePaymentToSalesWithInventoryCheck, + paymentExistsInSales, + deletePendingOrderById, +} from '../database/paymentService'; const router = Router(); @@ -9,6 +16,13 @@ const router = Router(); * POST /ipn * NOWPayments IPN endpoint * Receives and processes payment notifications + * + * Follows the IPN Callback Integration Guide flow: + * 1. Find pending order by payment_id or invoice_id + * 2. Check expiration + * 3. Validate payment status + * 4. Final inventory check (for finished/confirmed) + * 5. Create sale record */ router.post('/ipn', ipnValidationMiddleware, async (req: Request, res: Response) => { try { @@ -16,176 +30,198 @@ router.post('/ipn', ipnValidationMiddleware, async (req: Request, res: Response) console.log('Received IPN notification:', { payment_id: payload.payment_id, + invoice_id: payload.invoice_id, + order_id: payload.order_id, status: payload.payment_status, amount: payload.price_amount, currency: payload.price_currency }); - // TODO: Implement your database update logic here - // Example: - // await updatePaymentInDatabase(payload); + // Step 1: Find pending order by payment_id or invoice_id + const paymentIdToFind = payload.payment_id || payload.invoice_id; + if (!paymentIdToFind) { + console.warn('IPN callback missing both payment_id and invoice_id'); + // Return 200 to acknowledge receipt + return res.status(200).json({ + success: true, + message: 'IPN received but missing payment identifiers' + }); + } - // Process different payment statuses + // Try to find by payment_id/invoice_id first + // The function checks both payment_id and invoice_id fields + let pendingOrder = await findPendingOrderByPaymentId( + paymentIdToFind, + payload.invoice_id !== payload.payment_id ? payload.invoice_id : undefined + ); + + // If not found, try by order_id (for backward compatibility) + if (!pendingOrder && payload.order_id) { + console.log(`Pending order not found by payment_id, trying order_id: ${payload.order_id}`); + pendingOrder = await findPendingOrderByOrderId(payload.order_id); + } + + // If still not found, check if sale already exists (idempotency) + if (!pendingOrder) { + const existingSale = await paymentExistsInSales(paymentIdToFind); + if (existingSale) { + console.log(`Payment ${paymentIdToFind} already processed (sale exists), skipping`); + return res.status(200).json({ + success: true, + message: 'Payment already processed', + payment_id: paymentIdToFind + }); + } + + console.warn(`No pending order found for payment_id: ${paymentIdToFind}, invoice_id: ${payload.invoice_id}, order_id: ${payload.order_id}`); + // Return 200 to acknowledge receipt (don't retry) + return res.status(200).json({ + success: true, + message: 'IPN received but pending order not found', + payment_id: paymentIdToFind + }); + } + + // Step 2: Check expiration + const isExpired = await isPendingOrderExpired(pendingOrder.id); + if (isExpired) { + console.warn(`Pending order ${pendingOrder.id} (payment_id: ${pendingOrder.payment_id}) has expired, deleting`); + await deletePendingOrderById(pendingOrder.id); + return res.status(200).json({ + success: true, + message: 'Order expired', + payment_id: paymentIdToFind + }); + } + + // Step 3: Process payment status switch (payload.payment_status) { case PaymentStatus.WAITING: - console.log(`Payment ${payload.payment_id} is waiting for payment`); - await handleWaitingPayment(payload); - break; - case PaymentStatus.CONFIRMING: - console.log(`Payment ${payload.payment_id} is being confirmed`); - await handleConfirmingPayment(payload); + // Payment in progress, just acknowledge + console.log(`Payment ${paymentIdToFind} status: ${payload.payment_status} - waiting for final status`); break; case PaymentStatus.CONFIRMED: - console.log(`Payment ${payload.payment_id} has been confirmed`); - await handleConfirmedPayment(payload); - break; - - case PaymentStatus.SENDING: - console.log(`Payment ${payload.payment_id} is being sent`); - await handleSendingPayment(payload); - break; - case PaymentStatus.FINISHED: - console.log(`Payment ${payload.payment_id} has been finished`); - await handleFinishedPayment(payload); + // Payment successful - proceed to create sale + await handleSuccessfulPayment(pendingOrder, paymentIdToFind); break; case PaymentStatus.FAILED: - console.log(`Payment ${payload.payment_id} has failed`); - await handleFailedPayment(payload); + case PaymentStatus.EXPIRED: + // Payment failed/expired - delete pending order + console.log(`Payment ${paymentIdToFind} status: ${payload.payment_status} - deleting pending order`); + await deletePendingOrderById(pendingOrder.id); break; case PaymentStatus.REFUNDED: - console.log(`Payment ${payload.payment_id} has been refunded`); - await handleRefundedPayment(payload); - break; - - case PaymentStatus.EXPIRED: - console.log(`Payment ${payload.payment_id} has expired`); - await handleExpiredPayment(payload); + // Payment refunded - delete pending order and log + console.log(`Payment ${paymentIdToFind} was refunded - deleting pending order`); + await deletePendingOrderById(pendingOrder.id); break; + case PaymentStatus.SENDING: case PaymentStatus.PARTIALLY_PAID: - console.log(`Payment ${payload.payment_id} is partially paid`); - await handlePartiallyPaidPayment(payload); + // Payment in progress - just acknowledge + console.log(`Payment ${paymentIdToFind} status: ${payload.payment_status} - waiting for completion`); break; default: - console.warn(`Unknown payment status: ${payload.payment_status}`); + console.warn(`Unknown payment status: ${payload.payment_status} for payment ${paymentIdToFind}`); } // Always return 200 OK to acknowledge receipt // NOWPayments will retry if they don't receive a 200 response - res.status(200).json({ + res.status(200).json({ success: true, message: 'IPN received and processed', - payment_id: payload.payment_id + payment_id: paymentIdToFind }); } catch (error) { console.error('Error processing IPN:', error); // Still return 200 to prevent retries for processing errors // Log the error for manual review - res.status(200).json({ + res.status(200).json({ success: false, - error: 'Error processing IPN, but notification received' + error: 'Error processing IPN, but notification received', + message: error instanceof Error ? error.message : 'Unknown error' }); } }); /** - * Payment status handlers - * Implement your business logic here + * Handle successful payment (confirmed or finished) + * Implements Steps 4 and 5 from the guide: + * - Step 4: Final inventory check + * - Step 5: Create sale record and delete pending order */ -async function handleWaitingPayment(payload: NOWPaymentsIPNPayload): Promise { - // TODO: Update database - payment is waiting - // Example: await db.payments.update({ id: payload.payment_id, status: 'waiting' }); -} - -async function handleConfirmingPayment(payload: NOWPaymentsIPNPayload): Promise { - // TODO: Update database - payment is being confirmed on blockchain - // Example: await db.payments.update({ id: payload.payment_id, status: 'confirming' }); -} - -async function handleConfirmedPayment(payload: NOWPaymentsIPNPayload): Promise { - // TODO: Update database - payment confirmed on blockchain - // Example: await db.payments.update({ id: payload.payment_id, status: 'confirmed' }); -} - -async function handleSendingPayment(payload: NOWPaymentsIPNPayload): Promise { - // TODO: Update database - payment is being sent - // Example: await db.payments.update({ id: payload.payment_id, status: 'sending' }); -} - -async function handleFinishedPayment(payload: NOWPaymentsIPNPayload): Promise { +async function handleSuccessfulPayment(pendingOrder: any, paymentId: string): Promise { try { - // Check if order_id is provided - if (!payload.order_id) { - console.warn(`No order_id provided in IPN payload for payment_id: ${payload.payment_id}`); - return; - } - - // Check if payment_id is provided - if (!payload.payment_id) { - console.warn(`No payment_id provided in IPN payload for order_id: ${payload.order_id}`); - return; - } - - // Check if payment already exists in sales (idempotency check) - const alreadyProcessed = await paymentExistsInSales(payload.payment_id); - + // Check idempotency - if sale already exists, skip + const alreadyProcessed = await paymentExistsInSales(paymentId); if (alreadyProcessed) { - console.log(`Payment ${payload.payment_id} already exists in sales, skipping`); + console.log(`Payment ${paymentId} already exists in sales (sale exists), deleting pending order and skipping`); + await deletePendingOrderById(pendingOrder.id); return; } - // Check if pending order exists by order_id - const pendingOrder = await findPendingOrderByOrderId(payload.order_id); - - if (!pendingOrder) { - console.warn(`No pending order found for order_id: ${payload.order_id} (payment_id: ${payload.payment_id})`); - return; - } + // Steps 4 & 5: Final inventory check and create sale + // This is handled inside movePaymentToSalesWithInventoryCheck + const sale = await movePaymentToSalesWithInventoryCheck(pendingOrder.id, paymentId); - // Log pending order details for debugging - console.log('Pending order found:', { - order_id: payload.order_id, - drop_id: pendingOrder.drop_id, - buyer_id: pendingOrder.buyer_id, - size: pendingOrder.size, - payment_id: pendingOrder.payment_id - }); - - // Move payment from pending_orders to sales using order_id - const sale = await movePaymentToSales(payload.order_id, payload.payment_id); - - console.log(`Order ${payload.order_id} (payment_id: ${payload.payment_id}) successfully processed. Sale ID: ${sale.id}`); + console.log(`✅ Successfully processed payment ${paymentId}. Sale ID: ${sale.id}`); } catch (error) { - console.error(`Error processing finished payment ${payload.payment_id} (order_id: ${payload.order_id}):`, error); - throw error; // Re-throw to be handled by the main error handler + // Error handling: if inventory check fails, the pending order is already deleted in the transaction + if (error instanceof Error) { + if (error.message.includes('expired') || error.message.includes('Insufficient inventory')) { + console.warn(`Payment ${paymentId} processing failed: ${error.message}`); + // Pending order is already deleted in the transaction, just log + return; + } + } + // Re-throw other errors + throw error; } } +/** + * Legacy handlers - kept for reference but not used in main flow + */ +async function handleWaitingPayment(payload: NOWPaymentsIPNPayload): Promise { + // Handled in main flow +} + +async function handleConfirmingPayment(payload: NOWPaymentsIPNPayload): Promise { + // Handled in main flow +} + +async function handleConfirmedPayment(payload: NOWPaymentsIPNPayload): Promise { + // Handled in main flow via handleSuccessfulPayment +} + +async function handleSendingPayment(payload: NOWPaymentsIPNPayload): Promise { + // Handled in main flow +} + +async function handleFinishedPayment(payload: NOWPaymentsIPNPayload): Promise { + // Handled in main flow via handleSuccessfulPayment +} + async function handleFailedPayment(payload: NOWPaymentsIPNPayload): Promise { - // TODO: Update database - payment failed - // Example: await db.payments.update({ id: payload.payment_id, status: 'failed' }); + // Handled in main flow } async function handleRefundedPayment(payload: NOWPaymentsIPNPayload): Promise { - // TODO: Update database - payment refunded - // Example: await db.payments.update({ id: payload.payment_id, status: 'refunded' }); + // Handled in main flow } async function handleExpiredPayment(payload: NOWPaymentsIPNPayload): Promise { - // TODO: Update database - payment expired - // Example: await db.payments.update({ id: payload.payment_id, status: 'expired' }); + // Handled in main flow } async function handlePartiallyPaidPayment(payload: NOWPaymentsIPNPayload): Promise { - // TODO: Update database - payment partially paid - // Example: await db.payments.update({ id: payload.payment_id, status: 'partially_paid' }); + // Handled in main flow } export default router; -