Files
redefined-designs/backend/src/routes/cartCheckout.ts
T
bermudalambandClaude Opus 5 f626f27e75
SonarQube Analysis / sonarqube (pull_request) Successful in 2m46s
Tests / backend-unit (pull_request) Successful in 44s
Tests / frontend-e2e (pull_request) Failing after 9m48s
feat: favorite items and notify when a favorite is sold (#34)
Customers can favorite and unfavorite items from the storefront, opt in to being told when a favorite is sold to someone else, and manage that preference from their account page.

The opt-in is a consent of its own rather than the existing marketing flag. Being told that a specific item you asked about has gone is a narrower thing than agreeing to marketing, and folding one into the other would leave marketing_consent_text no longer describing what was actually agreed to. It is recorded the same way as the marketing consent — flag, timestamp, and the exact wording shown — and accepting it does not set marketing_consent.

The prompt appears only after a customer has actually favorited something, so the reason for asking is concrete rather than an abstract marketing ask, and it says plainly that it is separate from marketing email. Declining keeps the favorite.

Notifications fire when an item reaches sold, either through checkout or an admin marking it sold, and never to the buyer — telling someone the item they just bought is unavailable reads as a bug. Reserved is deliberately not a trigger: reservations expire and get released, so a "gone" email would often be about an item still for sale. Disabled accounts are excluded, per #33.

completeCheckout now returns the sold item ids and the buyer so its three call sites can notify after COMMIT. Sending inside the transaction would email people about a sale that then rolled back, and would hold the transaction open for SMTP. Each message is sent independently so one bad address cannot stop the rest, and the sale has already succeeded regardless.

Favoriting while signed out opens the existing inline register/login modal, exactly as Add to Cart does, and completes the favorite on success.

Also fixes a latent bug in the same component: while the session was still resolving, `customer` is null for a signed-in visitor too, so clicking Add to Cart or the new heart in that window prompted them to sign in again. Both now ignore clicks until the session has resolved, and the control shows as loading meanwhile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:29:11 -05:00

278 lines
11 KiB
TypeScript

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<string> {
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<LockedCart | null> {
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<OpenedCheckout> {
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 };