Files
redefined-designs/backend/src/routes/cartCheckout.ts
T
bermudalambandClaude Opus 5 41840d4890 fix(email): stop a demo purchase telling real customers an item sold (#206)
A demo purchase called `notifyFavoritersOfSale`, which mails everyone who favorited the item through production's configured SMTP: "An item you favorited has been sold to another customer, so it is no longer available… this one will not be restocked."

Nobody bought it and nobody is shipping anything, so both halves are false. It is also the only outbound consequence a demo purchase has — everything #195 and #203 fixed is on screen, in front of the person who clicked and who has now been told it is a demo. These recipients never saw the cart. They just get told something they cared about is gone, and while production runs the demo interim (#191) they are real customers on real SMTP.

The demo route no longer notifies. The PayPal capture and webhook paths are untouched, because those are sales.

The item is still marked `sold`, so the storefront stays truthful about availability and the favoriter who goes looking finds what the database says. Only the claim that somebody bought it goes away. That a demo purchase permanently consumes real production inventory is a larger question than this issue and is left alone.

Removing the call broke two tests and quietly hollowed out three more, which is the more interesting half of this change. Five tests in `favorites.integration.test.ts` used the demo purchase as a convenient way to make a sale happen; with the notification gone, the two asserting mail *is* sent failed, and the three asserting it is *not* sent would have passed for the wrong reason for ever.

They were always about who gets told rather than about the demo route, so they now call the notifier the way the PayPal routes do — after the purchase, with the sold ids and the buyer. `buyThenNotify` says so at the point of use. Route-level coverage is unaffected: the admin mark-sold path already had its own test, and the new test asserts the demo route notifies nobody.

Both halves were mutation-tested rather than assumed. The new test fails without the fix. Dropping the buyer exclusion from `collectFavoriteRecipients` fails "does not tell the buyer their own purchase is unavailable" and "emails every opted-in favoriter except the buyer" — so the restored tests are guarding the logic again rather than passing on an empty inbox.

Verified: 255 integration tests pass (the suite needs `--runInBand`; these share one database), 278 unit tests pass, backend build clean.

Closes #206

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 14:02:07 -05:00

322 lines
13 KiB
TypeScript

import { Router, Request, Response } from 'express';
import type { PoolClient } from 'pg';
import { pool, requireRow } from '../db';
import { asyncRoute } from '../asyncRoute';
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;
}
/**
* Row shapes for the reads in this file.
*
* Only queries whose rows are actually read carry a type. The INSERTs, UPDATEs,
* DELETEs and the BEGIN/COMMIT/ROLLBACK calls return nothing anyone looks at,
* and annotating them would be ceremony that makes the ones that matter harder
* to pick out.
*
* Kept in step with their SQL by hand: `client.query<T>` asserts a shape rather
* than checking it, because TypeScript never reads the query string. The
* integration suite is what catches a select and its type disagreeing.
*/
interface IdRow {
id: number;
}
interface CheckoutItemRow {
item_id: number;
price_cents: number;
}
interface CheckoutOwnerRow {
customer_id: number | null;
}
interface CheckoutStatusRow {
status: string;
}
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: PoolClient, customerId: number): Promise<LockedCart | null> {
const { rows: cartRows } = await client.query<IdRow>(`SELECT id FROM carts WHERE customer_id = $1`, [customerId]);
const [cart] = cartRows;
if (!cart) return null;
const cartId = cart.id;
const { rows: items } = await client.query<CartItem>(
`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, it) => 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: PoolClient,
customerId: number,
shippingAddressId: number,
processor: string,
processorOrderId: string | null
): Promise<OpenedCheckout> {
const { rows: addrRows } = await client.query<IdRow>(
`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<IdRow>(
`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 = requireRow(checkoutRows, 'the checkout INSERT').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, asyncRoute(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: PoolClient, checkoutId: number, processor: string, processorOrderId: string | null, rawEvent: unknown): Promise<{ itemIds: number[]; buyerId: number | null }> {
const { rows: checkoutItems } = await client.query<CheckoutItemRow>(
`SELECT item_id, price_cents FROM checkout_items WHERE checkout_id = $1`,
[checkoutId]
);
const { rows: checkoutRows } = await client.query<CheckoutOwnerRow>(`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) => ci.item_id),
buyerId: customerId ?? null
};
}
router.post('/paypal/capture', requireCustomer, asyncRoute(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<IdRow>(
`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, requireRow(rows, 'the checkout lookup').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, asyncRoute(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 }); }
await completeCheckout(client, opened.checkoutId, 'demo', null, { demo: true });
await client.query('COMMIT');
// Deliberately no notifyFavoritersOfSale here, unlike the PayPal capture and
// webhook paths above. A demo purchase is not a sale. The item really is
// marked sold, so the storefront stays truthful about availability, but the
// `favoriteSold` copy says the item "has been sold to another customer" and
// "will not be restocked" — and both are false when nobody bought anything.
//
// This is the only outbound consequence a demo purchase has. Everything else
// it does is visible to the person who clicked, who has been told it is a
// demo (#195, #203); these recipients never saw the cart and have no way to
// know. While production runs the demo interim (#191) they are real
// customers on real SMTP. See #206.
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('/', asyncRoute(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<CheckoutStatusRow>(`SELECT status FROM checkouts WHERE id = $1`, [checkoutId]);
const [checkout] = rows;
if (checkout && checkout.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 };