orders.ts raw

   1  import { getDb } from './db';
   2  import { getProductBySlug } from './products';
   3  import { shippingCostCentsSync } from './shipping';
   4  import crypto from 'crypto';
   5  
   6  export interface OrderItem {
   7    slug: string;
   8    name: string;
   9    price_cents: number;
  10    unit_size: number;
  11    unit: string;
  12    qty: number;
  13  }
  14  
  15  export interface Order {
  16    id: string;
  17    view_token: string;
  18    user_id: string | null;
  19    items: string;
  20    subtotal_cents: number;
  21    shipping_cents: number;
  22    total_cents: number;
  23    status: string;
  24    payment_method: string;
  25    payment_status: string;
  26    payment_id: string | null;
  27    courier: string | null;
  28    shipping_method: string | null;
  29    shipping_address: string | null;
  30    customer_name: string;
  31    customer_email: string;
  32    customer_phone: string | null;
  33    notes: string | null;
  34    created_at: string;
  35    updated_at: string;
  36  }
  37  
  38  export const PAYMENT_METHODS = ['stripe', 'applepay', 'googlepay', 'btcpay', 'bank', 'cod'] as const;
  39  
  40  export function shippingCostCents(totalKg: number, subtotalCents: number): number {
  41    return shippingCostCentsSync(totalKg, subtotalCents);
  42  }
  43  
  44  export class CheckoutError extends Error {
  45    status: number;
  46    constructor(message: string, status = 400) {
  47      super(message);
  48      this.status = status;
  49    }
  50  }
  51  
  52  import { shippingCostCents as asyncShippingCostCents } from './shipping';
  53  
  54  // items from the client are {slug, qty} only - prices come from the catalog
  55  export async function createOrder(data: any): Promise<Order> {
  56    if (!Array.isArray(data.items) || data.items.length === 0) throw new CheckoutError('items required');
  57    if (typeof data.email !== 'string' || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(data.email)) throw new CheckoutError('valid email required');
  58    if (!data.shipping || typeof data.shipping.name !== 'string' || !data.shipping.name.trim()) throw new CheckoutError('shipping name required');
  59    if (!PAYMENT_METHODS.includes(data.paymentMethod)) throw new CheckoutError('invalid payment method');
  60  
  61    const items: OrderItem[] = [];
  62    let subtotal = 0;
  63    let weightKg = 0;
  64    const db = getDb();
  65    for (const it of data.items) {
  66      const qty = Number(it?.qty);
  67      if (!Number.isInteger(qty) || qty < 1 || qty > 99) throw new CheckoutError('invalid quantity');
  68      const product = getProductBySlug(String(it?.slug ?? ''));
  69      if (!product) throw new CheckoutError(`unknown product: ${it?.slug}`);
  70      if (product.stock_count < qty) throw new CheckoutError(`out of stock: ${product.slug}`);
  71      items.push({ slug: product.slug, name: product.name, price_cents: product.price_cents, unit_size: product.unit_size, unit: product.unit, qty });
  72      subtotal += product.price_cents * qty;
  73      weightKg += (product.unit_size * qty) / 1000;
  74    }
  75  
  76    const shipping = await asyncShippingCostCents(
  77      weightKg, subtotal,
  78      data.shipping.city, data.courier,
  79      data.paymentMethod === 'cod' ? subtotal : 0,
  80      data.shipping.deliveryType === 'office',
  81    );
  82    const id = crypto.randomUUID();
  83    const viewToken = crypto.randomBytes(16).toString('hex');
  84  
  85    // decrement stock
  86    for (const it of items) {
  87      db.query('UPDATE products SET stock_count = MAX(0, stock_count - ?) WHERE slug = ?').run(it.qty, it.slug);
  88    }
  89  
  90    db.query(`INSERT INTO orders (
  91        id, view_token, user_id, items, subtotal_cents, shipping_cents, total_cents,
  92        status, payment_method, payment_status, courier, shipping_method, shipping_address,
  93        customer_name, customer_email, customer_phone, notes
  94      ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, 'pending', ?, ?, ?, ?, ?, ?, ?)`).run(
  95      id, viewToken, data.user_id || null, JSON.stringify(items), subtotal, shipping, subtotal + shipping,
  96      data.paymentMethod,
  97      data.courier || null,
  98      data.shipping.deliveryType || 'address',
  99      JSON.stringify({
 100        name: data.shipping.name,
 101        phone: data.shipping.phone || '',
 102        city: data.shipping.city || '',
 103        postCode: data.shipping.postCode || '',
 104        address: data.shipping.address || '',
 105        address2: data.shipping.address2 || '',
 106        officeId: data.shipping.officeId || null,
 107      }),
 108      data.shipping.name, data.email, data.shipping.phone || null, data.notes || null
 109    );
 110  
 111    return getOrder(id)!;
 112  }
 113  
 114  export function getOrder(id: string): Order | null {
 115    return (getDb().query('SELECT * FROM orders WHERE id = ?').get(id) as Order) || null;
 116  }
 117  
 118  export function getOrderForView(id: string, viewToken: string): Order | null {
 119    const order = getOrder(id);
 120    if (!order) return null;
 121    if (!viewToken || order.view_token !== viewToken) return null;
 122    return order;
 123  }
 124  
 125  export function setPaymentId(orderId: string, paymentId: string) {
 126    getDb().query(`UPDATE orders SET payment_id = ?, updated_at = datetime('now') WHERE id = ?`).run(paymentId, orderId);
 127  }
 128  
 129  const ORDER_TRANSITIONS: Record<string, string[]> = {
 130    pending: ['paid', 'failed', 'expired', 'cancelled'],
 131    paid: ['shipped', 'cancelled'],
 132    failed: ['pending', 'cancelled'],
 133    expired: ['pending', 'cancelled'],
 134    shipped: ['delivered'],
 135    delivered: [],
 136    cancelled: [],
 137  };
 138  
 139  export function transitionOrder(orderId: string, to: string): Order {
 140    const order = getOrder(orderId);
 141    if (!order) throw new CheckoutError('order not found', 404);
 142    const allowed = ORDER_TRANSITIONS[order.status] || [];
 143    if (!allowed.includes(to)) throw new CheckoutError(`cannot transition ${order.status} -> ${to}`, 409);
 144    const paymentStatus = to === 'paid' ? 'paid' : to === 'failed' ? 'failed' : to === 'expired' ? 'expired' : order.payment_status;
 145    const db = getDb();
 146    db.query(`UPDATE orders SET status = ?, payment_status = ?, updated_at = datetime('now') WHERE id = ?`).run(to, paymentStatus, orderId);
 147  
 148    if (to === 'cancelled') {
 149      try {
 150        const items: OrderItem[] = JSON.parse(order.items);
 151        for (const it of items) {
 152          db.query('UPDATE products SET stock_count = stock_count + ? WHERE slug = ?').run(it.qty, it.slug);
 153        }
 154      } catch {}
 155    }
 156  
 157    return getOrder(orderId)!;
 158  }
 159  
 160  export function markPaidByPaymentId(paymentId: string): Order | null {
 161    const order = getDb().query('SELECT * FROM orders WHERE payment_id = ?').get(paymentId) as Order | null;
 162    if (!order) return null;
 163    if (order.status !== 'pending') return order;
 164    return transitionOrder(order.id, 'paid');
 165  }
 166  
 167  // public projection for the order page - excludes internal fields
 168  export function orderView(order: Order) {
 169    let payment: any = null;
 170    if (order.payment_id) {
 171      try {
 172        const p = JSON.parse(order.payment_id);
 173        if (order.payment_method === 'lightning' && p.pr) payment = { pr: p.pr };
 174        if (order.payment_method === 'onchain' && p.checkoutUrl) payment = { checkoutUrl: p.checkoutUrl };
 175      } catch {
 176        // stripe stores the bare intent id - nothing to display
 177      }
 178    }
 179    return {
 180      id: order.id,
 181      items: JSON.parse(order.items),
 182      subtotal_cents: order.subtotal_cents,
 183      shipping_cents: order.shipping_cents,
 184      total_cents: order.total_cents,
 185      status: order.status,
 186      payment_method: order.payment_method,
 187      payment_status: order.payment_status,
 188      payment,
 189      courier: order.courier,
 190      shipping_method: order.shipping_method,
 191      customer_name: order.customer_name,
 192      created_at: order.created_at,
 193    };
 194  }
 195