feat: add customer cart with expiry, shipping addresses with USPS validation, and multi-item PayPal checkout
SonarQube Analysis / sonarqube (pull_request) Failing after 59s
Tests / backend-unit (pull_request) Successful in 34s
Tests / backend-integration (pull_request) Failing after 1m33s
Tests / frontend-e2e (pull_request) Failing after 1m5s

This commit is contained in:
2026-08-14 14:02:25 -05:00
parent 433d7e1db5
commit 9ab689e624
18 changed files with 1242 additions and 189 deletions
+9 -7
View File
@@ -2,18 +2,20 @@ import express from 'express';
import cookieParser from 'cookie-parser';
import path from 'path';
import itemsRouter from './routes/items';
import { router as paypalRouter, webhookRouter as paypalWebhookRouter } from './routes/paypal';
import { router as cartCheckoutRouter, webhookRouter as cartCheckoutWebhookRouter } from './routes/cartCheckout';
import adminRouter from './routes/admin';
import adminCustomersRouter from './routes/adminCustomers';
import demoRouter from './routes/demo';
import adminSettingsRouter from './routes/adminSettings';
import customersRouter from './routes/customers';
import publicRouter from './routes/public';
import cartRouter from './routes/cart';
import shippingAddressesRouter from './routes/shippingAddresses';
import { attachCustomer } from './middleware/customerAuth';
const app = express();
app.set('trust proxy', 1);
app.use('/webhooks/paypal', express.json(), paypalWebhookRouter);
app.use('/webhooks/paypal', express.json(), cartCheckoutWebhookRouter);
app.use(express.json());
app.use(cookieParser());
app.use(attachCustomer);
@@ -30,15 +32,15 @@ app.get('/api/config', (_req, res) => {
});
app.use('/api/items', itemsRouter);
app.use('/api/checkout/paypal', paypalRouter);
app.use('/api/checkout/demo', demoRouter);
app.use('/api/cart', cartRouter);
app.use('/api/checkout/cart', cartCheckoutRouter);
app.use('/api/admin/customers', adminCustomersRouter);
app.use('/api/admin/settings', adminSettingsRouter);
app.use('/api/admin', adminRouter);
app.use('/api/customers/me/addresses', shippingAddressesRouter);
app.use('/api/customers', customersRouter);
app.use('/', publicRouter);
// Skip serving the built frontend during tests — there's no /public dir yet
// at that point, and tests only care about the API surface.
if (process.env.NODE_ENV !== 'test') {
const staticDir = path.join(__dirname, '..', 'public');
app.use(express.static(staticDir));
+29
View File
@@ -0,0 +1,29 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
const router = Router();
router.get('/', async (_req: Request, res: Response) => {
const { rows } = await pool.query(`SELECT key, value FROM admin_settings`);
const map: Record<string, string> = {};
for (const r of rows) map[r.key] = r.value;
res.json({
cartExpiryHours: parseFloat(map.cart_expiry_hours || '24')
});
});
router.put('/', async (req: Request, res: Response) => {
const { cartExpiryHours } = req.body;
const hours = parseFloat(cartExpiryHours);
if (Number.isNaN(hours) || hours <= 0) {
return res.status(400).json({ error: 'cartExpiryHours must be a positive number' });
}
await pool.query(
`INSERT INTO admin_settings (key, value, updated_at) VALUES ('cart_expiry_hours', $1, now())
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = now()`,
[String(hours)]
);
res.json({ cartExpiryHours: hours });
});
export default router;
+108
View File
@@ -0,0 +1,108 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { requireCustomer } from '../middleware/customerAuth';
const router = Router();
async function getCartExpiryHours(): Promise<number> {
const { rows } = await pool.query(`SELECT value FROM admin_settings WHERE key = 'cart_expiry_hours'`);
return rows.length ? parseFloat(rows[0].value) : 24;
}
const CART_ITEM_SELECT = `
SELECT
ci.item_id, ci.added_at, ci.expires_at,
i.name, i.price_cents, i.status,
COALESCE(
json_agg(json_build_object('id', img.id, 'image_path', img.image_path) ORDER BY img.sort_order)
FILTER (WHERE img.id IS NOT NULL),
'[]'
) AS images
FROM cart_items ci
JOIN items i ON i.id = ci.item_id
LEFT JOIN item_images img ON img.item_id = i.id
WHERE ci.cart_id = $1
GROUP BY ci.item_id, ci.added_at, ci.expires_at, i.name, i.price_cents, i.status
ORDER BY ci.added_at DESC
`;
router.get('/', requireCustomer, async (req: Request, res: Response) => {
const { rows: cartRows } = await pool.query(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]);
if (!cartRows.length) return res.json({ items: [] });
const { rows: items } = await pool.query(CART_ITEM_SELECT, [cartRows[0].id]);
res.json({ items });
});
router.post('/items/:itemId', requireCustomer, async (req: Request, res: Response) => {
const itemId = req.params.itemId;
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows: itemRows } = await client.query(`SELECT * FROM items WHERE id = $1 FOR UPDATE`, [itemId]);
const item = itemRows[0];
if (!item) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); }
if (item.status !== 'available') {
await client.query('ROLLBACK');
return res.status(409).json({ error: 'item is no longer available' });
}
let { rows: cartRows } = await client.query(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]);
let cartId: number;
if (cartRows.length) {
cartId = cartRows[0].id;
await client.query(`UPDATE carts SET updated_at = now() WHERE id = $1`, [cartId]);
} else {
const { rows: newCart } = await client.query(
`INSERT INTO carts (customer_id) VALUES ($1) RETURNING id`,
[req.customerId]
);
cartId = newCart[0].id;
}
const hours = await getCartExpiryHours();
const expiresAt = new Date(Date.now() + hours * 60 * 60 * 1000);
await client.query(
`INSERT INTO cart_items (cart_id, item_id, expires_at) VALUES ($1, $2, $3)`,
[cartId, itemId, expiresAt]
);
await client.query(`UPDATE items SET status = 'reserved' WHERE id = $1`, [itemId]);
await client.query('COMMIT');
res.status(201).json({ itemId: parseInt(itemId, 10), expiresAt });
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
res.status(500).json({ error: 'internal error' });
} finally {
client.release();
}
});
router.delete('/items/:itemId', requireCustomer, async (req: Request, res: Response) => {
const itemId = req.params.itemId;
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`DELETE FROM cart_items ci
USING carts c
WHERE ci.cart_id = c.id AND c.customer_id = $1 AND ci.item_id = $2
RETURNING ci.item_id`,
[req.customerId, itemId]
);
if (!rows.length) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not in your cart' }); }
await client.query(
`UPDATE items SET status = 'available' WHERE id = $1 AND status = 'reserved'`,
[itemId]
);
await client.query('COMMIT');
res.status(204).end();
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
res.status(500).json({ error: 'internal error' });
} finally {
client.release();
}
});
export default router;
+248
View File
@@ -0,0 +1,248 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { requireCustomer } from '../middleware/customerAuth';
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;
}
// 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) {
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: any) => sum + it.price_cents, 0);
return { cartId, items, totalCents };
}
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 { 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 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: any) => ({
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();
}
});
async function completeCheckout(client: any, checkoutId: number, processor: string, processorOrderId: string | null, rawEvent: unknown) {
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]);
}
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');
await completeCheckout(client, rows[0].id, 'paypal', orderID, capture);
await client.query('COMMIT');
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 { 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, 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');
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');
await completeCheckout(client, parseInt(checkoutId, 10), 'paypal', event.resource?.id, event);
await client.query('COMMIT');
} 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 };
+104
View File
@@ -0,0 +1,104 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { requireCustomer } from '../middleware/customerAuth';
import { validateAddress, uspsConfigured } from '../usps';
const router = Router();
router.get('/', requireCustomer, async (req: Request, res: Response) => {
const { rows } = await pool.query(
`SELECT * FROM shipping_addresses WHERE customer_id = $1 ORDER BY is_default DESC, created_at DESC`,
[req.customerId]
);
res.json(rows);
});
router.post('/', requireCustomer, async (req: Request, res: Response) => {
const { fullName, addressLine1, addressLine2, city, state, postalCode, country, isDefault } = req.body;
if (!fullName || !addressLine1 || !city || !state || !postalCode) {
return res.status(400).json({ error: 'fullName, addressLine1, city, state, and postalCode are required' });
}
let uspsResult = { validated: false, deliverable: null as boolean | null, standardized: null as Record<string, unknown> | null, reason: undefined as string | undefined };
if ((country || 'US') === 'US') {
uspsResult = await validateAddress({ addressLine1, addressLine2, city, state, postalCode });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
if (isDefault) {
await client.query(`UPDATE shipping_addresses SET is_default = false WHERE customer_id = $1`, [req.customerId]);
}
const { rows } = await client.query(
`INSERT INTO shipping_addresses
(customer_id, full_name, address_line1, address_line2, city, state, postal_code, country, is_default, usps_validated, usps_standardized)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
[
req.customerId, fullName, addressLine1, addressLine2 || null, city, state, postalCode,
country || 'US', !!isDefault, uspsResult.deliverable === true, uspsResult.standardized
]
);
await client.query('COMMIT');
res.status(201).json({ address: rows[0], uspsCheck: uspsResult, uspsConfigured: uspsConfigured() });
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
res.status(500).json({ error: 'internal error' });
} finally {
client.release();
}
});
router.put('/:id', requireCustomer, async (req: Request, res: Response) => {
const { fullName, addressLine1, addressLine2, city, state, postalCode, country, isDefault } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
if (isDefault) {
await client.query(`UPDATE shipping_addresses SET is_default = false WHERE customer_id = $1`, [req.customerId]);
}
const { rows } = await client.query(
`UPDATE shipping_addresses
SET full_name=$1, address_line1=$2, address_line2=$3, city=$4, state=$5, postal_code=$6, country=$7, is_default=$8,
usps_validated = false, usps_standardized = NULL
WHERE id=$9 AND customer_id=$10 RETURNING *`,
[fullName, addressLine1, addressLine2 || null, city, state, postalCode, country || 'US', !!isDefault, req.params.id, req.customerId]
);
if (!rows.length) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); }
await client.query('COMMIT');
res.json(rows[0]);
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
res.status(500).json({ error: 'internal error' });
} finally {
client.release();
}
});
router.delete('/:id', requireCustomer, async (req: Request, res: Response) => {
await pool.query(`DELETE FROM shipping_addresses WHERE id = $1 AND customer_id = $2`, [req.params.id, req.customerId]);
res.status(204).end();
});
router.post('/:id/set-default', requireCustomer, async (req: Request, res: Response) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(`UPDATE shipping_addresses SET is_default = false WHERE customer_id = $1`, [req.customerId]);
const { rows } = await client.query(
`UPDATE shipping_addresses SET is_default = true WHERE id = $1 AND customer_id = $2 RETURNING *`,
[req.params.id, req.customerId]
);
await client.query('COMMIT');
res.json(rows[0]);
} catch (err) {
await client.query('ROLLBACK');
res.status(500).json({ error: 'internal error' });
} finally {
client.release();
}
});
export default router;
+48 -5
View File
@@ -1,16 +1,59 @@
import cron from 'node-cron';
import app from './app';
import { pool } from './db';
import { sendMail } from './mailer';
// Release cart holds whose expiry has passed, every 5 minutes.
setInterval(async () => {
try {
await pool.query(
`UPDATE items SET status = 'available', reserved_until = NULL, paypal_order_id = NULL
WHERE status = 'reserved' AND reserved_until < now()`
const { rows } = await pool.query(
`DELETE FROM cart_items WHERE expires_at < now() RETURNING item_id`
);
for (const row of rows) {
await pool.query(`UPDATE items SET status = 'available' WHERE id = $1 AND status = 'reserved'`, [row.item_id]);
}
} catch (err) {
console.error('reservation sweep failed:', (err as Error).message);
console.error('cart expiry sweep failed:', (err as Error).message);
}
}, 60 * 1000);
}, 5 * 60 * 1000);
// Daily cart reminder emails at 9am server time, for customers who opted into marketing email.
cron.schedule('0 9 * * *', async () => {
try {
const { rows } = await pool.query(`
SELECT c.email, c.name, i.name AS item_name, ci.expires_at, ci.id AS cart_item_id
FROM cart_items ci
JOIN carts ca ON ca.id = ci.cart_id
JOIN customers c ON c.id = ca.customer_id
JOIN items i ON i.id = ci.item_id
WHERE c.marketing_consent = true
AND (ci.last_reminder_sent_at IS NULL OR ci.last_reminder_sent_at < now() - interval '20 hours')
AND ci.expires_at > now()
`);
const byEmail = new Map<string, { name: string | null; items: { name: string; expiresAt: Date; cartItemId: number }[] }>();
for (const row of rows) {
if (!byEmail.has(row.email)) byEmail.set(row.email, { name: row.name, items: [] });
byEmail.get(row.email)!.items.push({ name: row.item_name, expiresAt: row.expires_at, cartItemId: row.cart_item_id });
}
for (const [email, data] of byEmail) {
const itemList = data.items.map(i => `<li>${i.name} — reserved until ${i.expiresAt.toLocaleString()}</li>`).join('');
await sendMail(
email,
'Items waiting in your cart',
`<p>Hi${data.name ? ' ' + data.name : ''},</p>
<p>You still have items in your cart at Redefined Designs:</p>
<ul>${itemList}</ul>
<p><a href="${process.env.PUBLIC_URL}/cart">View your cart</a> before your reservation expires.</p>`
);
const ids = data.items.map(i => i.cartItemId);
await pool.query(`UPDATE cart_items SET last_reminder_sent_at = now() WHERE id = ANY($1::int[])`, [ids]);
}
} catch (err) {
console.error('daily cart reminder job failed:', (err as Error).message);
}
});
const PORT = parseInt(process.env.PORT || '3000', 10);
app.listen(PORT, () => console.log(`redefined-designs listening on ${PORT}`));
+82
View File
@@ -0,0 +1,82 @@
interface UspsToken {
accessToken: string;
expiresAt: number;
}
let cachedToken: UspsToken | null = null;
const USPS_BASE =
process.env.USPS_ENV === 'production'
? 'https://apis.usps.com'
: 'https://apis-tem.usps.com';
export function uspsConfigured(): boolean {
return !!(process.env.USPS_CLIENT_ID && process.env.USPS_CLIENT_SECRET);
}
async function getToken(): Promise<string> {
if (cachedToken && cachedToken.expiresAt > Date.now() + 60_000) {
return cachedToken.accessToken;
}
const resp = await fetch(`${USPS_BASE}/oauth2/v3/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.USPS_CLIENT_ID,
client_secret: process.env.USPS_CLIENT_SECRET,
grant_type: 'client_credentials',
scope: 'addresses'
})
});
const data = await resp.json();
if (!resp.ok) throw new Error('USPS auth failed: ' + JSON.stringify(data));
cachedToken = {
accessToken: data.access_token,
expiresAt: Date.now() + (parseInt(data.expires_in, 10) || 3000) * 1000
};
return cachedToken.accessToken;
}
export interface AddressInput {
addressLine1: string;
addressLine2?: string | null;
city: string;
state: string;
postalCode: string;
}
export interface UspsValidationResult {
validated: boolean;
deliverable: boolean | null;
standardized: Record<string, unknown> | null;
reason?: string;
}
// Only validates domestic US addresses — USPS Addresses API has no international coverage.
export async function validateAddress(address: AddressInput): Promise<UspsValidationResult> {
if (!uspsConfigured()) {
return { validated: false, deliverable: null, standardized: null, reason: 'USPS not configured' };
}
try {
const token = await getToken();
const params = new URLSearchParams({
streetAddress: address.addressLine1,
city: address.city,
state: address.state,
ZIPCode: address.postalCode
});
if (address.addressLine2) params.set('secondaryAddress', address.addressLine2);
const resp = await fetch(`${USPS_BASE}/addresses/v3/address?${params.toString()}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }
});
const data = await resp.json();
if (!resp.ok) {
return { validated: false, deliverable: null, standardized: null, reason: data.error?.message || 'USPS lookup failed' };
}
const deliverable = data.additionalInfo?.DPVConfirmation === 'Y';
return { validated: true, deliverable, standardized: data };
} catch (err) {
return { validated: false, deliverable: null, standardized: null, reason: (err as Error).message };
}
}