payment on hold

This commit is contained in:
root
2025-12-21 08:43:43 +01:00
parent 872e5a1a6a
commit 6741f5ed72
9 changed files with 977 additions and 217 deletions

399
IPN_INTEGRATION_README.md Normal file
View 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
View 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
View 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 customers 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. Customers 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"
}

View File

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

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

View File

@@ -45,27 +45,61 @@ 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(
// 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: '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 currentFillInDropUnit = currentFill
let totalReservedInDropUnit = totalReserved
let sizeInDropUnit = size
if (drop.unit === 'kg') {
currentFillInDropUnit = currentFill / 1000
totalReservedInDropUnit = totalReserved / 1000
sizeInDropUnit = size / 1000
}
// Check if there's enough remaining inventory
const remaining = drop.size - currentFillInDropUnit
const remaining = drop.size - totalReservedInDropUnit
if (sizeInDropUnit > remaining) {
await connection.rollback()
connection.release()
return NextResponse.json(
{ error: 'Not enough inventory remaining' },
{ error: 'Not enough inventory remaining. Item may be on hold by another buyer.' },
{ status: 400 }
)
}
@@ -91,19 +125,16 @@ export async function POST(request: NextRequest) {
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: {
@@ -124,6 +155,8 @@ export async function POST(request: NextRequest) {
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 }
@@ -132,18 +165,27 @@ export async function POST(request: NextRequest) {
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]
// 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]
)
// Return invoice URL - sale will be created when payment is confirmed via IPN
// Commit transaction - inventory is now reserved
await connection.commit()
connection.release()
// 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,
}, { status: 201 })
} catch (error) {
await connection.rollback()
connection.release()
throw error
}
} catch (error) {
console.error('Error creating invoice:', error)
return NextResponse.json(

View File

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

View File

@@ -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>
)
})()}
</>
)}

View 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`);