import { Router, Request, Response } from 'express'; import { pool } from '../db'; import { requireCustomer } from '../middleware/customerAuth'; import { notifyFavoritersOfSale } from '../favoriteAlerts'; const router = Router(); const webhookRouter = Router(); const PAYPAL_BASE = process.env.PAYPAL_ENV === 'live' ? 'https://api-m.paypal.com' : 'https://api-m.sandbox.paypal.com'; async function getAccessToken(): Promise { const auth = Buffer.from(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`).toString('base64'); const resp = await fetch(`${PAYPAL_BASE}/v1/oauth2/token`, { method: 'POST', headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=client_credentials' }); const data = await resp.json(); if (!resp.ok) throw new Error('paypal auth failed: ' + JSON.stringify(data)); return data.access_token; } interface CartItem { id: number; name: string; price_cents: number; } interface LockedCart { cartId: number; items: CartItem[]; totalCents: number; } // Locks the customer's cart, verifies every item is still reserved to them, // and returns { cartId, items: [{id, name, price_cents}], totalCents }. async function loadLockedCart(client: any, customerId: number): Promise { const { rows: cartRows } = await client.query(`SELECT id FROM carts WHERE customer_id = $1`, [customerId]); if (!cartRows.length) return null; const cartId = cartRows[0].id; const { rows: items } = await client.query( `SELECT i.id, i.name, i.price_cents FROM cart_items ci JOIN items i ON i.id = ci.item_id WHERE ci.cart_id = $1 FOR UPDATE OF i`, [cartId] ); if (!items.length) return { cartId, items: [], totalCents: 0 }; const totalCents = items.reduce((sum: number, it: CartItem) => sum + it.price_cents, 0); return { cartId, items, totalCents }; } type OpenedCheckout = | { ok: true; checkoutId: number; cart: LockedCart } | { ok: false; error: string }; // Both checkout flows open the same way: confirm the shipping address belongs // to the caller, lock the cart, and record a pending checkout with its line // items. The caller owns the transaction — on `ok: false` it should roll back // and return the error as a 400. async function openCheckout( client: any, customerId: number, shippingAddressId: number, processor: string, processorOrderId: string | null ): Promise { const { rows: addrRows } = await client.query( `SELECT id FROM shipping_addresses WHERE id = $1 AND customer_id = $2`, [shippingAddressId, customerId] ); if (!addrRows.length) return { ok: false, error: 'invalid shipping address' }; const cart = await loadLockedCart(client, customerId); if (!cart || !cart.items.length) return { ok: false, error: 'cart is empty' }; const { rows: checkoutRows } = await client.query( `INSERT INTO checkouts (customer_id, shipping_address_id, processor, processor_order_id, amount_cents, status) VALUES ($1, $2, $3, $4, $5, 'pending') RETURNING id`, [customerId, shippingAddressId, processor, processorOrderId, cart.totalCents] ); const checkoutId = checkoutRows[0].id; for (const it of cart.items) { await client.query( `INSERT INTO checkout_items (checkout_id, item_id, price_cents) VALUES ($1, $2, $3)`, [checkoutId, it.id, it.price_cents] ); } return { ok: true, checkoutId, cart }; } router.post('/paypal/create', requireCustomer, async (req: Request, res: Response) => { const { shippingAddressId } = req.body; if (!shippingAddressId) return res.status(400).json({ error: 'shippingAddressId is required' }); const client = await pool.connect(); try { await client.query('BEGIN'); const opened = await openCheckout(client, req.customerId as number, shippingAddressId, 'paypal', null); if (!opened.ok) { await client.query('ROLLBACK'); return res.status(400).json({ error: opened.error }); } const { checkoutId, cart } = opened; const token = await getAccessToken(); const currency = process.env.SITE_CURRENCY || 'USD'; const orderResp = await fetch(`${PAYPAL_BASE}/v2/checkout/orders`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ intent: 'CAPTURE', purchase_units: [{ custom_id: String(checkoutId), amount: { currency_code: currency, value: (cart.totalCents / 100).toFixed(2), breakdown: { item_total: { currency_code: currency, value: (cart.totalCents / 100).toFixed(2) } } }, items: cart.items.map((it: CartItem) => ({ name: it.name.slice(0, 127), quantity: '1', unit_amount: { currency_code: currency, value: (it.price_cents / 100).toFixed(2) } })) }] }) }); const order = await orderResp.json(); if (!orderResp.ok) { await client.query('ROLLBACK'); return res.status(502).json({ error: 'paypal order create failed', detail: order }); } await client.query(`UPDATE checkouts SET processor_order_id = $1 WHERE id = $2`, [order.id, checkoutId]); await client.query('COMMIT'); res.json({ orderID: order.id, checkoutId }); } catch (err) { await client.query('ROLLBACK'); console.error(err); res.status(500).json({ error: 'internal error' }); } finally { client.release(); } }); // Returns the sold item ids and the buyer, so the caller can notify favoriters // *after* COMMIT. Sending inside the transaction would email people about a // sale that then rolled back, and would hold the transaction open for SMTP. async function completeCheckout(client: any, checkoutId: number, processor: string, processorOrderId: string | null, rawEvent: unknown): Promise<{ itemIds: number[]; buyerId: number | null }> { const { rows: checkoutItems } = await client.query( `SELECT item_id, price_cents FROM checkout_items WHERE checkout_id = $1`, [checkoutId] ); const { rows: checkoutRows } = await client.query(`SELECT customer_id FROM checkouts WHERE id = $1`, [checkoutId]); const customerId = checkoutRows[0]?.customer_id; for (const ci of checkoutItems) { await client.query(`UPDATE items SET status = 'sold', sold_at = now() WHERE id = $1`, [ci.item_id]); await client.query( `INSERT INTO orders (item_id, customer_id, checkout_id, processor, processor_order_id, amount_cents, status, raw_event) VALUES ($1, $2, $3, $4, $5, $6, 'completed', $7)`, [ci.item_id, customerId, checkoutId, processor, processorOrderId, ci.price_cents, rawEvent] ); await client.query(`DELETE FROM cart_items WHERE item_id = $1`, [ci.item_id]); } await client.query(`UPDATE checkouts SET status = 'completed', raw_event = $1 WHERE id = $2`, [rawEvent, checkoutId]); return { itemIds: checkoutItems.map((ci: { item_id: number }) => ci.item_id), buyerId: customerId ?? null }; } router.post('/paypal/capture', requireCustomer, async (req: Request, res: Response) => { const { orderID } = req.body; const client = await pool.connect(); try { const token = await getAccessToken(); const captureResp = await fetch(`${PAYPAL_BASE}/v2/checkout/orders/${orderID}/capture`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }); const capture = await captureResp.json(); if (!captureResp.ok || capture.status !== 'COMPLETED') { return res.status(502).json({ error: 'capture failed', detail: capture }); } const { rows } = await pool.query( `SELECT id FROM checkouts WHERE processor_order_id = $1 AND customer_id = $2`, [orderID, req.customerId] ); if (!rows.length) return res.status(404).json({ error: 'checkout not found' }); await client.query('BEGIN'); const sold = await completeCheckout(client, rows[0].id, 'paypal', orderID, capture); await client.query('COMMIT'); await notifyFavoritersOfSale(sold.itemIds, sold.buyerId); res.json({ status: 'completed' }); } catch (err) { await client.query('ROLLBACK'); console.error(err); res.status(500).json({ error: 'internal error' }); } finally { client.release(); } }); router.post('/demo/purchase', requireCustomer, async (req: Request, res: Response) => { if (process.env.DEMO_MODE === 'false') return res.status(403).json({ error: 'demo mode disabled' }); const { shippingAddressId } = req.body; if (!shippingAddressId) return res.status(400).json({ error: 'shippingAddressId is required' }); const client = await pool.connect(); try { await client.query('BEGIN'); const opened = await openCheckout(client, req.customerId as number, shippingAddressId, 'demo', `demo-${Date.now()}`); if (!opened.ok) { await client.query('ROLLBACK'); return res.status(400).json({ error: opened.error }); } const sold = await completeCheckout(client, opened.checkoutId, 'demo', null, { demo: true }); await client.query('COMMIT'); await notifyFavoritersOfSale(sold.itemIds, sold.buyerId); res.json({ status: 'completed' }); } catch (err) { await client.query('ROLLBACK'); console.error(err); res.status(500).json({ error: 'internal error' }); } finally { client.release(); } }); webhookRouter.post('/', async (req: Request, res: Response) => { try { const token = await getAccessToken(); const verifyResp = await fetch(`${PAYPAL_BASE}/v1/notifications/verify-webhook-signature`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ auth_algo: req.headers['paypal-auth-algo'], cert_url: req.headers['paypal-cert-url'], transmission_id: req.headers['paypal-transmission-id'], transmission_sig: req.headers['paypal-transmission-sig'], transmission_time: req.headers['paypal-transmission-time'], webhook_id: process.env.PAYPAL_WEBHOOK_ID, webhook_event: req.body }) }); const verification = await verifyResp.json(); if (verification.verification_status !== 'SUCCESS') return res.status(400).end(); const event = req.body; if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') { const checkoutId = event.resource?.custom_id; if (checkoutId) { const { rows } = await pool.query(`SELECT status FROM checkouts WHERE id = $1`, [checkoutId]); if (rows.length && rows[0].status !== 'completed') { const client = await pool.connect(); try { await client.query('BEGIN'); const sold = await completeCheckout(client, parseInt(checkoutId, 10), 'paypal', event.resource?.id, event); await client.query('COMMIT'); await notifyFavoritersOfSale(sold.itemIds, sold.buyerId); } catch (e) { await client.query('ROLLBACK'); console.error('webhook completeCheckout failed', e); } finally { client.release(); } } } } res.status(200).end(); } catch (err) { console.error('webhook error', err); res.status(500).end(); } }); export { router, webhookRouter };