payment on hold
This commit is contained in:
399
IPN_INTEGRATION_README.md
Normal file
399
IPN_INTEGRATION_README.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# IPN Callback Integration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the race condition prevention system implemented for the 420Deals.ch collective drop platform. The system uses a **10-minute reservation mechanism** via the `pending_orders` table to prevent overselling when multiple buyers attempt to purchase the last available units simultaneously.
|
||||
|
||||
## Database Schema
|
||||
|
||||
### `pending_orders` Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE `pending_orders` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`payment_id` varchar(255) NOT NULL, -- NOWPayments invoice/payment ID
|
||||
`order_id` varchar(255) NOT NULL, -- Internal order ID (format: SALE-{timestamp}-{drop_id}-{buyer_id})
|
||||
`drop_id` int(11) NOT NULL, -- Foreign key to drops table
|
||||
`buyer_id` int(11) NOT NULL, -- Foreign key to buyers table
|
||||
`size` int(11) NOT NULL, -- Quantity in grams
|
||||
`price_amount` decimal(10,2) NOT NULL, -- Price amount
|
||||
`price_currency` varchar(10) NOT NULL DEFAULT 'chf',
|
||||
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
|
||||
`expires_at` datetime NOT NULL, -- Expiration time (10 minutes from creation)
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `payment_id` (`payment_id`),
|
||||
UNIQUE KEY `order_id` (`order_id`),
|
||||
KEY `drop_id` (`drop_id`),
|
||||
KEY `buyer_id` (`buyer_id`),
|
||||
KEY `idx_expires_at` (`expires_at`), -- Index for cleanup queries
|
||||
FOREIGN KEY (`drop_id`) REFERENCES `drops` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
### `sales` Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE `sales` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`drop_id` int(11) NOT NULL,
|
||||
`buyer_id` int(11) NOT NULL,
|
||||
`size` int(11) NOT NULL DEFAULT 1, -- Quantity in grams
|
||||
`payment_id` text NOT NULL DEFAULT '', -- NOWPayments payment ID (matches pending_orders.payment_id)
|
||||
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `drop_id` (`drop_id`),
|
||||
KEY `buyer_id` (`buyer_id`),
|
||||
FOREIGN KEY (`drop_id`) REFERENCES `drops` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`buyer_id`) REFERENCES `buyers` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
## How the System Works
|
||||
|
||||
### 1. Purchase Flow
|
||||
|
||||
When a buyer initiates a purchase:
|
||||
|
||||
1. **Inventory Check**: System checks available inventory = `drop.size - (sales.total + non_expired_pending_orders.total)`
|
||||
2. **Reservation**: If inventory available, creates `pending_order` with:
|
||||
- `expires_at` = NOW() + 10 minutes
|
||||
- Inventory is now "on hold"
|
||||
3. **Invoice Creation**: Creates NOWPayments invoice with `invoice_timeout: 600` (10 minutes)
|
||||
4. **Transaction**: All of the above happens atomically in a database transaction
|
||||
|
||||
### 2. Inventory Calculation
|
||||
|
||||
**Available Inventory** = `drop.size - (SUM(sales.size) + SUM(pending_orders.size WHERE expires_at > NOW()))`
|
||||
|
||||
- `sales.size`: Confirmed purchases (permanent)
|
||||
- `pending_orders.size`: Temporary reservations (expire after 10 minutes)
|
||||
|
||||
### 3. Expiration
|
||||
|
||||
- Pending orders expire 10 minutes after creation (`expires_at < NOW()`)
|
||||
- Expired orders should be cleaned up periodically (recommended: every 1-2 minutes)
|
||||
- Cleanup endpoint: `POST /api/payments/cleanup-expired`
|
||||
- Expired orders are automatically excluded from inventory calculations
|
||||
|
||||
## IPN Callback Handling
|
||||
|
||||
### NOWPayments IPN Callback Format
|
||||
|
||||
The system expects IPN callbacks with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"payment_id": "string", // NOWPayments payment/invoice ID
|
||||
"invoice_id": "string", // Alternative field name (used if payment_id not present)
|
||||
"order_id": "string", // Internal order ID (format: SALE-{timestamp}-{drop_id}-{buyer_id})
|
||||
"payment_status": "string", // Status: waiting, confirming, confirmed, finished, failed, expired, etc.
|
||||
"pay_amount": "number",
|
||||
"pay_currency": "string",
|
||||
"price_amount": "number",
|
||||
"price_currency": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### IPN Callback Processing Logic
|
||||
|
||||
Your IPN callback handler should follow this flow:
|
||||
|
||||
#### Step 1: Find Pending Order
|
||||
|
||||
```sql
|
||||
SELECT * FROM pending_orders
|
||||
WHERE payment_id = ? OR payment_id = ?
|
||||
-- Try both payment_id and invoice_id from callback
|
||||
```
|
||||
|
||||
**Important**: The system uses `payment_id` OR `invoice_id` to find pending orders. Check both fields.
|
||||
|
||||
#### Step 2: Check Expiration
|
||||
|
||||
```sql
|
||||
-- Verify order hasn't expired
|
||||
SELECT * FROM pending_orders
|
||||
WHERE id = ? AND expires_at > NOW()
|
||||
```
|
||||
|
||||
**Action if expired**:
|
||||
- Delete the pending order
|
||||
- Return error response (don't create sale)
|
||||
- Log the expiration
|
||||
|
||||
#### Step 3: Validate Payment Status
|
||||
|
||||
Process based on `payment_status`:
|
||||
|
||||
- **`finished`** or **`confirmed`**: Payment successful → proceed to Step 4
|
||||
- **`failed`** or **`expired`**: Payment failed → delete pending order, return success
|
||||
- **`waiting`**, **`confirming`**: Payment in progress → return success, wait for final status
|
||||
|
||||
#### Step 4: Final Inventory Check (Before Creating Sale)
|
||||
|
||||
```sql
|
||||
-- Get drop details
|
||||
SELECT * FROM drops WHERE id = ?
|
||||
|
||||
-- Calculate current inventory
|
||||
SELECT COALESCE(SUM(size), 0) as total_sales
|
||||
FROM sales WHERE drop_id = ?
|
||||
|
||||
-- Calculate other pending orders (excluding current one)
|
||||
SELECT COALESCE(SUM(size), 0) as total_pending
|
||||
FROM pending_orders
|
||||
WHERE drop_id = ?
|
||||
AND id != ?
|
||||
AND expires_at > NOW()
|
||||
|
||||
-- Check availability
|
||||
-- Available = drop.size - (total_sales + total_pending)
|
||||
-- If pending_order.size > Available: REJECT
|
||||
```
|
||||
|
||||
**Important**: Always check inventory again before creating the sale, as other buyers may have reserved inventory in the meantime.
|
||||
|
||||
#### Step 5: Create Sale Record
|
||||
|
||||
If inventory is available:
|
||||
|
||||
```sql
|
||||
-- Create sale
|
||||
INSERT INTO sales (drop_id, buyer_id, size, payment_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
|
||||
-- Delete pending order
|
||||
DELETE FROM pending_orders WHERE id = ?
|
||||
```
|
||||
|
||||
**Important**:
|
||||
- Use the same `payment_id` from pending_order for the sale record
|
||||
- Delete the pending order after creating the sale
|
||||
- This should be done in a transaction to ensure atomicity
|
||||
|
||||
#### Step 6: Handle Idempotency
|
||||
|
||||
Before creating a sale, check if it already exists:
|
||||
|
||||
```sql
|
||||
SELECT * FROM sales WHERE payment_id = ?
|
||||
```
|
||||
|
||||
If sale exists, return success (idempotent operation).
|
||||
|
||||
### Example IPN Callback Handler (Pseudocode)
|
||||
|
||||
```javascript
|
||||
async function handleIPNCallback(callbackData) {
|
||||
const { payment_id, invoice_id, order_id, payment_status } = callbackData;
|
||||
|
||||
// Step 1: Find pending order
|
||||
const paymentIdToFind = invoice_id || payment_id;
|
||||
const pendingOrder = await db.query(
|
||||
'SELECT * FROM pending_orders WHERE payment_id = ?',
|
||||
[paymentIdToFind]
|
||||
);
|
||||
|
||||
if (!pendingOrder) {
|
||||
// Check if sale already exists (idempotency)
|
||||
const existingSale = await db.query(
|
||||
'SELECT * FROM sales WHERE payment_id = ?',
|
||||
[paymentIdToFind]
|
||||
);
|
||||
if (existingSale) {
|
||||
return { status: 'ok' }; // Already processed
|
||||
}
|
||||
return { error: 'Pending order not found' };
|
||||
}
|
||||
|
||||
// Step 2: Check expiration
|
||||
if (new Date(pendingOrder.expires_at) < new Date()) {
|
||||
await db.query('DELETE FROM pending_orders WHERE id = ?', [pendingOrder.id]);
|
||||
return { error: 'Order expired' };
|
||||
}
|
||||
|
||||
// Step 3: Process payment status
|
||||
if (payment_status === 'finished' || payment_status === 'confirmed') {
|
||||
// Step 4: Final inventory check
|
||||
const drop = await db.query('SELECT * FROM drops WHERE id = ?', [pendingOrder.drop_id]);
|
||||
const sales = await db.query(
|
||||
'SELECT COALESCE(SUM(size), 0) as total FROM sales WHERE drop_id = ?',
|
||||
[pendingOrder.drop_id]
|
||||
);
|
||||
const otherPending = await db.query(
|
||||
'SELECT COALESCE(SUM(size), 0) as total FROM pending_orders WHERE drop_id = ? AND id != ? AND expires_at > NOW()',
|
||||
[pendingOrder.drop_id, pendingOrder.id]
|
||||
);
|
||||
|
||||
const totalReserved = sales.total + otherPending.total;
|
||||
const available = drop.size - totalReserved;
|
||||
|
||||
if (pendingOrder.size > available) {
|
||||
await db.query('DELETE FROM pending_orders WHERE id = ?', [pendingOrder.id]);
|
||||
return { error: 'Inventory no longer available' };
|
||||
}
|
||||
|
||||
// Step 5: Create sale
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.query(
|
||||
'INSERT INTO sales (drop_id, buyer_id, size, payment_id) VALUES (?, ?, ?, ?)',
|
||||
[pendingOrder.drop_id, pendingOrder.buyer_id, pendingOrder.size, pendingOrder.payment_id]
|
||||
);
|
||||
await tx.query('DELETE FROM pending_orders WHERE id = ?', [pendingOrder.id]);
|
||||
});
|
||||
|
||||
return { status: 'ok' };
|
||||
} else if (payment_status === 'failed' || payment_status === 'expired') {
|
||||
await db.query('DELETE FROM pending_orders WHERE id = ?', [pendingOrder.id]);
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
return { status: 'ok' }; // Payment still in progress
|
||||
}
|
||||
```
|
||||
|
||||
## Important Considerations
|
||||
|
||||
### 1. Database Transactions
|
||||
|
||||
- Always use transactions when creating sales and deleting pending orders
|
||||
- This ensures atomicity and prevents race conditions
|
||||
|
||||
### 2. Expiration Handling
|
||||
|
||||
- Expired pending orders should be excluded from inventory calculations
|
||||
- Clean up expired orders periodically (every 1-2 minutes recommended)
|
||||
- The main application has a cleanup endpoint: `POST /api/payments/cleanup-expired`
|
||||
|
||||
### 3. Unit Conversion
|
||||
|
||||
- All `size` values in `sales` and `pending_orders` are stored in **grams**
|
||||
- `drops.size` and `drops.unit` may be in different units (g or kg)
|
||||
- When calculating inventory, convert to the drop's unit:
|
||||
```javascript
|
||||
if (drop.unit === 'kg') {
|
||||
sizeInDropUnit = sizeInGrams / 1000;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Idempotency
|
||||
|
||||
- IPN callbacks may be sent multiple times
|
||||
- Always check if a sale already exists before creating a new one
|
||||
- Use `payment_id` to check for existing sales
|
||||
|
||||
### 5. Error Handling
|
||||
|
||||
- Always return HTTP 200 to NOWPayments, even on errors
|
||||
- Log errors for debugging
|
||||
- Don't retry failed operations indefinitely
|
||||
|
||||
### 6. Inventory Availability
|
||||
|
||||
- Inventory is calculated as: `drop.size - (sales + non_expired_pending_orders)`
|
||||
- Always re-check inventory before creating a sale
|
||||
- Other buyers may have reserved inventory between payment initiation and confirmation
|
||||
|
||||
## API Endpoints Reference
|
||||
|
||||
### Cleanup Expired Orders
|
||||
|
||||
```
|
||||
POST /api/payments/cleanup-expired
|
||||
Authorization: Bearer {CLEANUP_API_TOKEN} (optional)
|
||||
|
||||
Response:
|
||||
{
|
||||
"message": "Cleaned up X expired pending orders",
|
||||
"cleaned": 5,
|
||||
"total": 5
|
||||
}
|
||||
```
|
||||
|
||||
### Check Expired Orders Count
|
||||
|
||||
```
|
||||
GET /api/payments/cleanup-expired
|
||||
|
||||
Response:
|
||||
{
|
||||
"expired_orders_count": 3,
|
||||
"message": "There are 3 expired pending orders that need cleanup"
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
When implementing your IPN callback handler, test:
|
||||
|
||||
1. ✅ Payment success → Sale created, pending order deleted
|
||||
2. ✅ Payment failure → Pending order deleted, no sale created
|
||||
3. ✅ Payment expiration → Pending order deleted, no sale created
|
||||
4. ✅ Expired pending order → Rejected, no sale created
|
||||
5. ✅ Insufficient inventory → Pending order deleted, no sale created
|
||||
6. ✅ Duplicate IPN callbacks → Idempotent (sale not created twice)
|
||||
7. ✅ Race condition → Only one sale created when multiple payments complete simultaneously
|
||||
|
||||
## Database Queries Reference
|
||||
|
||||
### Find Pending Order by Payment ID
|
||||
```sql
|
||||
SELECT * FROM pending_orders
|
||||
WHERE payment_id = ? OR payment_id = ?
|
||||
```
|
||||
|
||||
### Check if Pending Order is Expired
|
||||
```sql
|
||||
SELECT * FROM pending_orders
|
||||
WHERE id = ? AND expires_at > NOW()
|
||||
```
|
||||
|
||||
### Calculate Available Inventory
|
||||
```sql
|
||||
-- Sales
|
||||
SELECT COALESCE(SUM(size), 0) as total_sales
|
||||
FROM sales WHERE drop_id = ?
|
||||
|
||||
-- Non-expired pending orders
|
||||
SELECT COALESCE(SUM(size), 0) as total_pending
|
||||
FROM pending_orders
|
||||
WHERE drop_id = ? AND expires_at > NOW()
|
||||
|
||||
-- Available = drop.size - (total_sales + total_pending)
|
||||
```
|
||||
|
||||
### Create Sale and Delete Pending Order (Transaction)
|
||||
```sql
|
||||
START TRANSACTION;
|
||||
|
||||
INSERT INTO sales (drop_id, buyer_id, size, payment_id)
|
||||
VALUES (?, ?, ?, ?);
|
||||
|
||||
DELETE FROM pending_orders WHERE id = ?;
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
### Check for Existing Sale (Idempotency)
|
||||
```sql
|
||||
SELECT * FROM sales WHERE payment_id = ?
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The main application uses these environment variables (for reference):
|
||||
|
||||
- `IPN_CALLBACK_URL`: URL where NOWPayments sends IPN callbacks
|
||||
- `CLEANUP_API_TOKEN`: (Optional) Token for cleanup endpoint authentication
|
||||
- `NOWPAYMENTS_API_KEY`: NOWPayments API key
|
||||
- `NOWPAYMENTS_TESTNET`: Set to 'true' for sandbox environment
|
||||
|
||||
## Support
|
||||
|
||||
For questions or issues with the IPN callback integration, refer to:
|
||||
- Database schema: `cbd420.sql`
|
||||
- Race condition fix documentation: `RACE_CONDITION_FIX.md`
|
||||
|
||||
**Note**: The main application does not include an IPN callback handler. All IPN callbacks must be handled by your external service using the logic described in this document.
|
||||
|
||||
148
RACE_CONDITION_FIX.md
Normal file
148
RACE_CONDITION_FIX.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Race Condition Fix - Implementation Summary
|
||||
|
||||
## Problem
|
||||
When multiple buyers attempt to purchase the last available units simultaneously, a race condition occurs where inventory can be oversold.
|
||||
|
||||
## Solution
|
||||
Implemented a 10-minute reservation system using the `pending_orders` table to temporarily hold inventory during checkout.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Database Migration
|
||||
- **File**: `migrations/add_expires_at_to_pending_orders.sql`
|
||||
- Added `expires_at` column to `pending_orders` table
|
||||
- Added index on `expires_at` for efficient cleanup queries
|
||||
|
||||
**To apply the migration:**
|
||||
```sql
|
||||
-- Run the migration file
|
||||
SOURCE migrations/add_expires_at_to_pending_orders.sql;
|
||||
```
|
||||
|
||||
### 2. Create Invoice Endpoint (`app/api/payments/create-invoice/route.ts`)
|
||||
- **Atomic Inventory Reservation**: Uses database transactions to atomically check and reserve inventory
|
||||
- **Pending Orders Check**: Includes non-expired pending orders when calculating available inventory
|
||||
- **Expiration Time**: Sets `expires_at` to 10 minutes from creation
|
||||
- **NOWPayments Integration**: Sets `invoice_timeout` to 600 seconds (10 minutes) when creating invoice
|
||||
|
||||
### 3. Active Drop Endpoint (`app/api/drops/active/route.ts`)
|
||||
- **Fill Calculation**: Includes non-expired pending orders in fill calculation
|
||||
- **UI Display**: Progress bar now shows total reserved inventory (sales + pending orders)
|
||||
|
||||
### 4. Cleanup Endpoint (`app/api/payments/cleanup-expired/route.ts`)
|
||||
- **Automatic Cleanup**: Removes expired pending orders
|
||||
- **NOWPayments Status Check**: Verifies payment status before cleanup
|
||||
- **Monitoring**: GET endpoint to check count of expired orders
|
||||
|
||||
### 5. UI Updates (`app/components/Drop.tsx`)
|
||||
- **Visual Indicator**: Added note that items are held for 10 minutes during checkout
|
||||
- **Progress Bar**: Automatically reflects reserved inventory (including pending orders)
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Run Database Migration
|
||||
```bash
|
||||
mysql -u your_user -p your_database < migrations/add_expires_at_to_pending_orders.sql
|
||||
```
|
||||
|
||||
### 2. Set Up Cleanup Job
|
||||
The cleanup endpoint should be called periodically (recommended: every 1-2 minutes) to remove expired pending orders.
|
||||
|
||||
#### Option A: Cron Job (Linux/Mac)
|
||||
```bash
|
||||
# Add to crontab (crontab -e)
|
||||
*/2 * * * * curl -X POST https://your-domain.com/api/payments/cleanup-expired -H "Authorization: Bearer YOUR_CLEANUP_TOKEN"
|
||||
```
|
||||
|
||||
#### Option B: Vercel Cron (if using Vercel)
|
||||
Add to `vercel.json`:
|
||||
```json
|
||||
{
|
||||
"crons": [{
|
||||
"path": "/api/payments/cleanup-expired",
|
||||
"schedule": "*/2 * * * *"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
#### Option C: Node.js Cron Library
|
||||
```javascript
|
||||
const cron = require('node-cron');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
cron.schedule('*/2 * * * *', async () => {
|
||||
await fetch('http://localhost:3000/api/payments/cleanup-expired', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.CLEANUP_API_TOKEN}`
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Environment Variables
|
||||
Add to your `.env` file:
|
||||
```
|
||||
# Required: URL for external IPN handler service
|
||||
IPN_CALLBACK_URL=http://your-ipn-service.com/api/payments/ipn-callback
|
||||
|
||||
# Optional: Token for cleanup endpoint security
|
||||
CLEANUP_API_TOKEN=your-secure-random-token
|
||||
```
|
||||
|
||||
**Note**: IPN callbacks are handled by an external service. The `IPN_CALLBACK_URL` must point to your external IPN handler that processes payment confirmations and creates sales. See `IPN_INTEGRATION_README.md` for integration details.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Purchase Initiation**: When a buyer clicks "Join Drop":
|
||||
- System checks available inventory (sales + non-expired pending orders)
|
||||
- If available, creates pending order with 10-minute expiration
|
||||
- Creates NOWPayments invoice with 10-minute timeout
|
||||
- Inventory is now "on hold" for 10 minutes
|
||||
|
||||
2. **During Checkout**:
|
||||
- Progress bar shows reserved inventory (including pending orders)
|
||||
- Other buyers see reduced availability
|
||||
- If buyer completes payment within 10 minutes → sale is created
|
||||
- If buyer doesn't complete payment within 10 minutes → order expires
|
||||
|
||||
3. **Expiration**:
|
||||
- Cleanup job runs every 1-2 minutes
|
||||
- Expired pending orders are removed
|
||||
- Inventory becomes available again
|
||||
- NOWPayments invoice expires on their end
|
||||
|
||||
4. **Payment Confirmation** (handled by external IPN service):
|
||||
- External IPN handler receives payment notification from NOWPayments
|
||||
- Checks if pending order is expired
|
||||
- Validates final inventory availability
|
||||
- Creates sale record if valid
|
||||
- Deletes pending order
|
||||
|
||||
## Testing
|
||||
|
||||
1. **Test Race Condition**:
|
||||
- Open two browser windows
|
||||
- Try to purchase the last available unit simultaneously
|
||||
- Only one should succeed
|
||||
- The other should see "Not enough inventory remaining"
|
||||
|
||||
2. **Test Expiration**:
|
||||
- Create a pending order
|
||||
- Wait 10+ minutes
|
||||
- Run cleanup endpoint manually
|
||||
- Verify order is removed
|
||||
|
||||
3. **Test Payment Flow**:
|
||||
- Create pending order
|
||||
- Complete payment within 10 minutes
|
||||
- Verify sale is created
|
||||
- Verify pending order is deleted
|
||||
|
||||
## Notes
|
||||
|
||||
- Pending orders are automatically included in inventory calculations
|
||||
- The 10-minute hold prevents overselling while giving buyers time to complete payment
|
||||
- NOWPayments invoices also expire after 10 minutes
|
||||
- Cleanup job should run frequently (every 1-2 minutes) to free up inventory quickly
|
||||
|
||||
97
api-doc/create-payment.MD
Normal file
97
api-doc/create-payment.MD
Normal file
@@ -0,0 +1,97 @@
|
||||
POSTCreate payment
|
||||
https://api.nowpayments.io/v1/payment
|
||||
|
||||
Creates payment. With this method, your customer will be able to complete the payment without leaving your website.
|
||||
|
||||
Be sure to consider the details of repeated and wrong-asset deposits from 'Repeated Deposits and Wrong-Asset Deposits' section when processing payments.
|
||||
|
||||
Data must be sent as a JSON-object payload.
|
||||
Required request fields:
|
||||
|
||||
price_amount (required) - the fiat equivalent of the price to be paid in crypto. If the pay_amount parameter is left empty, our system will automatically convert this fiat price into its crypto equivalent. Please note that this does not enable fiat payments, only provides a fiat price for yours and the customer’s convenience and information. NOTE: Some of the assets (KISHU, NWC, FTT, CHR, XYM, SRK, KLV, SUPER, OM, XCUR, NOW, SHIB, SAND, MATIC, CTSI, MANA, FRONT, FTM, DAO, LGCY), have a maximum price amount of ~$2000;
|
||||
|
||||
price_currency (required) - the fiat currency in which the price_amount is specified (usd, eur, etc);
|
||||
|
||||
pay_amount (optional) - the amount that users have to pay for the order stated in crypto. You can either specify it yourself, or we will automatically convert the amount you indicated in price_amount;
|
||||
|
||||
pay_currency (required) - the crypto currency in which the pay_amount is specified (btc, eth, etc), or one of available fiat currencies if it's enabled for your account (USD, EUR, ILS, GBP, AUD, RON);
|
||||
NOTE: some of the currencies require a Memo, Destination Tag, etc., to complete a payment (AVA, EOS, BNBMAINNET, XLM, XRP). This is unique for each payment. This ID is received in “payin_extra_id” parameter of the response. Payments made without "payin_extra_id" cannot be detected automatically;
|
||||
|
||||
ipn_callback_url (optional) - url to receive callbacks, should contain "http" or "https", eg. "https://nowpayments.io";
|
||||
|
||||
order_id (optional) - inner store order ID, e.g. "RGDBP-21314";
|
||||
|
||||
order_description (optional) - inner store order description, e.g. "Apple Macbook Pro 2019 x 1";
|
||||
|
||||
payout_address (optional) - usually the funds will go to the address you specify in your Personal account. In case you want to receive funds on another address, you can specify it in this parameter;
|
||||
|
||||
payout_currency (optional) - currency of your external payout_address, required when payout_adress is specified;
|
||||
|
||||
payout_extra_id(optional) - extra id or memo or tag for external payout_address;
|
||||
|
||||
is_fixed_rate(optional) - boolean, can be true or false. Required for fixed-rate exchanges;
|
||||
NOTE: the rate of exchange will be frozen for 20 minutes. If there are no incoming payments during this period, the payment status changes to "expired".
|
||||
|
||||
is_fee_paid_by_user(optional) - boolean, can be true or false. Required for fixed-rate exchanges with all fees paid by users;
|
||||
NOTE: the rate of exchange will be frozen for 20 minutes. If there are no incoming payments during this period, the payment status changes to "expired". The fee paid by user payment can be only fixed rate. If you disable fixed rate during payment creation process, this flag would enforce fixed_rate to be true;
|
||||
|
||||
Here the list of available statuses of payment:
|
||||
|
||||
waiting - waiting for the customer to send the payment. The initial status of each payment;
|
||||
|
||||
confirming - the transaction is being processed on the blockchain. Appears when NOWPayments detect the funds from the user on the blockchain;
|
||||
Please note: each currency has its own amount of confirmations required to start the processing.
|
||||
|
||||
confirmed - the process is confirmed by the blockchain. Customer’s funds have accumulated enough confirmations;
|
||||
|
||||
sending - the funds are being sent to your personal wallet. We are in the process of sending the funds to you;
|
||||
|
||||
partially_paid - it shows that the customer sent less than the actual price. Appears when the funds have arrived in your wallet;
|
||||
|
||||
finished - the funds have reached your personal address and the payment is finished;
|
||||
|
||||
failed - the payment wasn't completed due to the error of some kind;
|
||||
|
||||
expired - the user didn't send the funds to the specified address in the 7 days time window;
|
||||
|
||||
Please note: when you're creating a fiat2crypto payment you also should include additional header to your request - "origin-ip : xxx", where xxx is your customer IP address.
|
||||
|
||||
|
||||
|
||||
Request Example:
|
||||
curl --location 'https://api.nowpayments.io/v1/payment' \
|
||||
--header 'x-api-key: {{api-key}}' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"price_amount": 3999.5,
|
||||
"price_currency": "usd",
|
||||
"pay_currency": "btc",
|
||||
"ipn_callback_url": "https://nowpayments.io",
|
||||
"order_id": "RGDBP-21314",
|
||||
"order_description": "Apple Macbook Pro 2019 x 1"
|
||||
}'
|
||||
|
||||
Response:
|
||||
{
|
||||
"payment_id": "5745459419",
|
||||
"payment_status": "waiting",
|
||||
"pay_address": "3EZ2uTdVDAMFXTfc6uLDDKR6o8qKBZXVkj",
|
||||
"price_amount": 3999.5,
|
||||
"price_currency": "usd",
|
||||
"pay_amount": 0.17070286,
|
||||
"pay_currency": "btc",
|
||||
"order_id": "RGDBP-21314",
|
||||
"order_description": "Apple Macbook Pro 2019 x 1",
|
||||
"ipn_callback_url": "https://nowpayments.io",
|
||||
"created_at": "2020-12-22T15:00:22.742Z",
|
||||
"updated_at": "2020-12-22T15:00:22.742Z",
|
||||
"purchase_id": "5837122679",
|
||||
"amount_received": null,
|
||||
"payin_extra_id": null,
|
||||
"smart_contract": "",
|
||||
"network": "btc",
|
||||
"network_precision": 8,
|
||||
"time_limit": null,
|
||||
"burning_percent": null,
|
||||
"expiration_estimate_date": "2020-12-23T15:00:22.742Z"
|
||||
}
|
||||
@@ -6,6 +6,11 @@ export async function GET() {
|
||||
try {
|
||||
const now = new Date()
|
||||
|
||||
// Clean up expired pending orders first to ensure accurate calculations
|
||||
await pool.execute(
|
||||
'DELETE FROM pending_orders WHERE expires_at < NOW()',
|
||||
)
|
||||
|
||||
// Get all drops ordered by start_time (or created_at if start_time is null)
|
||||
const [rows] = await pool.execute(
|
||||
'SELECT * FROM drops ORDER BY COALESCE(start_time, created_at) ASC'
|
||||
@@ -14,12 +19,14 @@ export async function GET() {
|
||||
const drops = rows as any[]
|
||||
|
||||
// Find the first drop that's not fully sold out and has started
|
||||
console.log(`Checking ${drops.length} drops for active drop`)
|
||||
for (const drop of drops) {
|
||||
// Check if drop has started (start_time is in the past or null)
|
||||
const startTime = drop.start_time ? new Date(drop.start_time) : new Date(drop.created_at)
|
||||
console.log(`Checking drop ${drop.id} (${drop.item}): startTime=${startTime.toISOString()}, now=${now.toISOString()}, started=${startTime <= now}`)
|
||||
if (startTime > now) {
|
||||
// Drop hasn't started yet - return it with a flag indicating it's upcoming
|
||||
// Calculate fill (will be 0 for upcoming drops)
|
||||
// Calculate fill (will be 0 for upcoming drops, but include pending for consistency)
|
||||
const [salesRows] = await pool.execute(
|
||||
'SELECT COALESCE(SUM(size), 0) as total_fill FROM sales WHERE drop_id = ?',
|
||||
[drop.id]
|
||||
@@ -27,20 +34,35 @@ export async function GET() {
|
||||
const salesData = salesRows as any[]
|
||||
const totalFillInGrams = salesData[0]?.total_fill || 0
|
||||
|
||||
let fill = totalFillInGrams
|
||||
if (drop.unit === 'kg') {
|
||||
fill = totalFillInGrams / 1000
|
||||
}
|
||||
// Include non-expired pending orders in fill calculation
|
||||
const [pendingRows] = await pool.execute(
|
||||
'SELECT COALESCE(SUM(size), 0) as total_pending FROM pending_orders WHERE drop_id = ? AND expires_at > NOW()',
|
||||
[drop.id]
|
||||
)
|
||||
const pendingData = pendingRows as any[]
|
||||
const pendingFillInGrams = pendingData[0]?.total_pending || 0
|
||||
|
||||
const totalReservedInGrams = totalFillInGrams + pendingFillInGrams
|
||||
let salesFill = totalFillInGrams
|
||||
let pendingFill = pendingFillInGrams
|
||||
if (drop.unit === 'kg') {
|
||||
salesFill = totalFillInGrams / 1000
|
||||
pendingFill = pendingFillInGrams / 1000
|
||||
}
|
||||
const totalFill = salesFill + pendingFill
|
||||
|
||||
console.log(`Returning upcoming drop ${drop.id} (${drop.item}): fill=${totalFill}, size=${drop.size}, starts at ${startTime.toISOString()}`)
|
||||
return NextResponse.json({
|
||||
...drop,
|
||||
fill: fill,
|
||||
fill: totalFill,
|
||||
sales_fill: salesFill,
|
||||
pending_fill: pendingFill,
|
||||
is_upcoming: true,
|
||||
start_time: drop.start_time || drop.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate fill from sales records
|
||||
// Calculate fill from sales records and pending orders
|
||||
// Sales are stored in grams, so we need to convert based on drop unit
|
||||
const [salesRows] = await pool.execute(
|
||||
'SELECT COALESCE(SUM(size), 0) as total_fill FROM sales WHERE drop_id = ?',
|
||||
@@ -49,21 +71,64 @@ export async function GET() {
|
||||
const salesData = salesRows as any[]
|
||||
const totalFillInGrams = salesData[0]?.total_fill || 0
|
||||
|
||||
// Convert fill to drop's unit for comparison
|
||||
let fill = totalFillInGrams
|
||||
// Include non-expired pending orders in fill calculation (shows "on hold" inventory)
|
||||
const [pendingRows] = await pool.execute(
|
||||
'SELECT COALESCE(SUM(size), 0) as total_pending FROM pending_orders WHERE drop_id = ? AND expires_at > NOW()',
|
||||
[drop.id]
|
||||
)
|
||||
const pendingData = pendingRows as any[]
|
||||
// Ensure we get a number, handle null/undefined cases
|
||||
// When table is empty, SUM returns NULL, COALESCE converts it to 0
|
||||
let pendingFillInGrams = 0
|
||||
if (pendingData && pendingData.length > 0 && pendingData[0]) {
|
||||
const rawValue = pendingData[0].total_pending
|
||||
// Handle both null (from empty result) and actual 0 values
|
||||
if (rawValue !== null && rawValue !== undefined) {
|
||||
pendingFillInGrams = Number(rawValue) || 0
|
||||
}
|
||||
}
|
||||
// Explicitly ensure it's 0 if we got here with no valid data
|
||||
pendingFillInGrams = Number(pendingFillInGrams) || 0
|
||||
|
||||
// Ensure totalFillInGrams and pendingFillInGrams are numbers, not strings
|
||||
const totalFillNum = Number(totalFillInGrams) || 0
|
||||
const pendingFillNum = Number(pendingFillInGrams) || 0
|
||||
const totalReservedInGrams = totalFillNum + pendingFillNum
|
||||
|
||||
// Convert to drop's unit for display
|
||||
let salesFill = totalFillNum
|
||||
let pendingFill = pendingFillNum
|
||||
if (drop.unit === 'kg') {
|
||||
fill = totalFillInGrams / 1000
|
||||
salesFill = totalFillNum / 1000
|
||||
pendingFill = pendingFillNum / 1000
|
||||
}
|
||||
|
||||
const totalFill = salesFill + pendingFill
|
||||
|
||||
// Ensure drop.size is a number for comparison
|
||||
const dropSize = typeof drop.size === 'string' ? parseFloat(drop.size) : Number(drop.size)
|
||||
|
||||
const fillNum = Number(totalFill)
|
||||
// Check if drop is not fully sold out
|
||||
if (fill < drop.size) {
|
||||
// Return drop with calculated fill
|
||||
// Use a small epsilon for floating point comparison to handle precision issues
|
||||
// Consider sold out if fill is within epsilon of size (to handle rounding)
|
||||
const epsilon = drop.unit === 'kg' ? 0.00001 : 0.01
|
||||
const remaining = dropSize - fillNum
|
||||
|
||||
if (remaining > epsilon) {
|
||||
// Ensure pending_fill is explicitly 0 if no pending orders
|
||||
const finalPendingFill = Number(pendingFill) || 0
|
||||
console.log(`Returning active drop ${drop.id} with fill ${fillNum} < size ${dropSize}, pending_fill=${finalPendingFill} (raw: ${pendingFill})`)
|
||||
return NextResponse.json({
|
||||
...drop,
|
||||
fill: fill,
|
||||
fill: fillNum,
|
||||
sales_fill: Number(salesFill) || 0, // Only confirmed sales
|
||||
pending_fill: finalPendingFill, // Items on hold (explicitly 0 if no pending orders)
|
||||
is_upcoming: false,
|
||||
start_time: drop.start_time || drop.created_at,
|
||||
})
|
||||
} else {
|
||||
console.log(`Drop ${drop.id} is sold out: fill=${fillNum} >= size=${dropSize} (remaining=${remaining})`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
115
app/api/payments/cleanup-expired/route.ts
Normal file
115
app/api/payments/cleanup-expired/route.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { getNowPaymentsConfig } from '@/lib/nowpayments'
|
||||
|
||||
// POST /api/payments/cleanup-expired - Clean up expired pending orders
|
||||
// This endpoint should be called periodically (e.g., via cron job every minute)
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Optional: Add authentication/authorization check here
|
||||
// For security, you might want to require an API key or admin authentication
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const expectedToken = process.env.CLEANUP_API_TOKEN
|
||||
if (expectedToken && authHeader !== `Bearer ${expectedToken}`) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Find all expired pending orders
|
||||
const [expiredRows] = await pool.execute(
|
||||
'SELECT * FROM pending_orders WHERE expires_at < NOW()',
|
||||
)
|
||||
const expiredOrders = expiredRows as any[]
|
||||
|
||||
if (expiredOrders.length === 0) {
|
||||
return NextResponse.json({
|
||||
message: 'No expired orders to clean up',
|
||||
cleaned: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const nowPaymentsConfig = getNowPaymentsConfig()
|
||||
let cleanedCount = 0
|
||||
const errors: string[] = []
|
||||
|
||||
// Process each expired order
|
||||
for (const order of expiredOrders) {
|
||||
try {
|
||||
// Try to cancel the NOWPayments invoice if it still exists
|
||||
// Note: NOWPayments may not have a direct cancel endpoint, but we can try
|
||||
// to check the status and handle accordingly
|
||||
try {
|
||||
const statusResponse = await fetch(
|
||||
`${nowPaymentsConfig.baseUrl}/v1/payment/${order.payment_id}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-api-key': nowPaymentsConfig.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (statusResponse.ok) {
|
||||
const paymentStatus = await statusResponse.json()
|
||||
// If payment is still pending/waiting, we can consider it expired
|
||||
// NOWPayments will handle the expiration on their end based on invoice_timeout
|
||||
console.log(`Payment ${order.payment_id} status:`, paymentStatus.payment_status)
|
||||
}
|
||||
} catch (apiError) {
|
||||
// Invoice might already be expired/cancelled on NOWPayments side
|
||||
console.log(`Could not check status for payment ${order.payment_id}:`, apiError)
|
||||
}
|
||||
|
||||
// Delete the expired pending order
|
||||
await pool.execute('DELETE FROM pending_orders WHERE id = ?', [order.id])
|
||||
cleanedCount++
|
||||
console.log(`Cleaned up expired pending order ${order.id} (payment_id: ${order.payment_id})`)
|
||||
} catch (error) {
|
||||
const errorMsg = `Error cleaning up order ${order.id}: ${error}`
|
||||
console.error(errorMsg)
|
||||
errors.push(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Cleaned up ${cleanedCount} expired pending orders`,
|
||||
cleaned: cleanedCount,
|
||||
total: expiredOrders.length,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in cleanup endpoint:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to clean up expired orders' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/payments/cleanup-expired - Get status of expired orders (for monitoring)
|
||||
export async function GET() {
|
||||
try {
|
||||
const [expiredRows] = await pool.execute(
|
||||
'SELECT COUNT(*) as count FROM pending_orders WHERE expires_at < NOW()',
|
||||
)
|
||||
const result = expiredRows as any[]
|
||||
const expiredCount = result[0]?.count || 0
|
||||
|
||||
return NextResponse.json({
|
||||
expired_orders_count: expiredCount,
|
||||
message: expiredCount > 0
|
||||
? `There are ${expiredCount} expired pending orders that need cleanup`
|
||||
: 'No expired orders',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error checking expired orders:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to check expired orders' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,31 +45,65 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const drop = drops[0]
|
||||
|
||||
// Check inventory availability (but don't reserve yet - will reserve when payment confirmed)
|
||||
const [salesRows] = await pool.execute(
|
||||
'SELECT COALESCE(SUM(size), 0) as total_fill FROM sales WHERE drop_id = ?',
|
||||
[drop_id]
|
||||
)
|
||||
const salesData = salesRows as any[]
|
||||
const currentFill = salesData[0]?.total_fill || 0
|
||||
|
||||
// Convert fill to the drop's unit for comparison
|
||||
let currentFillInDropUnit = currentFill
|
||||
let sizeInDropUnit = size
|
||||
if (drop.unit === 'kg') {
|
||||
currentFillInDropUnit = currentFill / 1000
|
||||
sizeInDropUnit = size / 1000
|
||||
}
|
||||
|
||||
// Check if there's enough remaining inventory
|
||||
const remaining = drop.size - currentFillInDropUnit
|
||||
if (sizeInDropUnit > remaining) {
|
||||
// Get IPN callback URL from environment variable (IPN is handled by external service)
|
||||
// This URL is still required for NOWPayments to know where to send payment notifications
|
||||
const ipnCallbackUrl = process.env.IPN_CALLBACK_URL
|
||||
if (!ipnCallbackUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not enough inventory remaining' },
|
||||
{ status: 400 }
|
||||
{ error: 'IPN_CALLBACK_URL environment variable is required. This should point to your external IPN handler service.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Use transaction to atomically check and reserve inventory
|
||||
const connection = await pool.getConnection()
|
||||
await connection.beginTransaction()
|
||||
|
||||
try {
|
||||
// Check inventory availability including non-expired pending orders
|
||||
// First, clean up expired pending orders
|
||||
await connection.execute(
|
||||
'DELETE FROM pending_orders WHERE expires_at < NOW()',
|
||||
)
|
||||
|
||||
// Calculate current fill from sales
|
||||
const [salesRows] = await connection.execute(
|
||||
'SELECT COALESCE(SUM(size), 0) as total_fill FROM sales WHERE drop_id = ?',
|
||||
[drop_id]
|
||||
)
|
||||
const salesData = salesRows as any[]
|
||||
const currentFill = salesData[0]?.total_fill || 0
|
||||
|
||||
// Calculate pending orders (non-expired) that are holding inventory
|
||||
const [pendingRows] = await connection.execute(
|
||||
'SELECT COALESCE(SUM(size), 0) as total_pending FROM pending_orders WHERE drop_id = ? AND expires_at > NOW()',
|
||||
[drop_id]
|
||||
)
|
||||
const pendingData = pendingRows as any[]
|
||||
const pendingFill = pendingData[0]?.total_pending || 0
|
||||
console.log(`total fill : ${currentFill} + ${pendingFill} = ${Number(currentFill) + Number(pendingFill)}`)
|
||||
// Total reserved = sales + pending orders, ensure both are numbers
|
||||
const totalReserved = Number(currentFill) + Number(pendingFill)
|
||||
|
||||
// Convert fill to the drop's unit for comparison
|
||||
let totalReservedInDropUnit = totalReserved
|
||||
let sizeInDropUnit = size
|
||||
if (drop.unit === 'kg') {
|
||||
totalReservedInDropUnit = totalReserved / 1000
|
||||
sizeInDropUnit = size / 1000
|
||||
}
|
||||
|
||||
// Check if there's enough remaining inventory
|
||||
const remaining = drop.size - totalReservedInDropUnit
|
||||
if (sizeInDropUnit > remaining) {
|
||||
await connection.rollback()
|
||||
connection.release()
|
||||
return NextResponse.json(
|
||||
{ error: 'Not enough inventory remaining. Item may be on hold by another buyer.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate price
|
||||
// ppu is stored as integer where 1000 = $1.00, so divide by 1000 to get actual price
|
||||
const pricePerUnit = drop.ppu / 1000
|
||||
@@ -86,64 +120,72 @@ export async function POST(request: NextRequest) {
|
||||
// Generate order ID
|
||||
const orderId = `SALE-${Date.now()}-${drop_id}-${buyer_id}`
|
||||
|
||||
// Get base URL for success/cancel redirects
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
request.headers.get('origin') ||
|
||||
'http://localhost:3420'
|
||||
// Get base URL for success/cancel redirects
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
request.headers.get('origin') ||
|
||||
'http://localhost:3420'
|
||||
|
||||
// Get IPN callback URL from environment variable
|
||||
const ipnCallbackUrl = process.env.IPN_CALLBACK_URL
|
||||
if (!ipnCallbackUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'IPN_CALLBACK_URL environment variable is required' },
|
||||
{ status: 500 }
|
||||
// Get NOWPayments config (testnet or production)
|
||||
const nowPaymentsConfig = getNowPaymentsConfig()
|
||||
|
||||
// Calculate expiration time (10 minutes from now)
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setMinutes(expiresAt.getMinutes() + 10)
|
||||
|
||||
// Create NOWPayments invoice
|
||||
// Note: NOWPayments doesn't support invoice_timeout parameter
|
||||
// Expiration is handled by our pending_orders table (10 minutes)
|
||||
const nowPaymentsResponse = await fetch(`${nowPaymentsConfig.baseUrl}/v1/invoice`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': nowPaymentsConfig.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
price_amount: priceAmount,
|
||||
price_currency: nowPaymentsConfig.currency,
|
||||
order_id: orderId,
|
||||
order_description: `${drop.item} - ${size}g`,
|
||||
ipn_callback_url: ipnCallbackUrl,
|
||||
success_url: `${baseUrl}/?payment=success&order_id=${orderId}`,
|
||||
cancel_url: `${baseUrl}/?payment=cancelled&order_id=${orderId}`,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!nowPaymentsResponse.ok) {
|
||||
const error = await nowPaymentsResponse.json()
|
||||
console.error('NOWPayments error:', error)
|
||||
await connection.rollback()
|
||||
connection.release()
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create payment invoice', details: error },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const invoice = await nowPaymentsResponse.json()
|
||||
|
||||
// Store pending order with expiration time (atomically reserves inventory)
|
||||
await connection.execute(
|
||||
'INSERT INTO pending_orders (payment_id, order_id, drop_id, buyer_id, size, price_amount, price_currency, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[invoice.id, orderId, drop_id, buyer_id, size, priceAmount, nowPaymentsConfig.currency, expiresAt]
|
||||
)
|
||||
}
|
||||
|
||||
// Get NOWPayments config (testnet or production)
|
||||
const nowPaymentsConfig = getNowPaymentsConfig()
|
||||
// Commit transaction - inventory is now reserved
|
||||
await connection.commit()
|
||||
connection.release()
|
||||
|
||||
// Create NOWPayments invoice
|
||||
const nowPaymentsResponse = await fetch(`${nowPaymentsConfig.baseUrl}/v1/invoice`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': nowPaymentsConfig.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
price_amount: priceAmount,
|
||||
price_currency: nowPaymentsConfig.currency,
|
||||
// Return invoice URL - sale will be created by external IPN handler when payment is confirmed
|
||||
return NextResponse.json({
|
||||
invoice_url: invoice.invoice_url,
|
||||
payment_id: invoice.id,
|
||||
order_id: orderId,
|
||||
order_description: `${drop.item} - ${size}g`,
|
||||
ipn_callback_url: ipnCallbackUrl,
|
||||
success_url: `${baseUrl}/?payment=success&order_id=${orderId}`,
|
||||
cancel_url: `${baseUrl}/?payment=cancelled&order_id=${orderId}`,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!nowPaymentsResponse.ok) {
|
||||
const error = await nowPaymentsResponse.json()
|
||||
console.error('NOWPayments error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create payment invoice', details: error },
|
||||
{ status: 500 }
|
||||
)
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
await connection.rollback()
|
||||
connection.release()
|
||||
throw error
|
||||
}
|
||||
|
||||
const invoice = await nowPaymentsResponse.json()
|
||||
|
||||
// Store pending order (will create sale when payment is confirmed)
|
||||
const [result] = await pool.execute(
|
||||
'INSERT INTO pending_orders (payment_id, order_id, drop_id, buyer_id, size, price_amount, price_currency) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[invoice.id, orderId, drop_id, buyer_id, size, priceAmount, nowPaymentsConfig.currency]
|
||||
)
|
||||
|
||||
// Return invoice URL - sale will be created when payment is confirmed via IPN
|
||||
return NextResponse.json({
|
||||
invoice_url: invoice.invoice_url,
|
||||
payment_id: invoice.id,
|
||||
order_id: orderId,
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating invoice:', error)
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
|
||||
// POST /api/payments/ipn-callback - Handle NOWPayments IPN callbacks
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// NOWPayments IPN callback structure
|
||||
// You may need to adjust based on actual NOWPayments IPN format
|
||||
const {
|
||||
payment_id,
|
||||
invoice_id,
|
||||
order_id,
|
||||
payment_status,
|
||||
pay_amount,
|
||||
pay_currency,
|
||||
price_amount,
|
||||
price_currency,
|
||||
} = body
|
||||
|
||||
console.log('IPN Callback received:', {
|
||||
payment_id,
|
||||
invoice_id,
|
||||
order_id,
|
||||
payment_status,
|
||||
})
|
||||
|
||||
// Find pending order by payment_id or invoice_id
|
||||
const paymentIdToFind = invoice_id || payment_id
|
||||
const [pendingRows] = await pool.execute(
|
||||
'SELECT * FROM pending_orders WHERE payment_id = ?',
|
||||
[paymentIdToFind]
|
||||
)
|
||||
|
||||
const pendingOrders = pendingRows as any[]
|
||||
if (pendingOrders.length === 0) {
|
||||
// Check if sale already exists (idempotency)
|
||||
const [existingSales] = await pool.execute(
|
||||
'SELECT * FROM sales WHERE payment_id = ?',
|
||||
[paymentIdToFind]
|
||||
)
|
||||
const existing = existingSales as any[]
|
||||
if (existing.length > 0) {
|
||||
// Sale already created, just return success
|
||||
console.log('Sale already exists for payment_id:', paymentIdToFind)
|
||||
return NextResponse.json({ status: 'ok' }, { status: 200 })
|
||||
}
|
||||
|
||||
console.error('Pending order not found for payment_id:', paymentIdToFind)
|
||||
return NextResponse.json(
|
||||
{ error: 'Pending order not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const pendingOrder = pendingOrders[0]
|
||||
|
||||
// Update payment status based on payment_status
|
||||
// NOWPayments statuses: waiting, confirming, confirmed, sending, partially_paid, finished, failed, refunded, expired
|
||||
if (payment_status === 'finished' || payment_status === 'confirmed') {
|
||||
// Payment successful - create sale record
|
||||
try {
|
||||
// Check inventory again before creating sale
|
||||
const [dropRows] = await pool.execute(
|
||||
'SELECT * FROM drops WHERE id = ?',
|
||||
[pendingOrder.drop_id]
|
||||
)
|
||||
const drops = dropRows as any[]
|
||||
if (drops.length === 0) {
|
||||
console.error('Drop not found for pending order:', pendingOrder.id)
|
||||
return NextResponse.json({ status: 'error', message: 'Drop not found' }, { status: 200 })
|
||||
}
|
||||
|
||||
const drop = drops[0]
|
||||
|
||||
// Calculate current fill from sales
|
||||
const [salesRows] = await pool.execute(
|
||||
'SELECT COALESCE(SUM(size), 0) as total_fill FROM sales WHERE drop_id = ?',
|
||||
[pendingOrder.drop_id]
|
||||
)
|
||||
const salesData = salesRows as any[]
|
||||
const currentFill = salesData[0]?.total_fill || 0
|
||||
|
||||
// Convert fill to the drop's unit for comparison
|
||||
let currentFillInDropUnit = currentFill
|
||||
let sizeInDropUnit = pendingOrder.size
|
||||
if (drop.unit === 'kg') {
|
||||
currentFillInDropUnit = currentFill / 1000
|
||||
sizeInDropUnit = pendingOrder.size / 1000
|
||||
}
|
||||
|
||||
// Check if there's still enough inventory
|
||||
const remaining = drop.size - currentFillInDropUnit
|
||||
if (sizeInDropUnit > remaining) {
|
||||
console.error('Not enough inventory for pending order:', pendingOrder.id)
|
||||
// Delete pending order since inventory is no longer available
|
||||
await pool.execute('DELETE FROM pending_orders WHERE id = ?', [pendingOrder.id])
|
||||
return NextResponse.json({ status: 'error', message: 'Inventory no longer available' }, { status: 200 })
|
||||
}
|
||||
|
||||
// Create sale record
|
||||
const [result] = await pool.execute(
|
||||
'INSERT INTO sales (drop_id, buyer_id, size, payment_id) VALUES (?, ?, ?, ?)',
|
||||
[pendingOrder.drop_id, pendingOrder.buyer_id, pendingOrder.size, pendingOrder.payment_id]
|
||||
)
|
||||
|
||||
const saleId = (result as any).insertId
|
||||
|
||||
// Delete pending order since sale is created
|
||||
await pool.execute('DELETE FROM pending_orders WHERE id = ?', [pendingOrder.id])
|
||||
|
||||
console.log(`Payment confirmed - Sale ${saleId} created from pending order ${pendingOrder.id}`)
|
||||
} catch (error) {
|
||||
console.error('Error creating sale from pending order:', error)
|
||||
return NextResponse.json({ status: 'error' }, { status: 200 })
|
||||
}
|
||||
} else if (payment_status === 'failed' || payment_status === 'expired') {
|
||||
// Payment failed - delete pending order
|
||||
await pool.execute('DELETE FROM pending_orders WHERE id = ?', [pendingOrder.id])
|
||||
console.log(`Payment failed - Pending order ${pendingOrder.id} deleted`)
|
||||
}
|
||||
|
||||
// Return success to NOWPayments
|
||||
return NextResponse.json({ status: 'ok' }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error('Error processing IPN callback:', error)
|
||||
// Still return 200 to prevent NOWPayments from retrying
|
||||
return NextResponse.json({ status: 'error' }, { status: 200 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ interface DropData {
|
||||
created_at: string
|
||||
start_time: string | null
|
||||
is_upcoming?: boolean
|
||||
sales_fill?: number // Only confirmed sales
|
||||
pending_fill?: number // Items on hold (pending orders)
|
||||
}
|
||||
|
||||
interface User {
|
||||
@@ -59,10 +61,17 @@ export default function Drop() {
|
||||
const response = await fetch('/api/drops/active')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setDrop(data)
|
||||
// Handle both null response and actual drop data
|
||||
setDrop(data) // data can be null if no active drop
|
||||
} else {
|
||||
// If response is not ok, log the error
|
||||
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
console.error('Error fetching active drop:', errorData)
|
||||
setDrop(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching active drop:', error)
|
||||
setDrop(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -285,6 +294,16 @@ export default function Drop() {
|
||||
{drop.unit} of {drop.size}
|
||||
{drop.unit} reserved
|
||||
</div>
|
||||
{(() => {
|
||||
const pendingFill = Number(drop.pending_fill) || 0;
|
||||
console.log(`pending fill:${pendingFill}`)
|
||||
return pendingFill > 0 && (
|
||||
<div className="meta" style={{ fontSize: '12px', color: 'var(--muted)', marginTop: '4px' }}>
|
||||
{drop.unit === 'kg' ? pendingFill.toFixed(2) : Math.round(pendingFill)}
|
||||
{drop.unit} on hold (10 min checkout window)
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
7
migrations/add_expires_at_to_pending_orders.sql
Normal file
7
migrations/add_expires_at_to_pending_orders.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Add expires_at column to pending_orders table for 10-minute reservation timeout
|
||||
ALTER TABLE `pending_orders`
|
||||
ADD COLUMN `expires_at` datetime NOT NULL DEFAULT (DATE_ADD(NOW(), INTERVAL 10 MINUTE));
|
||||
|
||||
-- Add index on expires_at for efficient cleanup queries
|
||||
CREATE INDEX `idx_expires_at` ON `pending_orders` (`expires_at`);
|
||||
|
||||
Reference in New Issue
Block a user