init
This commit is contained in:
16
.env.example
Normal file
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# NOWPayments IPN Secret Key
|
||||||
|
# Get this from your NOWPayments dashboard -> Store Settings -> IPN Secret Key
|
||||||
|
NOWPAYMENTS_IPN_SECRET_KEY=your_ipn_secret_key_here
|
||||||
|
|
||||||
|
# Server Port (optional, defaults to 3000)
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Node Environment
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASSWORD=your_database_password
|
||||||
|
DB_NAME=cbd420
|
||||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
167
README.md
Normal file
167
README.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# NOWPayments IPN Listener
|
||||||
|
|
||||||
|
A TypeScript Express application for receiving and processing Instant Payment Notifications (IPN) from NOWPayments.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ Secure IPN signature validation using HMAC SHA512
|
||||||
|
- ✅ TypeScript for type safety
|
||||||
|
- ✅ Handles all NOWPayments payment statuses
|
||||||
|
- ✅ MySQL/MariaDB database integration
|
||||||
|
- ✅ Automatic payment processing: moves finished payments from `pending_orders` to `sales`
|
||||||
|
- ✅ Transaction-safe database operations
|
||||||
|
- ✅ Error handling and logging
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure Environment Variables
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and fill in your NOWPayments IPN Secret Key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` and configure:
|
||||||
|
|
||||||
|
**NOWPayments IPN Secret Key:**
|
||||||
|
- Go to NOWPayments Dashboard → Store Settings → IPN Secret Key
|
||||||
|
- Generate a new key if you don't have one
|
||||||
|
- Add it to the `.env` file
|
||||||
|
|
||||||
|
**Database Configuration:**
|
||||||
|
- Set your MySQL/MariaDB connection details:
|
||||||
|
- `DB_HOST` - Database host (default: localhost)
|
||||||
|
- `DB_PORT` - Database port (default: 3306)
|
||||||
|
- `DB_USER` - Database username
|
||||||
|
- `DB_PASSWORD` - Database password
|
||||||
|
- `DB_NAME` - Database name (default: cbd420)
|
||||||
|
|
||||||
|
### 3. Build the Project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run the Server
|
||||||
|
|
||||||
|
**Development mode (with auto-reload):**
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production mode:**
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will start on port 3000 (or the port specified in your `.env` file).
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### POST `/ipn`
|
||||||
|
Receives IPN notifications from NOWPayments. This endpoint:
|
||||||
|
- Validates the request signature
|
||||||
|
- Processes payment status updates
|
||||||
|
- Returns 200 OK to acknowledge receipt
|
||||||
|
|
||||||
|
### GET `/health`
|
||||||
|
Health check endpoint to verify the server is running.
|
||||||
|
|
||||||
|
## IPN Callback URL Setup
|
||||||
|
|
||||||
|
When creating a payment via the NOWPayments API, include the `ipn_callback_url` parameter:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
"price_amount": 100,
|
||||||
|
"price_currency": "usd",
|
||||||
|
"pay_currency": "btc",
|
||||||
|
"ipn_callback_url": "https://yourdomain.com/ipn",
|
||||||
|
// ... other parameters
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Payment Statuses
|
||||||
|
|
||||||
|
The listener handles the following payment statuses:
|
||||||
|
|
||||||
|
- `waiting` - Payment is waiting
|
||||||
|
- `confirming` - Payment is being confirmed on blockchain
|
||||||
|
- `confirmed` - Payment confirmed on blockchain
|
||||||
|
- `sending` - Payment is being sent
|
||||||
|
- `partially_paid` - Payment partially received
|
||||||
|
- `finished` - Payment completed successfully
|
||||||
|
- `failed` - Payment failed
|
||||||
|
- `refunded` - Payment refunded
|
||||||
|
- `expired` - Payment expired
|
||||||
|
|
||||||
|
## Database Integration
|
||||||
|
|
||||||
|
The application automatically integrates with your MySQL/MariaDB database. When a payment status is `finished`, the system will:
|
||||||
|
|
||||||
|
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`
|
||||||
|
|
||||||
|
All operations are performed within a database transaction to ensure data consistency.
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
The application expects the following tables (as defined in `cbd420(1).sql`):
|
||||||
|
|
||||||
|
- **`pending_orders`** - Stores pending payment orders
|
||||||
|
- `payment_id` (unique) - NOWPayments payment ID
|
||||||
|
- `order_id` (unique) - Your order ID
|
||||||
|
- `drop_id` - Reference to drops table
|
||||||
|
- `buyer_id` - Reference to buyers table
|
||||||
|
- `size` - Order size
|
||||||
|
- `price_amount` - Payment amount
|
||||||
|
- `price_currency` - Payment currency
|
||||||
|
|
||||||
|
- **`sales`** - Stores completed sales
|
||||||
|
- `drop_id` - Reference to drops table
|
||||||
|
- `buyer_id` - Reference to buyers table
|
||||||
|
- `size` - Sale size
|
||||||
|
- `payment_id` - NOWPayments payment ID
|
||||||
|
|
||||||
|
### Payment Processing Flow
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
The system includes idempotency checks to prevent duplicate processing if the same IPN notification is received multiple times.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
You can test the IPN endpoint using tools like:
|
||||||
|
- Postman
|
||||||
|
- curl
|
||||||
|
- NOWPayments test payments
|
||||||
|
|
||||||
|
Example curl command (with test signature):
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/ipn \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-nowpayments-sig: your_signature_here" \
|
||||||
|
-d '{"payment_id":"test123","payment_status":"waiting","price_amount":100,"price_currency":"usd"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
ISC
|
||||||
|
|
||||||
202
cbd420(1).sql
Normal file
202
cbd420(1).sql
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
-- phpMyAdmin SQL Dump
|
||||||
|
-- version 5.2.1deb1+deb12u1
|
||||||
|
-- https://www.phpmyadmin.net/
|
||||||
|
--
|
||||||
|
-- Host: localhost:3306
|
||||||
|
-- Generation Time: Dec 20, 2025 at 05:20 PM
|
||||||
|
-- Server version: 10.11.14-MariaDB-0+deb12u2
|
||||||
|
-- PHP Version: 8.2.29
|
||||||
|
|
||||||
|
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||||
|
START TRANSACTION;
|
||||||
|
SET time_zone = "+00:00";
|
||||||
|
|
||||||
|
|
||||||
|
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||||
|
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||||
|
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||||
|
/*!40101 SET NAMES utf8mb4 */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Database: `cbd420`
|
||||||
|
--
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `buyers`
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE `buyers` (
|
||||||
|
`id` int(11) NOT NULL,
|
||||||
|
`username` varchar(255) NOT NULL,
|
||||||
|
`password` varchar(255) NOT NULL,
|
||||||
|
`email` varchar(255) NOT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `deliveries`
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE `deliveries` (
|
||||||
|
`id` int(11) NOT NULL,
|
||||||
|
`sale_id` int(11) NOT NULL,
|
||||||
|
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
|
||||||
|
`status` text NOT NULL DEFAULT 'Pending'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `drops`
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE `drops` (
|
||||||
|
`id` int(11) NOT NULL,
|
||||||
|
`item` text NOT NULL,
|
||||||
|
`size` int(11) NOT NULL DEFAULT 100,
|
||||||
|
`fill` int(11) NOT NULL DEFAULT 0,
|
||||||
|
`unit` varchar(12) NOT NULL DEFAULT 'g',
|
||||||
|
`image_url` varchar(255) DEFAULT NULL,
|
||||||
|
`ppu` int(11) NOT NULL DEFAULT 1,
|
||||||
|
`created_at` datetime NOT NULL DEFAULT current_timestamp()
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `pending_orders`
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE `pending_orders` (
|
||||||
|
`id` int(11) NOT NULL,
|
||||||
|
`payment_id` varchar(255) NOT NULL,
|
||||||
|
`order_id` varchar(255) NOT NULL,
|
||||||
|
`drop_id` int(11) NOT NULL,
|
||||||
|
`buyer_id` int(11) NOT NULL,
|
||||||
|
`size` int(11) NOT NULL,
|
||||||
|
`price_amount` decimal(10,2) NOT NULL,
|
||||||
|
`price_currency` varchar(10) NOT NULL DEFAULT 'chf',
|
||||||
|
`created_at` datetime NOT NULL DEFAULT current_timestamp()
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `sales`
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE `sales` (
|
||||||
|
`id` int(11) NOT NULL,
|
||||||
|
`drop_id` int(11) NOT NULL,
|
||||||
|
`buyer_id` int(11) NOT NULL,
|
||||||
|
`size` int(11) NOT NULL DEFAULT 1,
|
||||||
|
`payment_id` text NOT NULL DEFAULT '',
|
||||||
|
`created_at` datetime NOT NULL DEFAULT current_timestamp()
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Indexes for dumped tables
|
||||||
|
--
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Indexes for table `buyers`
|
||||||
|
--
|
||||||
|
ALTER TABLE `buyers`
|
||||||
|
ADD PRIMARY KEY (`id`);
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Indexes for table `deliveries`
|
||||||
|
--
|
||||||
|
ALTER TABLE `deliveries`
|
||||||
|
ADD PRIMARY KEY (`id`),
|
||||||
|
ADD KEY `sale_id` (`sale_id`);
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Indexes for table `drops`
|
||||||
|
--
|
||||||
|
ALTER TABLE `drops`
|
||||||
|
ADD PRIMARY KEY (`id`);
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Indexes for table `pending_orders`
|
||||||
|
--
|
||||||
|
ALTER TABLE `pending_orders`
|
||||||
|
ADD PRIMARY KEY (`id`),
|
||||||
|
ADD UNIQUE KEY `payment_id` (`payment_id`),
|
||||||
|
ADD UNIQUE KEY `order_id` (`order_id`),
|
||||||
|
ADD KEY `drop_id` (`drop_id`),
|
||||||
|
ADD KEY `buyer_id` (`buyer_id`);
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Indexes for table `sales`
|
||||||
|
--
|
||||||
|
ALTER TABLE `sales`
|
||||||
|
ADD PRIMARY KEY (`id`),
|
||||||
|
ADD KEY `drop_id` (`drop_id`),
|
||||||
|
ADD KEY `buyer_id` (`buyer_id`);
|
||||||
|
|
||||||
|
--
|
||||||
|
-- AUTO_INCREMENT for dumped tables
|
||||||
|
--
|
||||||
|
|
||||||
|
--
|
||||||
|
-- AUTO_INCREMENT for table `buyers`
|
||||||
|
--
|
||||||
|
ALTER TABLE `buyers`
|
||||||
|
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- AUTO_INCREMENT for table `deliveries`
|
||||||
|
--
|
||||||
|
ALTER TABLE `deliveries`
|
||||||
|
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- AUTO_INCREMENT for table `drops`
|
||||||
|
--
|
||||||
|
ALTER TABLE `drops`
|
||||||
|
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- AUTO_INCREMENT for table `pending_orders`
|
||||||
|
--
|
||||||
|
ALTER TABLE `pending_orders`
|
||||||
|
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- AUTO_INCREMENT for table `sales`
|
||||||
|
--
|
||||||
|
ALTER TABLE `sales`
|
||||||
|
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Constraints for dumped tables
|
||||||
|
--
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Constraints for table `deliveries`
|
||||||
|
--
|
||||||
|
ALTER TABLE `deliveries`
|
||||||
|
ADD CONSTRAINT `deliveries_ibfk_1` FOREIGN KEY (`sale_id`) REFERENCES `sales` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Constraints for table `pending_orders`
|
||||||
|
--
|
||||||
|
ALTER TABLE `pending_orders`
|
||||||
|
ADD CONSTRAINT `pending_orders_ibfk_1` FOREIGN KEY (`drop_id`) REFERENCES `drops` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
ADD CONSTRAINT `pending_orders_ibfk_2` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Constraints for table `sales`
|
||||||
|
--
|
||||||
|
ALTER TABLE `sales`
|
||||||
|
ADD CONSTRAINT `sales_ibfk_1` FOREIGN KEY (`drop_id`) REFERENCES `drops` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
ADD CONSTRAINT `sales_ibfk_2` FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||||
|
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||||
|
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||||
1841
package-lock.json
generated
Normal file
1841
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "cbd420-ipn-listener",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "NOWPayments IPN listener for payment validation and database updates",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||||
|
"watch": "tsc --watch"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"nowpayments",
|
||||||
|
"ipn",
|
||||||
|
"webhook",
|
||||||
|
"payment"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"crypto": "^1.0.1",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"mysql2": "^3.16.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/database/connection.ts
Normal file
50
src/database/connection.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database connection configuration
|
||||||
|
*/
|
||||||
|
const dbConfig = {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '3306', 10),
|
||||||
|
user: process.env.DB_USER || 'root',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
database: process.env.DB_NAME || 'cbd420',
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0,
|
||||||
|
enableKeepAlive: true,
|
||||||
|
keepAliveInitialDelay: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create MySQL connection pool
|
||||||
|
*/
|
||||||
|
export const pool = mysql.createPool(dbConfig);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test database connection
|
||||||
|
*/
|
||||||
|
export async function testConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
await connection.ping();
|
||||||
|
connection.release();
|
||||||
|
console.log('✅ Database connection successful');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Database connection failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close database connection pool
|
||||||
|
*/
|
||||||
|
export async function closeConnection(): Promise<void> {
|
||||||
|
await pool.end();
|
||||||
|
console.log('Database connection pool closed');
|
||||||
|
}
|
||||||
|
|
||||||
178
src/database/paymentService.ts
Normal file
178
src/database/paymentService.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { pool } from './connection';
|
||||||
|
import { PendingOrder, Sale } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a pending order by order_id
|
||||||
|
*/
|
||||||
|
export async function findPendingOrderByOrderId(orderId: string): Promise<PendingOrder | null> {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
'SELECT * FROM pending_orders WHERE order_id = ?',
|
||||||
|
[orderId]
|
||||||
|
) as [PendingOrder[], any];
|
||||||
|
|
||||||
|
if (Array.isArray(rows) && rows.length > 0) {
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error finding pending order:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a sale record from a pending order
|
||||||
|
*/
|
||||||
|
export async function createSaleFromPendingOrder(pendingOrder: PendingOrder): Promise<Sale> {
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
'INSERT INTO sales (drop_id, buyer_id, size, payment_id, created_at) VALUES (?, ?, ?, ?, NOW())',
|
||||||
|
[
|
||||||
|
pendingOrder.drop_id,
|
||||||
|
pendingOrder.buyer_id,
|
||||||
|
pendingOrder.size,
|
||||||
|
pendingOrder.payment_id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertResult = result as any;
|
||||||
|
const saleId = insertResult.insertId;
|
||||||
|
|
||||||
|
// Fetch the created sale
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
'SELECT * FROM sales WHERE id = ?',
|
||||||
|
[saleId]
|
||||||
|
) as [Sale[], any];
|
||||||
|
|
||||||
|
if (Array.isArray(rows) && rows.length > 0) {
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Failed to retrieve created sale');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating sale:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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:', 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();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start transaction
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
// Find the pending order by order_id
|
||||||
|
const [pendingRows] = await connection.execute(
|
||||||
|
'SELECT * FROM pending_orders WHERE order_id = ? FOR UPDATE',
|
||||||
|
[orderId]
|
||||||
|
) as [PendingOrder[], any];
|
||||||
|
|
||||||
|
if (!Array.isArray(pendingRows) || pendingRows.length === 0) {
|
||||||
|
throw new Error(`Pending order not found for order_id: ${orderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sale record
|
||||||
|
const [insertResult] = await connection.execute(
|
||||||
|
'INSERT INTO sales (drop_id, buyer_id, size, payment_id, created_at) VALUES (?, ?, ?, ?, NOW())',
|
||||||
|
[
|
||||||
|
pendingOrder.drop_id,
|
||||||
|
pendingOrder.buyer_id,
|
||||||
|
pendingOrder.size,
|
||||||
|
paymentId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const insert = insertResult as any;
|
||||||
|
const saleId = insert.insertId;
|
||||||
|
|
||||||
|
// Delete pending order by order_id
|
||||||
|
await connection.execute(
|
||||||
|
'DELETE FROM pending_orders WHERE order_id = ?',
|
||||||
|
[orderId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch the created sale
|
||||||
|
const [saleRows] = await connection.execute(
|
||||||
|
'SELECT * FROM sales WHERE id = ?',
|
||||||
|
[saleId]
|
||||||
|
) as [Sale[], any];
|
||||||
|
|
||||||
|
if (!Array.isArray(saleRows) || saleRows.length === 0) {
|
||||||
|
throw new Error('Failed to retrieve created sale');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit transaction
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
console.log(`✅ Successfully moved order ${orderId} (payment_id: ${paymentId}) from pending_orders to sales (sale_id: ${saleId})`);
|
||||||
|
return saleRows[0];
|
||||||
|
} catch (error) {
|
||||||
|
// Rollback transaction on error
|
||||||
|
await connection.rollback();
|
||||||
|
console.error('Error moving payment to sales:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
// Release connection back to pool
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a payment already exists in sales
|
||||||
|
*/
|
||||||
|
export async function paymentExistsInSales(paymentId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
'SELECT COUNT(*) as count FROM sales WHERE payment_id = ?',
|
||||||
|
[paymentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = rows as any[];
|
||||||
|
return result[0]?.count > 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking sale existence:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
25
src/database/types.ts
Normal file
25
src/database/types.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Database type definitions
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PendingOrder {
|
||||||
|
id: number;
|
||||||
|
payment_id: string;
|
||||||
|
order_id: string;
|
||||||
|
drop_id: number;
|
||||||
|
buyer_id: number;
|
||||||
|
size: number;
|
||||||
|
price_amount: number;
|
||||||
|
price_currency: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sale {
|
||||||
|
id: number;
|
||||||
|
drop_id: number;
|
||||||
|
buyer_id: number;
|
||||||
|
size: number;
|
||||||
|
payment_id: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
63
src/index.ts
Normal file
63
src/index.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import express, { Express, Request, Response } from 'express';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import ipnRouter from './routes/ipn';
|
||||||
|
import { testConnection } from './database/connection';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app: Express = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Request logging middleware
|
||||||
|
app.use((req: Request, res: Response, next) => {
|
||||||
|
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', (req: Request, res: Response) => {
|
||||||
|
res.status(200).json({
|
||||||
|
status: 'ok',
|
||||||
|
service: 'NOWPayments IPN Listener',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// IPN routes
|
||||||
|
app.use('/', ipnRouter);
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use((req: Request, res: Response) => {
|
||||||
|
res.status(404).json({ error: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
app.use((err: Error, req: Request, res: Response, next: any) => {
|
||||||
|
console.error('Unhandled error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(PORT, async () => {
|
||||||
|
console.log(`🚀 NOWPayments IPN Listener running on port ${PORT}`);
|
||||||
|
console.log(`📡 IPN endpoint: http://localhost:${PORT}/ipn`);
|
||||||
|
console.log(`💚 Health check: http://localhost:${PORT}/health`);
|
||||||
|
|
||||||
|
// Test database connection
|
||||||
|
await testConnection();
|
||||||
|
|
||||||
|
// Check environment variables
|
||||||
|
if (!process.env.NOWPAYMENTS_IPN_SECRET_KEY) {
|
||||||
|
console.warn('⚠️ WARNING: NOWPAYMENTS_IPN_SECRET_KEY is not set!');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.DB_HOST || !process.env.DB_NAME) {
|
||||||
|
console.warn('⚠️ WARNING: Database configuration may be incomplete!');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
108
src/middleware/ipnValidation.ts
Normal file
108
src/middleware/ipnValidation.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { NOWPaymentsIPNPayload, IPNValidationResult } from '../types/nowpayments';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively sorts object keys to ensure consistent ordering for signature calculation
|
||||||
|
*/
|
||||||
|
function sortObject(obj: any): any {
|
||||||
|
if (obj === null || typeof obj !== 'object') {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(item => sortObject(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(obj)
|
||||||
|
.sort()
|
||||||
|
.reduce((result: any, key: string) => {
|
||||||
|
result[key] = sortObject(obj[key]);
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates NOWPayments IPN signature using HMAC SHA512
|
||||||
|
*/
|
||||||
|
export function validateIPNSignature(
|
||||||
|
payload: NOWPaymentsIPNPayload,
|
||||||
|
receivedSignature: string,
|
||||||
|
secretKey: string
|
||||||
|
): IPNValidationResult {
|
||||||
|
if (!receivedSignature) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: 'Signature header missing'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!secretKey) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: 'IPN secret key not configured'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Sort the payload object recursively
|
||||||
|
const sortedPayload = sortObject(payload);
|
||||||
|
|
||||||
|
// Serialize to JSON string
|
||||||
|
const serializedPayload = JSON.stringify(sortedPayload);
|
||||||
|
|
||||||
|
// Calculate HMAC SHA512 signature
|
||||||
|
const hmac = crypto.createHmac('sha512', secretKey);
|
||||||
|
hmac.update(serializedPayload);
|
||||||
|
const calculatedSignature = hmac.digest('hex');
|
||||||
|
|
||||||
|
// Compare signatures using constant-time comparison to prevent timing attacks
|
||||||
|
const isValid = crypto.timingSafeEqual(
|
||||||
|
Buffer.from(calculatedSignature),
|
||||||
|
Buffer.from(receivedSignature)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: 'Invalid signature'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: `Signature validation error: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express middleware to validate NOWPayments IPN requests
|
||||||
|
*/
|
||||||
|
export function ipnValidationMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const secretKey = process.env.NOWPAYMENTS_IPN_SECRET_KEY;
|
||||||
|
|
||||||
|
if (!secretKey) {
|
||||||
|
console.error('NOWPAYMENTS_IPN_SECRET_KEY is not set in environment variables');
|
||||||
|
res.status(500).json({ error: 'Server configuration error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const receivedSignature = req.headers['x-nowpayments-sig'] as string;
|
||||||
|
const payload = req.body as NOWPaymentsIPNPayload;
|
||||||
|
|
||||||
|
const validation = validateIPNSignature(payload, receivedSignature, secretKey);
|
||||||
|
|
||||||
|
if (!validation.isValid) {
|
||||||
|
console.warn('Invalid IPN signature:', validation.error);
|
||||||
|
res.status(400).json({ error: validation.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach validated payload to request object
|
||||||
|
(req as any).validatedIPN = payload;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
191
src/routes/ipn.ts
Normal file
191
src/routes/ipn.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { ipnValidationMiddleware } from '../middleware/ipnValidation';
|
||||||
|
import { NOWPaymentsIPNPayload, PaymentStatus } from '../types/nowpayments';
|
||||||
|
import { movePaymentToSales, paymentExistsInSales, findPendingOrderByOrderId } from '../database/paymentService';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /ipn
|
||||||
|
* NOWPayments IPN endpoint
|
||||||
|
* Receives and processes payment notifications
|
||||||
|
*/
|
||||||
|
router.post('/ipn', ipnValidationMiddleware, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const payload = (req as any).validatedIPN as NOWPaymentsIPNPayload;
|
||||||
|
|
||||||
|
console.log('Received IPN notification:', {
|
||||||
|
payment_id: payload.payment_id,
|
||||||
|
status: payload.payment_status,
|
||||||
|
amount: payload.price_amount,
|
||||||
|
currency: payload.price_currency
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implement your database update logic here
|
||||||
|
// Example:
|
||||||
|
// await updatePaymentInDatabase(payload);
|
||||||
|
|
||||||
|
// Process different payment statuses
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PaymentStatus.FAILED:
|
||||||
|
console.log(`Payment ${payload.payment_id} has failed`);
|
||||||
|
await handleFailedPayment(payload);
|
||||||
|
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);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PaymentStatus.PARTIALLY_PAID:
|
||||||
|
console.log(`Payment ${payload.payment_id} is partially paid`);
|
||||||
|
await handlePartiallyPaidPayment(payload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown payment status: ${payload.payment_status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always return 200 OK to acknowledge receipt
|
||||||
|
// NOWPayments will retry if they don't receive a 200 response
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'IPN received and processed',
|
||||||
|
payment_id: payload.payment_id
|
||||||
|
});
|
||||||
|
} 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({
|
||||||
|
success: false,
|
||||||
|
error: 'Error processing IPN, but notification received'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payment status handlers
|
||||||
|
* Implement your business logic here
|
||||||
|
*/
|
||||||
|
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> {
|
||||||
|
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);
|
||||||
|
|
||||||
|
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> {
|
||||||
|
// TODO: Update database - payment failed
|
||||||
|
// Example: await db.payments.update({ id: payload.payment_id, status: 'failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefundedPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
||||||
|
// TODO: Update database - payment refunded
|
||||||
|
// Example: await db.payments.update({ id: payload.payment_id, status: 'refunded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExpiredPayment(payload: NOWPaymentsIPNPayload): Promise<void> {
|
||||||
|
// TODO: Update database - payment expired
|
||||||
|
// Example: await db.payments.update({ id: payload.payment_id, status: 'expired' });
|
||||||
|
}
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
47
src/types/nowpayments.ts
Normal file
47
src/types/nowpayments.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* NOWPayments IPN (Instant Payment Notification) types
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface NOWPaymentsIPNPayload {
|
||||||
|
payment_id: string;
|
||||||
|
invoice_id?: string;
|
||||||
|
payment_status: PaymentStatus;
|
||||||
|
pay_address?: string;
|
||||||
|
price_amount: number;
|
||||||
|
price_currency: string;
|
||||||
|
pay_amount?: number;
|
||||||
|
pay_currency?: string;
|
||||||
|
order_id?: string;
|
||||||
|
order_description?: string;
|
||||||
|
purchase_id?: string;
|
||||||
|
outcome_amount?: number;
|
||||||
|
outcome_currency?: string;
|
||||||
|
payin_extra_id?: string;
|
||||||
|
smart_contract?: string;
|
||||||
|
network?: string;
|
||||||
|
network_precision?: string;
|
||||||
|
time_limit?: string;
|
||||||
|
expiration_estimate_date?: string;
|
||||||
|
payment_extra_id?: string;
|
||||||
|
payment_hash?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PaymentStatus {
|
||||||
|
WAITING = 'waiting',
|
||||||
|
CONFIRMING = 'confirming',
|
||||||
|
CONFIRMED = 'confirmed',
|
||||||
|
SENDING = 'sending',
|
||||||
|
PARTIALLY_PAID = 'partially_paid',
|
||||||
|
FINISHED = 'finished',
|
||||||
|
FAILED = 'failed',
|
||||||
|
REFUNDED = 'refunded',
|
||||||
|
EXPIRED = 'expired'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPNValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user