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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user