race condition

This commit is contained in:
root
2025-12-21 09:57:56 +01:00
parent 02830aa7df
commit 4686aa1ef3
5 changed files with 585 additions and 178 deletions

116
README.md
View File

@@ -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

View File

@@ -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))

View File

@@ -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<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
@@ -17,7 +48,163 @@ export async function findPendingOrderByOrderId(orderId: string): Promise<Pendin
return null;
} catch (error) {
console.error('Error finding pending order:', 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;
}
}
@@ -25,7 +212,7 @@ export async function findPendingOrderByOrderId(orderId: string): Promise<Pendin
/**
* Create a sale record from a pending order
*/
export async function createSaleFromPendingOrder(pendingOrder: PendingOrder): Promise<Sale> {
export async function createSaleFromPendingOrder(pendingOrder: PendingOrder, paymentId: string): Promise<Sale> {
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<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:', 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<Sale> {
export async function movePaymentToSalesWithInventoryCheck(
pendingOrderId: number,
paymentId: string
): Promise<Sale> {
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<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 {
@@ -175,4 +438,3 @@ export async function paymentExistsInSales(paymentId: string): Promise<boolean>
throw error;
}
}

View File

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

View File

@@ -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,64 +30,105 @@ 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
@@ -81,7 +136,7 @@ router.post('/ipn', ipnValidationMiddleware, async (req: Request, res: Response)
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);
@@ -89,103 +144,84 @@ router.post('/ipn', ipnValidationMiddleware, async (req: Request, res: Response)
// Log the error for manual review
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<void> {
// TODO: Update database - payment is waiting
// Example: await db.payments.update({ id: payload.payment_id, status: 'waiting' });
}
async function handleConfirmingPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
// 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<void> {
// TODO: Update database - payment confirmed on blockchain
// Example: await db.payments.update({ id: payload.payment_id, status: 'confirmed' });
}
async function handleSendingPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
// TODO: Update database - payment is being sent
// Example: await db.payments.update({ id: payload.payment_id, status: 'sending' });
}
async function handleFinishedPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
async function handleSuccessfulPayment(pendingOrder: any, paymentId: string): Promise<void> {
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);
// Steps 4 & 5: Final inventory check and create sale
// This is handled inside movePaymentToSalesWithInventoryCheck
const sale = await movePaymentToSalesWithInventoryCheck(pendingOrder.id, paymentId);
if (!pendingOrder) {
console.warn(`No pending order found for order_id: ${payload.order_id} (payment_id: ${payload.payment_id})`);
return;
}
// 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<void> {
// Handled in main flow
}
async function handleConfirmingPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
// Handled in main flow
}
async function handleConfirmedPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
// Handled in main flow via handleSuccessfulPayment
}
async function handleSendingPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
// Handled in main flow
}
async function handleFinishedPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
// Handled in main flow via handleSuccessfulPayment
}
async function handleFailedPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
// 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<void> {
// 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<void> {
// 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<void> {
// 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;