refactor: delete superseded checkout routes and dedupe cart checkout #17
@@ -22,9 +22,21 @@ async function getAccessToken(): Promise<string> {
|
|||||||
return data.access_token;
|
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,
|
// Locks the customer's cart, verifies every item is still reserved to them,
|
||||||
// and returns { cartId, items: [{id, name, price_cents}], totalCents }.
|
// 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]);
|
const { rows: cartRows } = await client.query(`SELECT id FROM carts WHERE customer_id = $1`, [customerId]);
|
||||||
if (!cartRows.length) return null;
|
if (!cartRows.length) return null;
|
||||||
const cartId = cartRows[0].id;
|
const cartId = cartRows[0].id;
|
||||||
@@ -37,10 +49,49 @@ async function loadLockedCart(client: any, customerId: number) {
|
|||||||
[cartId]
|
[cartId]
|
||||||
);
|
);
|
||||||
if (!items.length) return { cartId, items: [], totalCents: 0 };
|
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 };
|
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) => {
|
router.post('/paypal/create', requireCustomer, async (req: Request, res: Response) => {
|
||||||
const { shippingAddressId } = req.body;
|
const { shippingAddressId } = req.body;
|
||||||
if (!shippingAddressId) return res.status(400).json({ error: 'shippingAddressId is required' });
|
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();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
const { rows: addrRows } = await client.query(
|
const opened = await openCheckout(client, req.customerId as number, shippingAddressId, 'paypal', null);
|
||||||
`SELECT id FROM shipping_addresses WHERE id = $1 AND customer_id = $2`,
|
if (!opened.ok) { await client.query('ROLLBACK'); return res.status(400).json({ error: opened.error }); }
|
||||||
[shippingAddressId, req.customerId]
|
const { checkoutId, cart } = opened;
|
||||||
);
|
|
||||||
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 token = await getAccessToken();
|
const token = await getAccessToken();
|
||||||
const currency = process.env.SITE_CURRENCY || 'USD';
|
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),
|
value: (cart.totalCents / 100).toFixed(2),
|
||||||
breakdown: { item_total: { 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: any) => ({
|
items: cart.items.map((it: CartItem) => ({
|
||||||
name: it.name.slice(0, 127),
|
name: it.name.slice(0, 127),
|
||||||
quantity: '1',
|
quantity: '1',
|
||||||
unit_amount: { currency_code: currency, value: (it.price_cents / 100).toFixed(2) }
|
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();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
const { rows: addrRows } = await client.query(
|
const opened = await openCheckout(client, req.customerId as number, shippingAddressId, 'demo', `demo-${Date.now()}`);
|
||||||
`SELECT id FROM shipping_addresses WHERE id = $1 AND customer_id = $2`,
|
if (!opened.ok) { await client.query('ROLLBACK'); return res.status(400).json({ error: opened.error }); }
|
||||||
[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);
|
await completeCheckout(client, opened.checkoutId, 'demo', null, { demo: true });
|
||||||
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 client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
res.json({ status: 'completed' });
|
res.json({ status: 'completed' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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<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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 };
|
|
||||||
Reference in New Issue
Block a user