import { getDb } from './db'; import { getProductBySlug } from './products'; import { shippingCostCentsSync } from './shipping'; import crypto from 'crypto'; export interface OrderItem { slug: string; name: string; price_cents: number; unit_size: number; unit: string; qty: number; } export interface Order { id: string; view_token: string; user_id: string | null; items: string; subtotal_cents: number; shipping_cents: number; total_cents: number; status: string; payment_method: string; payment_status: string; payment_id: string | null; courier: string | null; shipping_method: string | null; shipping_address: string | null; customer_name: string; customer_email: string; customer_phone: string | null; notes: string | null; created_at: string; updated_at: string; } export const PAYMENT_METHODS = ['stripe', 'applepay', 'googlepay', 'btcpay', 'bank', 'cod'] as const; export function shippingCostCents(totalKg: number, subtotalCents: number): number { return shippingCostCentsSync(totalKg, subtotalCents); } export class CheckoutError extends Error { status: number; constructor(message: string, status = 400) { super(message); this.status = status; } } import { shippingCostCents as asyncShippingCostCents } from './shipping'; // items from the client are {slug, qty} only - prices come from the catalog export async function createOrder(data: any): Promise { if (!Array.isArray(data.items) || data.items.length === 0) throw new CheckoutError('items required'); if (typeof data.email !== 'string' || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(data.email)) throw new CheckoutError('valid email required'); if (!data.shipping || typeof data.shipping.name !== 'string' || !data.shipping.name.trim()) throw new CheckoutError('shipping name required'); if (!PAYMENT_METHODS.includes(data.paymentMethod)) throw new CheckoutError('invalid payment method'); const items: OrderItem[] = []; let subtotal = 0; let weightKg = 0; const db = getDb(); for (const it of data.items) { const qty = Number(it?.qty); if (!Number.isInteger(qty) || qty < 1 || qty > 99) throw new CheckoutError('invalid quantity'); const product = getProductBySlug(String(it?.slug ?? '')); if (!product) throw new CheckoutError(`unknown product: ${it?.slug}`); if (product.stock_count < qty) throw new CheckoutError(`out of stock: ${product.slug}`); items.push({ slug: product.slug, name: product.name, price_cents: product.price_cents, unit_size: product.unit_size, unit: product.unit, qty }); subtotal += product.price_cents * qty; weightKg += (product.unit_size * qty) / 1000; } const shipping = await asyncShippingCostCents( weightKg, subtotal, data.shipping.city, data.courier, data.paymentMethod === 'cod' ? subtotal : 0, data.shipping.deliveryType === 'office', ); const id = crypto.randomUUID(); const viewToken = crypto.randomBytes(16).toString('hex'); // decrement stock for (const it of items) { db.query('UPDATE products SET stock_count = MAX(0, stock_count - ?) WHERE slug = ?').run(it.qty, it.slug); } db.query(`INSERT INTO orders ( id, view_token, user_id, items, subtotal_cents, shipping_cents, total_cents, status, payment_method, payment_status, courier, shipping_method, shipping_address, customer_name, customer_email, customer_phone, notes ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, 'pending', ?, ?, ?, ?, ?, ?, ?)`).run( id, viewToken, data.user_id || null, JSON.stringify(items), subtotal, shipping, subtotal + shipping, data.paymentMethod, data.courier || null, data.shipping.deliveryType || 'address', JSON.stringify({ name: data.shipping.name, phone: data.shipping.phone || '', city: data.shipping.city || '', postCode: data.shipping.postCode || '', address: data.shipping.address || '', address2: data.shipping.address2 || '', officeId: data.shipping.officeId || null, }), data.shipping.name, data.email, data.shipping.phone || null, data.notes || null ); return getOrder(id)!; } export function getOrder(id: string): Order | null { return (getDb().query('SELECT * FROM orders WHERE id = ?').get(id) as Order) || null; } export function getOrderForView(id: string, viewToken: string): Order | null { const order = getOrder(id); if (!order) return null; if (!viewToken || order.view_token !== viewToken) return null; return order; } export function setPaymentId(orderId: string, paymentId: string) { getDb().query(`UPDATE orders SET payment_id = ?, updated_at = datetime('now') WHERE id = ?`).run(paymentId, orderId); } const ORDER_TRANSITIONS: Record = { pending: ['paid', 'failed', 'expired', 'cancelled'], paid: ['shipped', 'cancelled'], failed: ['pending', 'cancelled'], expired: ['pending', 'cancelled'], shipped: ['delivered'], delivered: [], cancelled: [], }; export function transitionOrder(orderId: string, to: string): Order { const order = getOrder(orderId); if (!order) throw new CheckoutError('order not found', 404); const allowed = ORDER_TRANSITIONS[order.status] || []; if (!allowed.includes(to)) throw new CheckoutError(`cannot transition ${order.status} -> ${to}`, 409); const paymentStatus = to === 'paid' ? 'paid' : to === 'failed' ? 'failed' : to === 'expired' ? 'expired' : order.payment_status; const db = getDb(); db.query(`UPDATE orders SET status = ?, payment_status = ?, updated_at = datetime('now') WHERE id = ?`).run(to, paymentStatus, orderId); if (to === 'cancelled') { try { const items: OrderItem[] = JSON.parse(order.items); for (const it of items) { db.query('UPDATE products SET stock_count = stock_count + ? WHERE slug = ?').run(it.qty, it.slug); } } catch {} } return getOrder(orderId)!; } export function markPaidByPaymentId(paymentId: string): Order | null { const order = getDb().query('SELECT * FROM orders WHERE payment_id = ?').get(paymentId) as Order | null; if (!order) return null; if (order.status !== 'pending') return order; return transitionOrder(order.id, 'paid'); } // public projection for the order page - excludes internal fields export function orderView(order: Order) { let payment: any = null; if (order.payment_id) { try { const p = JSON.parse(order.payment_id); if (order.payment_method === 'lightning' && p.pr) payment = { pr: p.pr }; if (order.payment_method === 'onchain' && p.checkoutUrl) payment = { checkoutUrl: p.checkoutUrl }; } catch { // stripe stores the bare intent id - nothing to display } } return { id: order.id, items: JSON.parse(order.items), subtotal_cents: order.subtotal_cents, shipping_cents: order.shipping_cents, total_cents: order.total_cents, status: order.status, payment_method: order.payment_method, payment_status: order.payment_status, payment, courier: order.courier, shipping_method: order.shipping_method, customer_name: order.customer_name, created_at: order.created_at, }; }