import { NextRequest, NextResponse } from 'next/server' import { cookies } from 'next/headers' import pool from '@/lib/db' import { getNowPaymentsConfig } from '@/lib/nowpayments' import { ALLOWED_PAYMENT_CURRENCIES, isAllowedCurrency } from '@/lib/payment-currencies' import { getCountryFromIp, calculateShippingFee } from '@/lib/geolocation' import { getCurrencyForCountry, convertPriceForCountry } from '@/lib/currency' // POST /api/payments/create-invoice - Create a NOWPayments payment // Note: Endpoint name kept as "create-invoice" for backward compatibility // but it now uses the /v1/payment endpoint instead of /v1/invoice export async function POST(request: NextRequest) { try { // Get buyer_id from session cookie const cookieStore = await cookies() const buyerIdCookie = cookieStore.get('buyer_id')?.value if (!buyerIdCookie) { return NextResponse.json( { error: 'Authentication required' }, { status: 401 } ) } const buyer_id = parseInt(buyerIdCookie, 10) const body = await request.json() const { drop_id, size, pay_currency, buyer_data_id, points_to_use } = body // Validate required fields if (!drop_id || !size || !buyer_data_id) { return NextResponse.json( { error: 'Missing required fields: drop_id, size, and buyer_data_id' }, { status: 400 } ) } // Validate and parse points_to_use const pointsToUse = points_to_use ? parseFloat(points_to_use) : 0 if (pointsToUse < 0) { return NextResponse.json( { error: 'points_to_use must be non-negative' }, { status: 400 } ) } // Validate pay_currency against allowed list const normalizedPayCurrency = pay_currency ? String(pay_currency).trim().toLowerCase() : null if (normalizedPayCurrency && !isAllowedCurrency(normalizedPayCurrency)) { return NextResponse.json( { error: `Invalid payment currency. Allowed currencies: ${ALLOWED_PAYMENT_CURRENCIES.join(', ').toUpperCase()}` }, { status: 400 } ) } // Verify buyer_data_id exists and belongs to the buyer const [buyerDataRows] = await pool.execute( 'SELECT id FROM buyer_data WHERE id = ? AND buyer_id = ?', [buyer_data_id, buyer_id] ) const buyerData = buyerDataRows as any[] if (buyerData.length === 0) { return NextResponse.json( { error: 'Invalid buyer_data_id or buyer_data does not belong to user' }, { status: 400 } ) } // Get drop details const [dropRows] = await pool.execute( 'SELECT * FROM drops WHERE id = ?', [drop_id] ) const drops = dropRows as any[] if (drops.length === 0) { return NextResponse.json( { error: 'Drop not found' }, { status: 404 } ) } const drop = drops[0] // 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 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 } ) } // Check if user has unlocked wholesale prices (use transaction connection to avoid connection leak) const [referralRows] = await connection.execute( 'SELECT COUNT(*) as count FROM referrals WHERE referrer = ?', [buyer_id] ) const referralCount = (referralRows as any[])[0]?.count || 0 const isWholesaleUnlocked = referralCount >= 3 // Get country from IP to determine currency const countryCode = await getCountryFromIp(request) const currency = getCurrencyForCountry(countryCode) // Calculate price in EUR (database stores prices in EUR) // ppu is stored as integer where 1000 = 1.00 EUR, so divide by 1000 to get actual price // Assuming ppu is per gram const pricePerGramEur = drop.ppu / 1000 const priceToUseEur = isWholesaleUnlocked ? pricePerGramEur * 0.76 : pricePerGramEur const priceAmountEur = size * priceToUseEur // Convert price to user's currency (CHF for Swiss, EUR for others) const priceAmount = convertPriceForCountry(priceAmountEur, countryCode) // Handle referral points discount if points are being used let pointsDiscount = 0 let actualPointsUsed = 0 if (pointsToUse > 0) { // Get buyer's current points balance and points_to_chf setting const [buyerPointsRows] = await connection.execute( 'SELECT referral_points FROM buyers WHERE id = ?', [buyer_id] ) const buyerPointsData = buyerPointsRows as any[] if (buyerPointsData.length === 0) { await connection.rollback() connection.release() return NextResponse.json( { error: 'Buyer not found' }, { status: 404 } ) } const availablePoints = parseFloat(buyerPointsData[0].referral_points) || 0 if (pointsToUse > availablePoints) { await connection.rollback() connection.release() return NextResponse.json( { error: 'Insufficient referral points' }, { status: 400 } ) } // Get points_to_eur setting (fallback to points_to_chf for backward compatibility) const [settingsRows] = await connection.execute( 'SELECT setting_key, setting_value FROM referral_settings WHERE setting_key IN (?, ?)', ['points_to_eur', 'points_to_chf'] ) const settings = settingsRows as any[] let pointsToEur = parseFloat(settings.find(s => s.setting_key === 'points_to_eur')?.setting_value || '0') // If points_to_eur not found, use points_to_chf and convert if (pointsToEur === 0) { const pointsToChf = parseFloat(settings.find(s => s.setting_key === 'points_to_chf')?.setting_value || '100') // Convert CHF-based points to EUR-based (1 CHF ≈ 1.0309 EUR) pointsToEur = pointsToChf / 1.030927835 } if (pointsToEur === 0) { pointsToEur = 100 // Default fallback } // Calculate discount in EUR first (universal base currency) const discountEur = pointsToUse / pointsToEur // Convert discount to user's currency if (currency === 'CHF') { // Convert EUR to CHF (1 EUR = 0.97 CHF) pointsDiscount = discountEur * 0.97 } else { // Already in EUR pointsDiscount = discountEur } // Don't allow discount to exceed the product price (before shipping) pointsDiscount = Math.min(pointsDiscount, priceAmount) actualPointsUsed = pointsToUse // Deduct points directly (stored procedures can be tricky with transactions) // Get current points balance const [currentPointsRows] = await connection.execute( 'SELECT referral_points FROM buyers WHERE id = ? FOR UPDATE', [buyer_id] ) const currentPointsData = currentPointsRows as any[] const currentPoints = parseFloat(currentPointsData[0]?.referral_points) || 0 if (currentPoints < actualPointsUsed) { await connection.rollback() connection.release() return NextResponse.json( { error: 'Insufficient referral points' }, { status: 400 } ) } // Deduct points const newBalance = currentPoints - actualPointsUsed await connection.execute( 'UPDATE buyers SET referral_points = ? WHERE id = ?', [newBalance, buyer_id] ) // Record the transaction (we'll update pending_order_id later when we have it) // For now, we'll record it after creating the pending order } // Calculate final price after discount const priceAfterDiscount = Math.max(0, priceAmount - pointsDiscount) // Calculate shipping fee (already in correct currency: CHF for CH, EUR for others) const shippingFee = calculateShippingFee(countryCode) // Add shipping fee to total price (shipping is not discounted) const totalPriceAmount = priceAfterDiscount + shippingFee // Round to 2 decimal places const roundedPriceAmount = Math.round(totalPriceAmount * 100) / 100 const roundedShippingFee = Math.round(shippingFee * 100) / 100 const roundedSubtotal = Math.round(priceAfterDiscount * 100) / 100 const roundedPointsDiscount = Math.round(pointsDiscount * 100) / 100 // 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 NOWPayments config (testnet or production) const nowPaymentsConfig = getNowPaymentsConfig() // Use currency based on user location (CHF for Swiss, EUR for others) // Override the default currency from config with user's currency const priceCurrency = currency.toLowerCase() // Calculate expiration time (10 minutes from now) const expiresAt = new Date() expiresAt.setMinutes(expiresAt.getMinutes() + 10) // Create NOWPayments payment // Note: Payment API requires pay_currency (crypto currency) // Use currency from request (already validated), or fall back to env/default (must be in allowed list) const defaultCurrency = process.env.NOWPAYMENTS_PAY_CURRENCY?.toLowerCase() || 'btc' const payCurrency = normalizedPayCurrency || (isAllowedCurrency(defaultCurrency) ? defaultCurrency : 'btc') // Optional: Use fixed rate for 20 minutes (prevents rate changes during checkout) // If is_fixed_rate is true, payment expires after 20 minutes if not paid const isFixedRate = process.env.NOWPAYMENTS_FIXED_RATE === 'true' || false const nowPaymentsResponse = await fetch(`${nowPaymentsConfig.baseUrl}/v1/payment`, { method: 'POST', headers: { 'x-api-key': nowPaymentsConfig.apiKey, 'Content-Type': 'application/json', }, body: JSON.stringify({ price_amount: roundedPriceAmount, price_currency: priceCurrency, // CHF for Swiss users, EUR for others pay_currency: payCurrency, // Required: crypto currency (btc, eth, etc) order_id: orderId, order_description: `${drop.item} - ${size}g`, ipn_callback_url: ipnCallbackUrl, is_fixed_rate: isFixedRate, // Optional: freeze exchange rate for 20 minutes }), }) 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', details: error }, { status: 500 } ) } const payment = await nowPaymentsResponse.json() // Store pending order with expiration time (atomically reserves inventory) // payment.payment_id is the NOWPayments payment ID const [pendingOrderResult] = await connection.execute( 'INSERT INTO pending_orders (payment_id, order_id, drop_id, buyer_id, buyer_data_id, size, price_amount, price_currency, points_used, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [payment.payment_id, orderId, drop_id, buyer_id, buyer_data_id, size, roundedPriceAmount, priceCurrency, actualPointsUsed, expiresAt] ) const pendingOrderId = (pendingOrderResult as any).insertId // Record referral point transaction if points were used if (actualPointsUsed > 0) { await connection.execute( 'INSERT INTO referral_point_transactions (buyer_id, points, type, pending_order_id, description) VALUES (?, ?, ?, ?, ?)', [ buyer_id, actualPointsUsed, 'spent', pendingOrderId, `Points spent for purchase (Pending Order #${pendingOrderId})` ] ) } // Commit transaction - inventory is now reserved await connection.commit() connection.release() // Return payment details - sale will be created by external IPN handler when payment is confirmed return NextResponse.json({ payment_id: payment.payment_id, payment_status: payment.payment_status, pay_address: payment.pay_address, // Address where customer sends payment pay_amount: payment.pay_amount, // Amount in crypto to pay pay_currency: payment.pay_currency, // Crypto currency price_amount: payment.price_amount, // Total price in fiat (includes shipping, after points discount) price_currency: payment.price_currency, // Fiat currency (CHF or EUR) shipping_fee: roundedShippingFee, // Shipping fee in user's currency subtotal: roundedSubtotal, // Product price without shipping in user's currency (after discount) points_used: actualPointsUsed, // Points used for discount points_discount: roundedPointsDiscount, // Discount amount in user's currency order_id: orderId, payin_extra_id: payment.payin_extra_id, // Memo/tag for certain currencies (XRP, XLM, etc) expiration_estimate_date: payment.expiration_estimate_date, // When payment expires }, { status: 201 }) } catch (error) { await connection.rollback() connection.release() throw error } } catch (error) { console.error('Error creating payment:', error) return NextResponse.json( { error: 'Failed to create payment' }, { status: 500 } ) } }