This commit is contained in:
root
2025-12-20 19:00:17 +01:00
commit 02830aa7df
14 changed files with 2967 additions and 0 deletions

16
.env.example Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View 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"
}
}

View 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');
}

View 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
View 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
View 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!');
}
});

View 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
View 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
View 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
View 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"]
}