Add multi-item sales
This commit is contained in:
+13
-1
@@ -5,7 +5,7 @@ import { extname, join, normalize } from "node:path";
|
||||
import { backup, DatabaseSync } from "node:sqlite";
|
||||
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto";
|
||||
|
||||
const APP_VERSION="3.3.1",SCHEMA_VERSION=3,port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data";
|
||||
const APP_VERSION="3.4.0",SCHEMA_VERSION=3,port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data";
|
||||
const databasePath=join(dataDir,"vboxstock.db"),backupDir=join(dataDir,"backups"),restoreMarker=join(dataDir,".restore-audit.json"),publicDir=join(import.meta.dirname,"public");
|
||||
const SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map();
|
||||
mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true});
|
||||
@@ -99,6 +99,17 @@ function warrantyEnd(start,preset){if(!preset||!preset.duration_value)return nul
|
||||
const fulfillmentMethods=new Set(["Shipped","Dropped Off","Installed At","Meet"]),carriers=new Set(["UPS","FedEx","USPS","Other"]),deliveryStatuses=new Set(["Awaiting Tracking","Label Created","In Transit","Out for Delivery","Delivered","Delivery Exception","Returned","Unknown"]);
|
||||
function saleDetails(v,current={}){const method=String(v.fulfillmentMethod??current.fulfillmentMethod??"").trim();if(!fulfillmentMethods.has(method))throw new Error("Delivery method is required.");const address1=String(v.shipAddress1??current.shipAddress1??"").trim(),address2=String(v.shipAddress2??current.shipAddress2??"").trim(),city=String(v.shipCity??current.shipCity??"").trim(),state=String(v.shipState??current.shipState??"").trim(),zip=String(v.shipZip??current.shipZip??"").trim(),name=String(v.fulfillmentName??current.fulfillmentName??"").trim(),notes=String(v.fulfillmentNotes??current.fulfillmentNotes??"").trim();if(new Set(["Shipped","Installed At"]).has(method)&&(!address1||!city||!state||!zip))throw new Error(`${method} requires a street address, city, state, and ZIP code.`);if(new Set(["Dropped Off","Meet"]).has(method)&&!name&&!address1&&!notes)throw new Error(`${method} requires a venue, address, or fulfillment detail.`);let carrier="",trackingNumber="",deliveryStatus="";if(method==="Shipped"){carrier=String(v.carrier??current.carrier??"").trim();if(!carriers.has(carrier))throw new Error("Carrier is required for shipped products.");trackingNumber=String(v.trackingNumber??current.trackingNumber??"").trim();deliveryStatus=String(v.deliveryStatus??(current.deliveryStatus||(trackingNumber?"Label Created":"Awaiting Tracking")));if(!deliveryStatuses.has(deliveryStatus))throw new Error("Invalid delivery status.")}return{method,name,address1,address2,city,state,zip,notes,carrier,trackingNumber,deliveryStatus}}
|
||||
function resolveCustomer(v){const id=String(v.customerId||"").trim();if(id){const customer=db.prepare("SELECT id,name,phone FROM customers WHERE id=?").get(id);if(!customer)throw new Error("Selected customer no longer exists.");return customer}const phone=normalizePhone(v.phone);if(phone){const match=db.prepare("SELECT id,name,phone FROM customers").all().find(x=>normalizePhone(x.phone)===phone);if(match)return match}return findCustomerByName.get(String(v.customerName||"").trim())}
|
||||
function recordSales(req,user,v,rawItems){
|
||||
const items=Array.isArray(rawItems)?rawItems.map(x=>({productId:String(x?.productId||"").trim(),salePrice:x?.salePrice===""||x?.salePrice==null?0:Number(x.salePrice)})):[];
|
||||
if(!items.length)throw new Error("Select at least one product.");if(items.length>100)throw new Error("A sale can contain no more than 100 products.");
|
||||
if(items.some(x=>!x.productId)||new Set(items.map(x=>x.productId)).size!==items.length)throw new Error("Each selected product must be unique.");if(items.some(x=>!Number.isFinite(x.salePrice)||x.salePrice<0))throw new Error("Sale prices must be zero or greater.");
|
||||
const name=String(v.customerName||"").trim(),phone=String(v.phone||"").trim(),soldAt=String(v.soldAt||"");if(!name||!soldAt)throw new Error("Customer name and sale date are required.");
|
||||
const paymentMethod=String(v.paymentMethod||"").trim();if(!new Set(["Cash","Venmo","PayPal"]).has(paymentMethod))throw new Error("Payment method must be Cash, Venmo, or PayPal.");
|
||||
const f=saleDetails(v),preset=v.warrantyPresetId?db.prepare("SELECT id,name,duration_value,duration_unit FROM warranty_presets WHERE id=? AND active=1").get(String(v.warrantyPresetId)):db.prepare("SELECT id,name,duration_value,duration_unit FROM warranty_presets WHERE active=1 AND is_default=1").get();if(!preset)throw new Error("Select an active warranty period.");
|
||||
for(const item of items){const product=getProduct().get(item.productId);if(!product)throw new Error("One of the selected products no longer exists.");if(product.status!=="available")throw new Error(`${product.model} (${product.uid||product.sn||product.mac||product.id}) is no longer available.`)}
|
||||
let customer=resolveCustomer(v);db.exec("BEGIN IMMEDIATE");try{if(!customer){customer={id:crypto.randomUUID()};addCustomer.run(customer.id,name,phone,"","","","","","")}saveCustomer(customer.id,name,phone,f);const update=db.prepare("UPDATE products SET status='sold',sold_at=?,customer_id=?,customer_name=?,phone=?,sale_price=?,ship_address1=?,ship_address2=?,ship_city=?,ship_state=?,ship_zip=?,shipping_notes=?,payment_method=?,payment_reference=?,sale_notes=?,fulfillment_method=?,fulfillment_name=?,carrier=?,tracking_number=?,delivery_status=?,delivery_status_updated_at=CURRENT_TIMESTAMP,warranty_preset_id=?,warranty_name=?,warranty_end_date=? WHERE id=? AND status='available'");for(const item of items){const result=update.run(soldAt,customer.id,name,phone,item.salePrice,f.address1,f.address2,f.city,f.state,f.zip,f.notes,paymentMethod,String(v.paymentReference||"").trim(),String(v.saleNotes||"").trim(),f.method,f.name,f.carrier,f.trackingNumber,f.deliveryStatus,preset.id,preset.name,warrantyEnd(soldAt,preset),item.productId);if(result.changes!==1)throw new Error("Inventory changed while the sale was being recorded. No products were sold.");audit(req,user,"sale_recorded",item.productId,`${name}; ${f.method}; ${preset.name}; ${items.length}-item transaction`)}db.exec("COMMIT")}catch(error){db.exec("ROLLBACK");throw error}
|
||||
const products=items.map(x=>getProduct().get(x.productId));return{products,count:products.length,total:items.reduce((sum,x)=>sum+x.salePrice,0)};
|
||||
}
|
||||
function saveCustomer(customerId,name,phone,f){const storeAddress=new Set(["Shipped","Installed At"]).has(f.method);if(storeAddress)db.prepare("UPDATE customers SET name=?,phone=?,address1=?,address2=?,city=?,state=?,zip=?,shipping_notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(name,phone,f.address1,f.address2,f.city,f.state,f.zip,f.notes,customerId);else db.prepare("UPDATE customers SET name=?,phone=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(name,phone,customerId)}
|
||||
|
||||
async function authApi(req,res,url,session){
|
||||
@@ -148,6 +159,7 @@ async function api(req,res,url){
|
||||
const exportMatch=url.pathname.match(/^\/api\/export\/(inventory|sales|customers|warranties|audit)$/);if(exportMatch&&req.method==="GET"){const type=exportMatch[1];if(type==="inventory"){const rows=listProducts().all().filter(x=>x.status==="available");return csvResponse(res,"vboxstock-inventory.csv",["Model","UID","Serial Number","MAC","Condition","Received","Cost","Notes"],rows.map(x=>[x.model,x.uid,x.sn,x.mac,x.condition,x.receivedAt,x.cost,x.notes]))}if(type==="sales"){const rows=listProducts().all().filter(x=>x.status==="sold");return csvResponse(res,"vboxstock-sales.csv",["Model","UID","Serial Number","MAC","Customer","Phone","Date Sold","Sale Price","Payment Method","Payment Reference","Fulfillment","Carrier","Tracking Number","Delivery Status","Warranty","Warranty End","Sale Notes"],rows.map(x=>[x.model,x.uid,x.sn,x.mac,x.customerName,x.phone,x.soldAt,x.salePrice,x.paymentMethod,x.paymentReference,x.fulfillmentMethod,x.carrier,x.trackingNumber,x.deliveryStatus,x.warrantyName,x.warrantyEndDate,x.saleNotes]))}if(type==="customers"){const rows=db.prepare("SELECT name,phone,address1,address2,city,state,zip,shipping_notes FROM customers ORDER BY lower(name)").all();return csvResponse(res,"vboxstock-customers.csv",["Name","Phone","Address 1","Address 2","City","State","ZIP","Notes"],rows.map(Object.values))}if(type==="warranties"){const rows=allWarranties();return csvResponse(res,"vboxstock-warranties.csv",["Name","Duration","Unit","Active","Default","Sales"],rows.map(x=>[x.name,x.durationValue,x.durationUnit,x.active,x.isDefault,x.usageCount]))}if(type==="audit"){if(user.role!=="admin")throw Object.assign(new Error("Administrator access required."),{status:403});const rows=db.prepare("SELECT username,action,target,details,ip_address,created_at FROM audit_log ORDER BY id DESC").all();return csvResponse(res,"vboxstock-audit.csv",["Username","Action","Target","Details","IP Address","Created"],rows.map(Object.values))}}
|
||||
if(url.pathname==="/api/products"&&req.method==="GET")return json(res,200,listProducts().all());
|
||||
if(url.pathname==="/api/products"&&req.method==="POST"){const p=productInput(await body(req)),id=crypto.randomUUID();db.prepare("INSERT INTO products (id,uid,sn,mac,model,condition,received_at,cost,notes) VALUES (?,?,?,?,?,?,?,?,?)").run(id,p.uid,p.sn,p.mac,p.model,p.condition,p.receivedAt,p.cost,p.notes);audit(req,user,"product_received",id,p.model);return json(res,201,getProduct().get(id))}
|
||||
if(url.pathname==="/api/sales"&&req.method==="POST"){const v=await body(req);return json(res,201,recordSales(req,user,v,v.items))}
|
||||
const customerMatch=url.pathname.match(/^\/api\/customers\/([^/]+)$/);
|
||||
if(customerMatch&&req.method==="GET"){const id=decodeURIComponent(customerMatch[1]),customer=db.prepare("SELECT id,name,phone,address1,address2,city,state,zip,shipping_notes AS shippingNotes FROM customers WHERE id=?").get(id);if(!customer)return json(res,404,{error:"Customer not found"});const purchases=db.prepare(`SELECT ${columns} FROM products WHERE status='sold' AND customer_id=? ORDER BY sold_at DESC,rowid DESC`).all(id),notes=db.prepare("SELECT id,category,note,created_at AS createdAt,updated_at AS updatedAt FROM customer_notes WHERE customer_id=? ORDER BY created_at DESC,rowid DESC").all(id);return json(res,200,{...customer,purchases,notes})}
|
||||
const notesMatch=url.pathname.match(/^\/api\/customers\/([^/]+)\/notes(?:\/([^/]+))?$/);
|
||||
|
||||
Reference in New Issue
Block a user