diff --git a/README.md b/README.md index bf5e048..e736e41 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,16 @@ NOWPAYMENTS_CURRENCY=usd # Sandbox doesn't support CHF, use USD or other suppor # IPN Callback URL (your external Node.js service that handles IPN callbacks) IPN_CALLBACK_URL=http://your-ipn-service.com/api/payments/ipn-callback -# Base URL for success/cancel redirects (use your domain in production) +# Payment Currency (crypto currency for payments, e.g. btc, eth, usdt) +# Default: btc +NOWPAYMENTS_PAY_CURRENCY=btc + +# Use Fixed Rate (optional, true/false) +# If true, exchange rate is frozen for 20 minutes. Payment expires if not paid within 20 minutes. +# Default: false +NOWPAYMENTS_FIXED_RATE=false + +# Base URL (use your domain in production) NEXT_PUBLIC_BASE_URL=http://localhost:3420 ``` diff --git a/app/api/payments/cancel-pending/route.ts b/app/api/payments/cancel-pending/route.ts new file mode 100644 index 0000000..a447ca0 --- /dev/null +++ b/app/api/payments/cancel-pending/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import pool from '@/lib/db' + +// DELETE /api/payments/cancel-pending - Cancel a pending order (frees up inventory) +export async function DELETE(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 { payment_id } = body + + // Validate required fields + if (!payment_id) { + return NextResponse.json( + { error: 'Missing required field: payment_id' }, + { status: 400 } + ) + } + + // Find the pending order + const [pendingRows] = await pool.execute( + 'SELECT * FROM pending_orders WHERE payment_id = ? AND buyer_id = ?', + [payment_id, buyer_id] + ) + + const pendingOrders = pendingRows as any[] + if (pendingOrders.length === 0) { + return NextResponse.json( + { error: 'Pending order not found or already cancelled' }, + { status: 404 } + ) + } + + const pendingOrder = pendingOrders[0] + + // Check if payment has already been confirmed (sale exists) + const [salesRows] = await pool.execute( + 'SELECT * FROM sales WHERE payment_id = ?', + [payment_id] + ) + const sales = salesRows as any[] + if (sales.length > 0) { + // Payment already confirmed, don't delete pending order + // The IPN handler should have already deleted it, but if not, leave it + return NextResponse.json( + { error: 'Payment already confirmed. Cannot cancel.' }, + { status: 400 } + ) + } + + // Delete the pending order (frees up inventory) + await pool.execute( + 'DELETE FROM pending_orders WHERE payment_id = ? AND buyer_id = ?', + [payment_id, buyer_id] + ) + + return NextResponse.json({ + message: 'Pending order cancelled successfully', + payment_id: payment_id, + }) + } catch (error) { + console.error('Error cancelling pending order:', error) + return NextResponse.json( + { error: 'Failed to cancel pending order' }, + { status: 500 } + ) + } +} + diff --git a/app/api/payments/check-status/route.ts b/app/api/payments/check-status/route.ts index f556de9..62d71b3 100644 --- a/app/api/payments/check-status/route.ts +++ b/app/api/payments/check-status/route.ts @@ -43,11 +43,31 @@ export async function GET(request: NextRequest) { const pendingOrders = pendingRows as any[] const sales = salesRows as any[] - if (pendingOrders.length === 0 && sales.length === 0) { - return NextResponse.json( - { error: 'Payment not found' }, - { status: 404 } - ) + // Check if pending order exists and if sale exists + const hasPendingOrder = pendingOrders.length > 0 + const hasSale = sales.length > 0 + + // If pending order is gone and sale exists, payment was processed + if (!hasPendingOrder && hasSale) { + return NextResponse.json({ + payment_id, + status: 'completed', + payment_status: 'completed', + has_pending_order: false, + has_sale: true, + sale: sales[0], + }) + } + + // If both are gone, payment was cancelled or expired + if (!hasPendingOrder && !hasSale) { + return NextResponse.json({ + payment_id, + status: 'cancelled', + payment_status: 'cancelled', + has_pending_order: false, + has_sale: false, + }) } // Get NOWPayments config (testnet or production) @@ -82,6 +102,8 @@ export async function GET(request: NextRequest) { pay_currency: paymentStatus.pay_currency, price_amount: paymentStatus.price_amount, price_currency: paymentStatus.price_currency, + has_pending_order: hasPendingOrder, + has_sale: hasSale, }) } catch (error) { console.error('Error checking payment status:', error) diff --git a/app/api/payments/create-invoice/route.ts b/app/api/payments/create-invoice/route.ts index fbba760..c0aca9e 100644 --- a/app/api/payments/create-invoice/route.ts +++ b/app/api/payments/create-invoice/route.ts @@ -3,7 +3,9 @@ import { cookies } from 'next/headers' import pool from '@/lib/db' import { getNowPaymentsConfig } from '@/lib/nowpayments' -// POST /api/payments/create-invoice - Create a NOWPayments invoice +// 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 @@ -20,7 +22,7 @@ export async function POST(request: NextRequest) { const buyer_id = parseInt(buyerIdCookie, 10) const body = await request.json() - const { drop_id, size } = body + const { drop_id, size, pay_currency } = body // Validate required fields if (!drop_id || !size) { @@ -132,10 +134,16 @@ export async function POST(request: NextRequest) { 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`, { + // Create NOWPayments payment + // Note: Payment API requires pay_currency (crypto currency) + // Use currency from request, or fall back to env/default + const payCurrency = pay_currency || process.env.NOWPAYMENTS_PAY_CURRENCY || '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, @@ -144,11 +152,11 @@ export async function POST(request: NextRequest) { body: JSON.stringify({ price_amount: priceAmount, price_currency: nowPaymentsConfig.currency, + pay_currency: payCurrency, // Required: crypto currency (btc, eth, etc) 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}`, + is_fixed_rate: isFixedRate, // Optional: freeze exchange rate for 20 minutes }), }) @@ -158,28 +166,36 @@ export async function POST(request: NextRequest) { await connection.rollback() connection.release() return NextResponse.json( - { error: 'Failed to create payment invoice', details: error }, + { error: 'Failed to create payment', details: error }, { status: 500 } ) } - const invoice = await nowPaymentsResponse.json() + const payment = await nowPaymentsResponse.json() // Store pending order with expiration time (atomically reserves inventory) + // payment.payment_id is the NOWPayments payment ID 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] + [payment.payment_id, orderId, drop_id, buyer_id, size, priceAmount, nowPaymentsConfig.currency, expiresAt] ) // 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 payment details - sale will be created by external IPN handler when payment is confirmed return NextResponse.json({ - invoice_url: invoice.invoice_url, - payment_id: invoice.id, + 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, // Price in fiat + price_currency: payment.price_currency, // Fiat 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() @@ -187,9 +203,9 @@ export async function POST(request: NextRequest) { throw error } } catch (error) { - console.error('Error creating invoice:', error) + console.error('Error creating payment:', error) return NextResponse.json( - { error: 'Failed to create invoice' }, + { error: 'Failed to create payment' }, { status: 500 } ) } diff --git a/app/api/payments/currencies/route.ts b/app/api/payments/currencies/route.ts new file mode 100644 index 0000000..cb1b865 --- /dev/null +++ b/app/api/payments/currencies/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server' +import { getNowPaymentsConfig } from '@/lib/nowpayments' + +// GET /api/payments/currencies - Get available payment currencies from NOWPayments +export async function GET() { + try { + const nowPaymentsConfig = getNowPaymentsConfig() + + // Fetch available currencies from NOWPayments + const response = await fetch( + `${nowPaymentsConfig.baseUrl}/v1/currencies?fixed_rate=true`, + { + method: 'GET', + headers: { + 'x-api-key': nowPaymentsConfig.apiKey, + }, + } + ) + + if (!response.ok) { + const error = await response.json() + console.error('NOWPayments currencies error:', error) + return NextResponse.json( + { error: 'Failed to fetch available currencies', details: error }, + { status: 500 } + ) + } + + const data = await response.json() + + // Return the currencies array + return NextResponse.json({ + currencies: data.currencies || [], + }) + } catch (error) { + console.error('Error fetching currencies:', error) + return NextResponse.json( + { error: 'Failed to fetch currencies' }, + { status: 500 } + ) + } +} + diff --git a/app/components/Drop.tsx b/app/components/Drop.tsx index 2c8a88e..d60a693 100644 --- a/app/components/Drop.tsx +++ b/app/components/Drop.tsx @@ -29,8 +29,16 @@ export default function Drop() { const [drop, setDrop] = useState(null) const [loading, setLoading] = useState(true) const [selectedSize, setSelectedSize] = useState(50) + const [selectedCurrency, setSelectedCurrency] = useState('btc') + const [availableCurrencies, setAvailableCurrencies] = useState([]) + const [loadingCurrencies, setLoadingCurrencies] = useState(false) const [showConfirmModal, setShowConfirmModal] = useState(false) const [showAuthModal, setShowAuthModal] = useState(false) + const [showPaymentModal, setShowPaymentModal] = useState(false) + const [showSuccessModal, setShowSuccessModal] = useState(false) + const [showErrorModal, setShowErrorModal] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + const [paymentData, setPaymentData] = useState(null) const [processing, setProcessing] = useState(false) const [user, setUser] = useState(null) const [checkingAuth, setCheckingAuth] = useState(true) @@ -40,6 +48,41 @@ export default function Drop() { checkAuth() }, []) + // Poll payment status when payment modal is open + useEffect(() => { + if (!showPaymentModal || !paymentData?.payment_id) return + + const checkPaymentStatus = async () => { + try { + const response = await fetch(`/api/payments/check-status?payment_id=${paymentData.payment_id}`, { + credentials: 'include', + }) + + if (response.ok) { + const status = await response.json() + + // If pending order is gone and sale exists, payment was processed + if (!status.has_pending_order && status.has_sale) { + // Close payment modal + setShowPaymentModal(false) + // Show success modal + setShowSuccessModal(true) + // Refresh drop data + await fetchActiveDrop() + } + } + } catch (error) { + console.error('Error checking payment status:', error) + } + } + + // Check immediately, then poll every 3 seconds + checkPaymentStatus() + const interval = setInterval(checkPaymentStatus, 3000) + + return () => clearInterval(interval) + }, [showPaymentModal, paymentData?.payment_id]) + const checkAuth = async () => { try { const response = await fetch('/api/auth/session', { @@ -105,12 +148,58 @@ export default function Drop() { return sizes.filter((size) => size <= remainingInGrams) } + const fetchAvailableCurrencies = async () => { + setLoadingCurrencies(true) + try { + const response = await fetch('/api/payments/currencies') + if (response.ok) { + const data = await response.json() + + // When fixed_rate=true, API returns objects with { currency, min_amount, max_amount } + // When fixed_rate=false, API returns array of strings + const currencies: string[] = [] + if (Array.isArray(data.currencies)) { + data.currencies.forEach((c: any) => { + let currencyCode: string | null = null + + // Handle object format (when fixed_rate=true) + if (typeof c === 'object' && c !== null && c.currency) { + currencyCode = String(c.currency).trim().toLowerCase() + } + // Handle string format (when fixed_rate=false) + else if (typeof c === 'string') { + currencyCode = c.trim().toLowerCase() + } + + // Add to array if valid + if (currencyCode && currencyCode.length > 0) { + currencies.push(currencyCode) + } + }) + } + + setAvailableCurrencies(currencies) + // Set default to BTC if available, otherwise first currency + if (currencies.length > 0) { + const defaultCurrency = currencies.includes('btc') ? 'btc' : currencies[0] + setSelectedCurrency(defaultCurrency) + } + } + } catch (error) { + console.error('Error fetching currencies:', error) + } finally { + setLoadingCurrencies(false) + } + } + const handleJoinDrop = () => { // Check if user is logged in if (!user) { setShowAuthModal(true) return } + // Fetch available currencies when opening confirm modal + fetchAvailableCurrencies() setShowConfirmModal(true) } @@ -126,7 +215,7 @@ export default function Drop() { setProcessing(true) try { - // Create NOWPayments invoice and sale record + // Create NOWPayments payment const response = await fetch('/api/payments/create-invoice', { method: 'POST', headers: { @@ -136,6 +225,7 @@ export default function Drop() { body: JSON.stringify({ drop_id: drop.id, size: selectedSize, // Size in grams + pay_currency: selectedCurrency, // Selected payment currency }), }) @@ -148,26 +238,33 @@ export default function Drop() { setProcessing(false) return } - alert(`Error: ${error.error || 'Failed to create payment invoice'}`) + // Show error modal instead of alert + setErrorMessage(error.error || 'Failed to create payment') + setShowErrorModal(true) + setShowConfirmModal(false) setProcessing(false) return } const data = await response.json() - // Close modal + // Close confirmation modal setShowConfirmModal(false) + setProcessing(false) - // Redirect to NOWPayments invoice - if (data.invoice_url) { - window.location.href = data.invoice_url + // Show payment modal with payment details + if (data.pay_address) { + setPaymentData(data) + setShowPaymentModal(true) } else { - alert('Payment invoice created but no redirect URL received') + setErrorMessage('Payment created but no payment address received') + setShowErrorModal(true) await fetchActiveDrop() } } catch (error) { console.error('Error creating payment invoice:', error) - alert('Failed to create payment invoice. Please try again.') + setErrorMessage('Failed to create payment invoice. Please try again.') + setShowErrorModal(true) setProcessing(false) } } @@ -384,6 +481,57 @@ export default function Drop() {

