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