race condition
This commit is contained in:
116
README.md
116
README.md
@@ -8,8 +8,12 @@ A TypeScript Express application for receiving and processing Instant Payment No
|
|||||||
- ✅ TypeScript for type safety
|
- ✅ TypeScript for type safety
|
||||||
- ✅ Handles all NOWPayments payment statuses
|
- ✅ Handles all NOWPayments payment statuses
|
||||||
- ✅ MySQL/MariaDB database integration
|
- ✅ 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
|
- ✅ Transaction-safe database operations
|
||||||
|
- ✅ Idempotent IPN processing (handles duplicate callbacks)
|
||||||
- ✅ Error handling and logging
|
- ✅ Error handling and logging
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
@@ -43,13 +47,32 @@ Edit `.env` and configure:
|
|||||||
- `DB_PASSWORD` - Database password
|
- `DB_PASSWORD` - Database password
|
||||||
- `DB_NAME` - Database name (default: cbd420)
|
- `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
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Run the Server
|
### 5. Run the Server
|
||||||
|
|
||||||
**Development mode (with auto-reload):**
|
**Development mode (with auto-reload):**
|
||||||
```bash
|
```bash
|
||||||
@@ -104,47 +127,92 @@ The listener handles the following payment statuses:
|
|||||||
|
|
||||||
## Database Integration
|
## 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`
|
### Payment Processing Flow
|
||||||
2. **Create a sale record** - Insert the payment into the `sales` table
|
|
||||||
3. **Remove pending order** - Delete the record from `pending_orders`
|
|
||||||
|
|
||||||
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
|
### 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
|
- **`pending_orders`** - Stores pending payment orders with 10-minute reservations
|
||||||
- `payment_id` (unique) - NOWPayments payment ID
|
- `payment_id` (unique) - NOWPayments payment/invoice ID
|
||||||
- `order_id` (unique) - Your order ID
|
- `order_id` (unique) - Internal order ID (format: SALE-{timestamp}-{drop_id}-{buyer_id})
|
||||||
- `drop_id` - Reference to drops table
|
- `drop_id` - Reference to drops table
|
||||||
- `buyer_id` - Reference to buyers table
|
- `buyer_id` - Reference to buyers table
|
||||||
- `size` - Order size
|
- `size` - Order size (in grams)
|
||||||
- `price_amount` - Payment amount
|
- `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
|
- **`sales`** - Stores completed sales
|
||||||
- `drop_id` - Reference to drops table
|
- `drop_id` - Reference to drops table
|
||||||
- `buyer_id` - Reference to buyers table
|
- `buyer_id` - Reference to buyers table
|
||||||
- `size` - Sale size
|
- `size` - Sale size (in grams)
|
||||||
- `payment_id` - NOWPayments payment ID
|
- `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`
|
### Inventory Calculation
|
||||||
2. IPN notification received → Signature validated
|
|
||||||
3. Payment status `finished` → Record moved from `pending_orders` to `sales`
|
|
||||||
4. Transaction committed → Payment processing complete
|
|
||||||
|
|
||||||
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
|
## Security
|
||||||
|
|
||||||
- All IPN requests are validated using HMAC SHA512 signature verification
|
- All IPN requests are validated using HMAC SHA512 signature verification
|
||||||
- Invalid signatures are rejected with 400 Bad Request
|
- Invalid signatures are rejected with 400 Bad Request
|
||||||
- The IPN secret key should never be committed to version control
|
- The IPN secret key should never be committed to version control
|
||||||
|
- Database transactions ensure data consistency and prevent race conditions
|
||||||
|
|
||||||
## Testing
|
## 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"}'
|
-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
|
## License
|
||||||
|
|
||||||
ISC
|
ISC
|
||||||
|
|||||||
25
migrations/add_expires_at_to_pending_orders.sql
Normal file
25
migrations/add_expires_at_to_pending_orders.sql
Normal 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))
|
||||||
|
|
||||||
@@ -1,5 +1,36 @@
|
|||||||
import { pool } from './connection';
|
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
|
* Find a pending order by order_id
|
||||||
@@ -17,7 +48,163 @@ export async function findPendingOrderByOrderId(orderId: string): Promise<Pendin
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,7 +212,7 @@ export async function findPendingOrderByOrderId(orderId: string): Promise<Pendin
|
|||||||
/**
|
/**
|
||||||
* Create a sale record from a pending order
|
* 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 {
|
try {
|
||||||
const [result] = await pool.execute(
|
const [result] = await pool.execute(
|
||||||
'INSERT INTO sales (drop_id, buyer_id, size, payment_id, created_at) VALUES (?, ?, ?, ?, NOW())',
|
'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.drop_id,
|
||||||
pendingOrder.buyer_id,
|
pendingOrder.buyer_id,
|
||||||
pendingOrder.size,
|
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> {
|
export async function movePaymentToSalesWithInventoryCheck(
|
||||||
try {
|
pendingOrderId: number,
|
||||||
const [result] = await pool.execute(
|
paymentId: string
|
||||||
'DELETE FROM pending_orders WHERE order_id = ?',
|
): Promise<Sale> {
|
||||||
[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> {
|
|
||||||
const connection = await pool.getConnection();
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Start transaction
|
// Start transaction
|
||||||
await connection.beginTransaction();
|
await connection.beginTransaction();
|
||||||
|
|
||||||
// Find the pending order by order_id
|
// Get the pending order with FOR UPDATE lock
|
||||||
const [pendingRows] = await connection.execute(
|
const [pendingRows] = await connection.execute(
|
||||||
'SELECT * FROM pending_orders WHERE order_id = ? FOR UPDATE',
|
'SELECT * FROM pending_orders WHERE id = ? FOR UPDATE',
|
||||||
[orderId]
|
[pendingOrderId]
|
||||||
) as [PendingOrder[], any];
|
) as [PendingOrder[], any];
|
||||||
|
|
||||||
if (!Array.isArray(pendingRows) || pendingRows.length === 0) {
|
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];
|
const pendingOrder = pendingRows[0];
|
||||||
|
|
||||||
// Validate all required fields are present
|
// Check expiration
|
||||||
if (pendingOrder.drop_id === undefined || pendingOrder.drop_id === null) {
|
const [expiredCheckRows] = await connection.execute(
|
||||||
throw new Error(`Pending order missing drop_id for order_id: ${orderId}`);
|
'SELECT * FROM pending_orders WHERE id = ? AND expires_at > NOW()',
|
||||||
}
|
[pendingOrderId]
|
||||||
if (pendingOrder.buyer_id === undefined || pendingOrder.buyer_id === null) {
|
) as [PendingOrder[], any];
|
||||||
throw new Error(`Pending order missing buyer_id for order_id: ${orderId}`);
|
|
||||||
}
|
if (!Array.isArray(expiredCheckRows) || expiredCheckRows.length === 0) {
|
||||||
if (pendingOrder.size === undefined || pendingOrder.size === null) {
|
// Order expired, delete it and throw error
|
||||||
throw new Error(`Pending order missing size for order_id: ${orderId}`);
|
await connection.execute('DELETE FROM pending_orders WHERE id = ?', [pendingOrderId]);
|
||||||
}
|
await connection.rollback();
|
||||||
if (!paymentId) {
|
throw new Error(`Pending order ${pendingOrderId} has expired`);
|
||||||
throw new Error(`Payment ID is required but was ${paymentId} for order_id: ${orderId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(
|
const [insertResult] = await connection.execute(
|
||||||
'INSERT INTO sales (drop_id, buyer_id, size, payment_id, created_at) VALUES (?, ?, ?, ?, NOW())',
|
'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 insert = insertResult as any;
|
||||||
const saleId = insert.insertId;
|
const saleId = insert.insertId;
|
||||||
|
|
||||||
// Delete pending order by order_id
|
// Delete pending order
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
'DELETE FROM pending_orders WHERE order_id = ?',
|
'DELETE FROM pending_orders WHERE id = ?',
|
||||||
[orderId]
|
[pendingOrderId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch the created sale
|
// Fetch the created sale
|
||||||
@@ -145,7 +392,7 @@ export async function movePaymentToSales(orderId: string, paymentId: string): Pr
|
|||||||
// Commit transaction
|
// Commit transaction
|
||||||
await connection.commit();
|
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];
|
return saleRows[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Rollback transaction on 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> {
|
export async function paymentExistsInSales(paymentId: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -175,4 +438,3 @@ export async function paymentExistsInSales(paymentId: string): Promise<boolean>
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface PendingOrder {
|
|||||||
price_amount: number;
|
price_amount: number;
|
||||||
price_currency: string;
|
price_currency: string;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
|
expires_at: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Sale {
|
export interface Sale {
|
||||||
@@ -23,3 +24,14 @@ export interface Sale {
|
|||||||
created_at: Date;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { ipnValidationMiddleware } from '../middleware/ipnValidation';
|
import { ipnValidationMiddleware } from '../middleware/ipnValidation';
|
||||||
import { NOWPaymentsIPNPayload, PaymentStatus } from '../types/nowpayments';
|
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();
|
const router = Router();
|
||||||
|
|
||||||
@@ -9,6 +16,13 @@ const router = Router();
|
|||||||
* POST /ipn
|
* POST /ipn
|
||||||
* NOWPayments IPN endpoint
|
* NOWPayments IPN endpoint
|
||||||
* Receives and processes payment notifications
|
* 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) => {
|
router.post('/ipn', ipnValidationMiddleware, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -16,64 +30,105 @@ router.post('/ipn', ipnValidationMiddleware, async (req: Request, res: Response)
|
|||||||
|
|
||||||
console.log('Received IPN notification:', {
|
console.log('Received IPN notification:', {
|
||||||
payment_id: payload.payment_id,
|
payment_id: payload.payment_id,
|
||||||
|
invoice_id: payload.invoice_id,
|
||||||
|
order_id: payload.order_id,
|
||||||
status: payload.payment_status,
|
status: payload.payment_status,
|
||||||
amount: payload.price_amount,
|
amount: payload.price_amount,
|
||||||
currency: payload.price_currency
|
currency: payload.price_currency
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Implement your database update logic here
|
// Step 1: Find pending order by payment_id or invoice_id
|
||||||
// Example:
|
const paymentIdToFind = payload.payment_id || payload.invoice_id;
|
||||||
// await updatePaymentInDatabase(payload);
|
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) {
|
switch (payload.payment_status) {
|
||||||
case PaymentStatus.WAITING:
|
case PaymentStatus.WAITING:
|
||||||
console.log(`Payment ${payload.payment_id} is waiting for payment`);
|
|
||||||
await handleWaitingPayment(payload);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case PaymentStatus.CONFIRMING:
|
case PaymentStatus.CONFIRMING:
|
||||||
console.log(`Payment ${payload.payment_id} is being confirmed`);
|
// Payment in progress, just acknowledge
|
||||||
await handleConfirmingPayment(payload);
|
console.log(`Payment ${paymentIdToFind} status: ${payload.payment_status} - waiting for final status`);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PaymentStatus.CONFIRMED:
|
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:
|
case PaymentStatus.FINISHED:
|
||||||
console.log(`Payment ${payload.payment_id} has been finished`);
|
// Payment successful - proceed to create sale
|
||||||
await handleFinishedPayment(payload);
|
await handleSuccessfulPayment(pendingOrder, paymentIdToFind);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PaymentStatus.FAILED:
|
case PaymentStatus.FAILED:
|
||||||
console.log(`Payment ${payload.payment_id} has failed`);
|
case PaymentStatus.EXPIRED:
|
||||||
await handleFailedPayment(payload);
|
// Payment failed/expired - delete pending order
|
||||||
|
console.log(`Payment ${paymentIdToFind} status: ${payload.payment_status} - deleting pending order`);
|
||||||
|
await deletePendingOrderById(pendingOrder.id);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PaymentStatus.REFUNDED:
|
case PaymentStatus.REFUNDED:
|
||||||
console.log(`Payment ${payload.payment_id} has been refunded`);
|
// Payment refunded - delete pending order and log
|
||||||
await handleRefundedPayment(payload);
|
console.log(`Payment ${paymentIdToFind} was refunded - deleting pending order`);
|
||||||
break;
|
await deletePendingOrderById(pendingOrder.id);
|
||||||
|
|
||||||
case PaymentStatus.EXPIRED:
|
|
||||||
console.log(`Payment ${payload.payment_id} has expired`);
|
|
||||||
await handleExpiredPayment(payload);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case PaymentStatus.SENDING:
|
||||||
case PaymentStatus.PARTIALLY_PAID:
|
case PaymentStatus.PARTIALLY_PAID:
|
||||||
console.log(`Payment ${payload.payment_id} is partially paid`);
|
// Payment in progress - just acknowledge
|
||||||
await handlePartiallyPaidPayment(payload);
|
console.log(`Payment ${paymentIdToFind} status: ${payload.payment_status} - waiting for completion`);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
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
|
// Always return 200 OK to acknowledge receipt
|
||||||
@@ -81,7 +136,7 @@ router.post('/ipn', ipnValidationMiddleware, async (req: Request, res: Response)
|
|||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'IPN received and processed',
|
message: 'IPN received and processed',
|
||||||
payment_id: payload.payment_id
|
payment_id: paymentIdToFind
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing IPN:', 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
|
// Log the error for manual review
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: false,
|
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
|
* Handle successful payment (confirmed or finished)
|
||||||
* Implement your business logic here
|
* Implements Steps 4 and 5 from the guide:
|
||||||
|
* - Step 4: Final inventory check
|
||||||
|
* - Step 5: Create sale record and delete pending order
|
||||||
|
*/
|
||||||
|
async function handleSuccessfulPayment(pendingOrder: any, paymentId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Check idempotency - if sale already exists, skip
|
||||||
|
const alreadyProcessed = await paymentExistsInSales(paymentId);
|
||||||
|
if (alreadyProcessed) {
|
||||||
|
console.log(`Payment ${paymentId} already exists in sales (sale exists), deleting pending order and skipping`);
|
||||||
|
await deletePendingOrderById(pendingOrder.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steps 4 & 5: Final inventory check and create sale
|
||||||
|
// This is handled inside movePaymentToSalesWithInventoryCheck
|
||||||
|
const sale = await movePaymentToSalesWithInventoryCheck(pendingOrder.id, paymentId);
|
||||||
|
|
||||||
|
console.log(`✅ Successfully processed payment ${paymentId}. Sale ID: ${sale.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
// 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> {
|
async function handleWaitingPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
||||||
// TODO: Update database - payment is waiting
|
// Handled in main flow
|
||||||
// Example: await db.payments.update({ id: payload.payment_id, status: 'waiting' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConfirmingPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
async function handleConfirmingPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
||||||
// TODO: Update database - payment is being confirmed on blockchain
|
// Handled in main flow
|
||||||
// Example: await db.payments.update({ id: payload.payment_id, status: 'confirming' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConfirmedPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
async function handleConfirmedPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
||||||
// TODO: Update database - payment confirmed on blockchain
|
// Handled in main flow via handleSuccessfulPayment
|
||||||
// Example: await db.payments.update({ id: payload.payment_id, status: 'confirmed' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSendingPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
async function handleSendingPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
||||||
// TODO: Update database - payment is being sent
|
// Handled in main flow
|
||||||
// Example: await db.payments.update({ id: payload.payment_id, status: 'sending' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFinishedPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
async function handleFinishedPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
||||||
try {
|
// Handled in main flow via handleSuccessfulPayment
|
||||||
// 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);
|
|
||||||
|
|
||||||
if (alreadyProcessed) {
|
|
||||||
console.log(`Payment ${payload.payment_id} already exists in sales, skipping`);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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}`);
|
|
||||||
} 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFailedPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
async function handleFailedPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
||||||
// TODO: Update database - payment failed
|
// Handled in main flow
|
||||||
// Example: await db.payments.update({ id: payload.payment_id, status: 'failed' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRefundedPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
async function handleRefundedPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
||||||
// TODO: Update database - payment refunded
|
// Handled in main flow
|
||||||
// Example: await db.payments.update({ id: payload.payment_id, status: 'refunded' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExpiredPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
async function handleExpiredPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
||||||
// TODO: Update database - payment expired
|
// Handled in main flow
|
||||||
// Example: await db.payments.update({ id: payload.payment_id, status: 'expired' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePartiallyPaidPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
async function handlePartiallyPaidPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
||||||
// TODO: Update database - payment partially paid
|
// Handled in main flow
|
||||||
// Example: await db.payments.update({ id: payload.payment_id, status: 'partially_paid' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user