diff --git a/backend/src/routes/cartCheckout.ts b/backend/src/routes/cartCheckout.ts index a276116..09d96dc 100644 --- a/backend/src/routes/cartCheckout.ts +++ b/backend/src/routes/cartCheckout.ts @@ -22,9 +22,21 @@ async function getAccessToken(): Promise { 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) { +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; @@ -37,10 +49,49 @@ async function loadLockedCart(client: any, customerId: number) { [cartId] ); if (!items.length) return { cartId, items: [], totalCents: 0 }; - const totalCents = items.reduce((sum: number, it: any) => sum + it.price_cents, 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' }); @@ -48,27 +99,9 @@ router.post('/paypal/create', requireCustomer, async (req: Request, res: Respons const client = await pool.connect(); try { await client.query('BEGIN'); - const { rows: addrRows } = await client.query( - `SELECT id FROM shipping_addresses WHERE id = $1 AND customer_id = $2`, - [shippingAddressId, req.customerId] - ); - if (!addrRows.length) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'invalid shipping address' }); } - - const cart = await loadLockedCart(client, req.customerId as number); - if (!cart || !cart.items.length) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'cart is empty' }); } - - const { rows: checkoutRows } = await client.query( - `INSERT INTO checkouts (customer_id, shipping_address_id, processor, amount_cents, status) - VALUES ($1, $2, 'paypal', $3, 'pending') RETURNING id`, - [req.customerId, shippingAddressId, 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] - ); - } + 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'; @@ -84,7 +117,7 @@ router.post('/paypal/create', requireCustomer, async (req: Request, res: Respons value: (cart.totalCents / 100).toFixed(2), breakdown: { item_total: { currency_code: currency, value: (cart.totalCents / 100).toFixed(2) } } }, - items: cart.items.map((it: any) => ({ + 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) } @@ -168,26 +201,10 @@ router.post('/demo/purchase', requireCustomer, async (req: Request, res: Respons const client = await pool.connect(); try { await client.query('BEGIN'); - const { rows: addrRows } = await client.query( - `SELECT id FROM shipping_addresses WHERE id = $1 AND customer_id = $2`, - [shippingAddressId, req.customerId] - ); - if (!addrRows.length) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'invalid shipping address' }); } + 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 cart = await loadLockedCart(client, req.customerId as number); - if (!cart || !cart.items.length) { await client.query('ROLLBACK'); return res.status(400).json({ 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, 'demo', $3, $4, 'pending') RETURNING id`, - [req.customerId, shippingAddressId, `demo-${Date.now()}`, 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]); - } - - await completeCheckout(client, checkoutId, 'demo', null, { demo: true }); + await completeCheckout(client, opened.checkoutId, 'demo', null, { demo: true }); await client.query('COMMIT'); res.json({ status: 'completed' }); } catch (err) { diff --git a/backend/src/routes/demo.ts b/backend/src/routes/demo.ts deleted file mode 100755 index c4b93ea..0000000 --- a/backend/src/routes/demo.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Router, Request, Response } from 'express'; -import { pool } from '../db'; - -const router = Router(); - -router.post('/:id/purchase', async (req: Request, res: Response) => { - if (process.env.DEMO_MODE === 'false') { - return res.status(403).json({ error: 'demo mode disabled' }); - } - const itemId = req.params.id; - const client = await pool.connect(); - try { - await client.query('BEGIN'); - const { rows } = await client.query(`SELECT * FROM items WHERE id = $1 FOR UPDATE`, [itemId]); - const item = rows[0]; - if (!item) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); } - if (item.status === 'sold') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'already sold' }); } - - const { rows: updated } = await client.query( - `UPDATE items SET status = 'sold', sold_at = now() WHERE id = $1 RETURNING *`, - [itemId] - ); - await client.query( - `INSERT INTO orders (item_id, customer_id, processor, processor_order_id, amount_cents, status, raw_event) - VALUES ($1, $2, 'demo', $3, $4, 'completed', $5)`, - [itemId, req.customerId || null, `demo-${Date.now()}`, item.price_cents, JSON.stringify({ demo: true })] - ); - await client.query('COMMIT'); - res.json({ status: 'sold', item: updated[0] }); - } catch (err) { - await client.query('ROLLBACK'); - console.error(err); - res.status(500).json({ error: 'internal error' }); - } finally { - client.release(); - } -}); - -export default router; diff --git a/backend/src/routes/paypal.ts b/backend/src/routes/paypal.ts deleted file mode 100755 index d9c7852..0000000 --- a/backend/src/routes/paypal.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Router, Request, Response } from 'express'; -import { pool } from '../db'; - -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'; - -const RESERVATION_MINUTES = parseInt(process.env.RESERVATION_MINUTES || '15', 10); - -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; -} - -router.post('/:id/create', async (req: Request, res: Response) => { - const itemId = req.params.id; - const client = await pool.connect(); - try { - await client.query('BEGIN'); - const { rows } = await client.query(`SELECT * FROM items WHERE id = $1 FOR UPDATE`, [itemId]); - const item = rows[0]; - if (!item) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); } - if (item.status === 'sold') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'already sold' }); } - if (item.status === 'reserved' && new Date(item.reserved_until) > new Date()) { - await client.query('ROLLBACK'); - return res.status(409).json({ error: 'currently reserved by another checkout' }); - } - - const token = await getAccessToken(); - const amount = (item.price_cents / 100).toFixed(2); - 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(item.id), - description: item.name, - amount: { currency_code: process.env.SITE_CURRENCY || 'USD', value: amount } - }] - }) - }); - const order = await orderResp.json(); - if (!orderResp.ok) { await client.query('ROLLBACK'); return res.status(502).json({ error: 'paypal order create failed', detail: order }); } - - const reservedUntil = new Date(Date.now() + RESERVATION_MINUTES * 60 * 1000); - await client.query( - `UPDATE items SET status = 'reserved', reserved_until = $1, paypal_order_id = $2 WHERE id = $3`, - [reservedUntil, order.id, item.id] - ); - await client.query('COMMIT'); - res.json({ orderID: order.id }); - } catch (err) { - await client.query('ROLLBACK'); - console.error(err); - res.status(500).json({ error: 'internal error' }); - } finally { - client.release(); - } -}); - -router.post('/:id/capture', async (req: Request, res: Response) => { - const itemId = req.params.id; - 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 }); - } - - await client.query('BEGIN'); - const { rows } = await client.query( - `UPDATE items SET status = 'sold', sold_at = now() - WHERE id = $1 AND paypal_order_id = $2 AND status != 'sold' - RETURNING *`, - [itemId, orderID] - ); - const capturedAmount = capture.purchase_units?.[0]?.payments?.captures?.[0]?.amount?.value; - await client.query( - `INSERT INTO orders (item_id, customer_id, processor, processor_order_id, amount_cents, status, raw_event) - VALUES ($1, $2, 'paypal', $3, $4, 'completed', $5)`, - [itemId, req.customerId || null, orderID, Math.round(parseFloat(capturedAmount || '0') * 100), capture] - ); - await client.query('COMMIT'); - res.json({ status: 'sold', item: rows[0] || null }); - } 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') { - console.warn('paypal webhook signature invalid'); - return res.status(400).end(); - } - - const event = req.body; - if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') { - const itemId = event.resource?.custom_id; - if (itemId) { - await pool.query( - `UPDATE items SET status = 'sold', sold_at = now() WHERE id = $1 AND status != 'sold'`, - [itemId] - ); - } - } - res.status(200).end(); - } catch (err) { - console.error('webhook error', err); - res.status(500).end(); - } -}); - -export { router, webhookRouter };