refactor: delete superseded checkout routes and dedupe cart checkout

routes/paypal.ts and routes/demo.ts were the pre-cart single-item
checkout flow. Nothing has imported them since the cart flow landed:
app.ts mounts only cartCheckout, the frontend calls /api/checkout/cart/*,
and no test touches them. They duplicated PAYPAL_BASE, getAccessToken,
and a second handler for the /webhooks/paypal mount.

Also extract openCheckout() from /paypal/create and /demo/purchase in
cartCheckout.ts, which repeated the same address-ownership check, cart
lock, and checkouts/checkout_items inserts. It returns a discriminated
union so callers keep control of the transaction and the response. Add
CartItem/LockedCart interfaces, dropping the (it: any) casts.

Note: paypal.ts was the only writer of items.reserved_until and
items.paypal_order_id. Those columns are now write-dead; the schema is
left alone for a separate migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 17:59:42 -05:00
co-authored by Claude Opus 5
parent 0935bc14f1
commit 977489cbc0
3 changed files with 60 additions and 238 deletions
+60 -43
View File
@@ -22,9 +22,21 @@ async function getAccessToken(): Promise<string> {
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<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;
@@ -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<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' });
@@ -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) {