Price per {drop.unit}: {(drop.ppu / 1000).toFixed(2)} CHF

+ + {/* Currency Selection */} +
+ + {loadingCurrencies ? ( +

Loading currencies...

+ ) : ( + + )} +
+
incl. 2.5% VAT

+

+ Pay with: {String(selectedCurrency || '').toUpperCase()} +

setShowAuthModal(false)} onLogin={handleLogin} /> + + {/* Success Modal */} + {showSuccessModal && ( +
setShowSuccessModal(false)} + > +
e.stopPropagation()} + > +

+ ✓ Payment Confirmed! +

+

+ Your payment has been successfully processed. Your order is confirmed and will be included in the drop. +

+
+ +
+
+
+ )} + + {/* Error Modal */} + {showErrorModal && ( +
{ + setShowErrorModal(false) + setErrorMessage('') + // Don't refresh - just return to drop view as is + }} + > +
e.stopPropagation()} + > +

+ ⚠️ Error +

+

+ {errorMessage} +

+
+ +
+
+
+ )} + + {/* Payment Modal */} + {showPaymentModal && paymentData && ( +
{ + // Check if payment was already processed before cancelling + if (paymentData?.payment_id) { + try { + const statusResponse = await fetch(`/api/payments/check-status?payment_id=${paymentData.payment_id}`, { + credentials: 'include', + }) + + if (statusResponse.ok) { + const status = await statusResponse.json() + // Only cancel if payment hasn't been processed yet + if (status.has_pending_order && !status.has_sale) { + await fetch('/api/payments/cancel-pending', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + payment_id: paymentData.payment_id, + }), + }) + } + } + } catch (error) { + console.error('Error checking/cancelling pending order:', error) + } + } + setShowPaymentModal(false) + // Refresh drop data to show updated inventory + await fetchActiveDrop() + }} + > +
e.stopPropagation()} + > +

+ Complete Payment +

+ +
+

+ Amount to Pay: {paymentData.pay_amount} {paymentData.pay_currency.toUpperCase()} +

+

+ Price: {paymentData.price_amount} {paymentData.price_currency.toUpperCase()} +

+ +
+ +
+ {paymentData.pay_address} +
+ +
+ + {paymentData.payin_extra_id && ( +
+ +
+ {paymentData.payin_extra_id} +
+ +
+ )} + + {paymentData.expiration_estimate_date && ( +

+ Payment expires: {new Date(paymentData.expiration_estimate_date).toLocaleString()} +

+ )} + +
+

+ Status: {paymentData.payment_status} +

+

+ ⚠️ Closing this window will cancel your reservation and free up the inventory. +

+
+
+ +
+ +
+
+
+ )}
) }