From e5dc37e0ce42cdf358571ce30b5769eede68f231 Mon Sep 17 00:00:00 2001 From: mfwadejr <125498560+mfwadejr@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:51:56 -0400 Subject: [PATCH] Add multi-item sales --- CHANGELOG.md | 9 +++++++++ README.md | 3 ++- package.json | 2 +- public/app.js | 6 ++++-- public/extras.css | 1 + public/theme.css | 2 +- server.mjs | 14 +++++++++++++- test/server.test.mjs | 8 +++++++- 8 files changed, 38 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4624914..0d14724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 3.4.0 + +- Added multi-item sales so one customer transaction can include multiple available inventory devices. +- Added an individual sale-price field for every selected device and a live transaction total. +- Applied shared customer, payment, fulfillment, warranty, date, and notes to every item in the transaction. +- Made multi-item sales atomic so validation or inventory conflicts leave every selected device unchanged. +- Retained the single-device sale API for backward compatibility. +- Added API coverage for successful multi-item sales and failed-transaction rollback behavior. + ## 3.3.1 - Moved automatic-backup scheduling into the Database backups section. diff --git a/README.md b/README.md index 2b17757..ff258af 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ General inventory tools can be larger and more complicated than a small reseller 1. Receive an individually identifiable device into inventory. 2. Record its cost, model, condition, and notes. -3. Complete a sale with customer, payment, fulfillment, warranty, and transaction details. +3. Complete a sale for one or more devices with customer, payment, fulfillment, warranty, and transaction details. 4. Revisit the customer or sale later for support and follow-up. 5. Back up the complete business record without managing a separate database server. @@ -31,6 +31,7 @@ General inventory tools can be larger and more complicated than a small reseller - Configure warranty periods in Admin and see live green in-warranty countdowns or red expired indicators throughout sale history. - Keep shipped-to and installed-at addresses while allowing venue or notes-based details for drop-offs and meetups. - Record Cash, Venmo, or PayPal payments with an optional reference. +- Sell multiple inventory items in one checkout, assign an individual price to each device, and review the calculated transaction total. - Attach transaction notes to a sale and time-stamped support notes to a customer. - Browse inventory, sales, and customers in searchable 10-record pages. - Filter records by model and date, with additional payment, fulfillment, and warranty filters for sales. diff --git a/package.json b/package.json index 0f28ab1..122f15a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vboxstock", - "version": "3.3.1", + "version": "3.4.0", "private": true, "type": "module", "engines": { "node": ">=22.13.0" }, diff --git a/public/app.js b/public/app.js index d08516e..0e35efd 100644 --- a/public/app.js +++ b/public/app.js @@ -82,9 +82,11 @@ function receiveForm() { } function sellForm(id="") { const available=state.products.filter(p=>p.status==="available"); - openModal(`
Enter the customer, payment, fulfillment, and warranty details.
`); + if(!available.length){openModal('Receive a product before recording a sale.
');$("[data-cancel]").onclick=closeModal;return;} + openModal(`Select one or more products. Customer, payment, fulfillment, warranty, and notes apply to every selected item.
`); bindFulfillment($("#sellForm")); - bindCustomer($("#sellForm"));$("#sellForm").onsubmit=async e=>{ e.preventDefault();const button=e.submitter;button.disabled=true;const data=Object.fromEntries(new FormData(e.currentTarget)), productId=data.productId;delete data.productId;try{await change(`/api/products/${encodeURIComponent(productId)}/sell`,"POST",data,"Sale recorded.");}finally{button.disabled=false} }; $("[data-cancel]").onclick=closeModal; + const form=$("#sellForm"),checks=[...form.querySelectorAll('[name="productIds"]')],drawTotal=()=>{let count=0,total=0;checks.forEach(check=>{const price=form.querySelector(`[data-price="${check.value}"]`);price.disabled=!check.checked;if(check.checked){count++;total+=Number(price.value)||0}});form.querySelector("[data-selected-count]").textContent=count;form.querySelector("[data-sale-total]").textContent=money.format(total);form.querySelector('.form-actions .primary').textContent=count>1?`Complete sale (${count} items)`:"Complete sale";};checks.forEach(check=>check.onchange=drawTotal);form.querySelectorAll("[data-price]").forEach(input=>input.oninput=drawTotal);drawTotal(); + bindCustomer(form);form.onsubmit=async e=>{e.preventDefault();const button=e.submitter,selected=checks.filter(x=>x.checked);if(!selected.length){toast("Select at least one product.");return;}button.disabled=true;const data=Object.fromEntries(new FormData(form));delete data.productIds;data.items=selected.map(check=>({productId:check.value,salePrice:Number(form.querySelector(`[data-price="${check.value}"]`).value)||0}));try{await change("/api/sales","POST",data,`${selected.length} ${selected.length===1?"item":"items"} sold.`);}finally{button.disabled=false}};$("[data-cancel]").onclick=closeModal; } async function submitForm(e,path,message,method="POST"){ e.preventDefault(); await change(path,method,Object.fromEntries(new FormData(e.currentTarget)),message); } async function change(path,method,body,message){ try{ await api(path,{method,body:body?JSON.stringify(body):undefined}); closeModal(); await load(); toast(message); }catch(e){ toast(e.message); } } diff --git a/public/extras.css b/public/extras.css index ae7de3c..6d54418 100644 --- a/public/extras.css +++ b/public/extras.css @@ -7,4 +7,5 @@ .auth-pending #appShell,.auth-required #appShell{display:none}.role-admin #authScreen,.role-readonly #authScreen{display:none}.auth-screen{min-height:100vh;display:grid;place-items:center;padding:24px;background:linear-gradient(145deg,#eef4ff,#f8fafc)}.auth-card{width:min(430px,100%);background:#fff;border:1px solid #dfe5ef;border-radius:18px;box-shadow:0 22px 60px #17203320;padding:34px}.auth-brand{margin-bottom:28px}.auth-card h1{margin:0 0 7px}.auth-card>div>p{color:#68758b}.auth-submit{width:100%;margin-top:14px}.auth-message{background:#fff0f0;color:#a51d1d;border:1px solid #fecaca;border-radius:8px;padding:10px 12px;margin:14px 0}.auth-logout{width:100%;margin-top:10px}.user-menu{display:flex;align-items:center;gap:10px;border-left:1px solid #e3e7ef;padding-left:18px}.user-menu span{display:grid;font-size:13px}.user-menu small{color:#758197}.admin-section{display:grid;gap:16px;border-bottom:1px solid #e3e7ef;padding-bottom:28px}.admin-section:last-child{border-bottom:0}.inline-user-form{display:grid;grid-template-columns:1fr 1fr 180px auto;gap:10px;align-items:end}.inline-user-form label{margin:0}.user-list,.audit-list{border:1px solid #e3e7ef;border-radius:10px;overflow:hidden}.user-list article{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:14px;border-bottom:1px solid #edf0f5}.user-list article:last-child{border-bottom:0}.user-list small{display:block;color:#7b8799;margin-top:4px}.user-list article>div:last-child{display:flex;gap:7px;flex-wrap:wrap}.delete-backup{background:#fff0f0;color:#c62828}.audit-list{max-height:420px;overflow:auto}.audit-list article{display:grid;grid-template-columns:140px 180px 1fr;gap:12px;padding:10px 13px;border-bottom:1px solid #edf0f5;font-size:13px}.audit-list span{text-transform:capitalize}.audit-list small{color:#7b8799}@media(max-width:900px){.inline-user-form{grid-template-columns:1fr 1fr}.user-list article{align-items:flex-start;flex-direction:column}.audit-list article{grid-template-columns:1fr}.user-menu span{display:none}}@media(max-width:600px){.inline-user-form{grid-template-columns:1fr}.user-menu{padding-left:5px}.user-menu button{font-size:12px;padding:8px}.auth-card{padding:25px}} .inline-model-form{display:grid;grid-template-columns:minmax(220px,1fr) auto;gap:10px;align-items:end}.inline-model-form label{margin:0}.model-list{border:1px solid #e3e7ef;border-radius:10px;overflow:hidden}.model-list article{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:14px;border-bottom:1px solid #edf0f5}.model-list article:last-child{border-bottom:0}.model-summary>div{display:flex;align-items:center;gap:9px}.model-summary small{display:block;color:#7b8799;margin-top:5px}.model-status{display:inline-flex;border-radius:99px;padding:3px 8px;font-size:11px;font-weight:750}.model-status.active{background:#dcfce7;color:#166534}.model-status.archived{background:#eef1f5;color:#526071}.model-actions{display:flex;gap:7px;flex-wrap:wrap}@media(max-width:760px){.inline-model-form{grid-template-columns:1fr}.model-list article{align-items:stretch;flex-direction:column}.model-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.model-actions button{text-align:center}} .conditional-fields{margin:2px 0 14px;padding:16px;border:1px solid #e3e7ef;border-radius:11px;background:#f8fafc}.conditional-fields h3{margin:0 0 12px}.conditional-hint{margin:4px 0 16px;padding:13px;border:1px dashed #d7deea;border-radius:9px;color:#68758b}.field-help{display:block;margin-top:-8px;color:#68758b}.warranty-pill{display:inline-flex;align-items:center;border-radius:99px;padding:5px 9px;font-size:12px;font-weight:750;white-space:nowrap}.warranty-pill.in-warranty{background:#dcfce7;color:#166534}.warranty-pill.expired{background:#fee2e2;color:#b91c1c}.warranty-pill.neutral{background:#eef1f5;color:#526071}.inline-warranty-form{display:grid;grid-template-columns:minmax(180px,1fr) 130px 150px auto;gap:10px;align-items:end}.inline-warranty-form label{margin:0}.warranty-list{border:1px solid #e3e7ef;border-radius:10px;overflow:hidden}.warranty-list article{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:14px;border-bottom:1px solid #edf0f5}.warranty-list article:last-child{border-bottom:0}@media(max-width:760px){.inline-warranty-form{grid-template-columns:1fr}.warranty-list article{align-items:stretch;flex-direction:column}} +.sale-items{margin:18px 0;padding:0;border:1px solid #d7deea;border-radius:11px;overflow:hidden}.sale-items legend{margin-left:12px;padding:0 6px;font-size:13px;font-weight:750}.sale-item-list{max-height:250px;overflow:auto}.sale-item{display:grid;grid-template-columns:auto minmax(0,1fr) 130px;align-items:center;gap:11px;margin:0;padding:11px 13px;border-bottom:1px solid #edf0f5}.sale-item>input{width:18px;height:18px}.sale-item small{display:block;margin-top:3px;color:#68758b}.sale-item-price{font-size:12px}.sale-item-price input{margin-top:4px;text-align:right}.sale-summary{display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:#f8fafc}.sale-summary span{color:#68758b}@media(max-width:520px){.sale-item{grid-template-columns:auto minmax(0,1fr)}.sale-item-price{grid-column:2}.sale-item-list{max-height:310px}} .filter-bar{display:flex;align-items:end;gap:9px;flex-wrap:wrap;padding:12px 16px;border-bottom:1px solid #e3e7ef}.filter-bar label{margin:0;min-width:125px;font-size:11px}.filter-bar select,.filter-bar input{padding:8px 9px;font-size:13px}.filter-bar .button,.filter-bar button{padding:9px 12px}.export-button{text-decoration:none}.customer-match{min-height:18px;margin:-7px 0 10px!important;color:#1d4ed8!important;font-size:12px}.diagnostic-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.diagnostic-grid article{display:grid;gap:4px;padding:14px;border:1px solid #e3e7ef;border-radius:10px}.diagnostic-grid span,.diagnostic-grid small{color:#68758b}.diagnostic-grid strong{font-size:18px}.automatic-backups{display:grid;gap:12px;padding:16px;border:1px solid #e3e7ef;border-radius:10px}.automatic-backups h3,.automatic-backups p{margin:0}.backup-schedule{display:grid;grid-template-columns:minmax(190px,1.3fr) repeat(4,minmax(130px,1fr)) auto;gap:10px;align-items:end}.backup-schedule label{margin:0}.weekly-field[hidden]{display:none!important}.check-label{display:flex!important;align-items:center;gap:8px;min-height:42px}.check-label input{width:auto}.backup-schedule-status{display:flex;gap:18px;flex-wrap:wrap;font-size:13px}.export-links{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.export-links .button{padding:8px 11px;text-decoration:none}@media(max-width:1100px){.backup-schedule{grid-template-columns:1fr 1fr 1fr}}@media(max-width:900px){.diagnostic-grid{grid-template-columns:1fr 1fr}.backup-schedule{grid-template-columns:1fr 1fr}}@media(max-width:600px){.filter-bar{align-items:stretch}.filter-bar label,.filter-bar .button,.filter-bar button{width:100%}.diagnostic-grid,.backup-schedule{grid-template-columns:1fr}} diff --git a/public/theme.css b/public/theme.css index 816b73e..dbe6515 100644 --- a/public/theme.css +++ b/public/theme.css @@ -25,7 +25,7 @@ th{background:var(--surface-table);color:var(--muted-2)}th,td,.records nav,.back .pill{background:var(--green-soft);color:var(--green)}.detail-card{background:var(--surface-soft);border-color:var(--border)} .customer-notes article,.backup-list,.user-list,.model-list,.warranty-list,.audit-list{border-color:var(--border)} .model-list article{border-color:var(--border)}.model-summary small{color:var(--muted)}.model-status.active{background:var(--green-soft);color:var(--green)}.model-status.archived{background:var(--surface-soft);color:var(--muted)} -.warranty-list article,.conditional-fields{border-color:var(--border)}.conditional-fields{background:var(--surface-soft)}.conditional-hint{border-color:var(--border-strong);color:var(--muted)}.field-help{color:var(--muted)}.warranty-pill.in-warranty{background:var(--green-soft);color:var(--green)}.warranty-pill.expired{background:var(--danger-soft);color:var(--danger)}.warranty-pill.neutral{background:var(--surface-soft);color:var(--muted)} +.warranty-list article,.conditional-fields,.sale-items,.sale-item{border-color:var(--border)}.conditional-fields,.sale-summary{background:var(--surface-soft)}.sale-item small,.sale-summary span,.conditional-hint{color:var(--muted)}.conditional-hint{border-color:var(--border-strong)}.field-help{color:var(--muted)}.warranty-pill.in-warranty{background:var(--green-soft);color:var(--green)}.warranty-pill.expired{background:var(--danger-soft);color:var(--danger)}.warranty-pill.neutral{background:var(--surface-soft);color:var(--muted)} .filter-bar{border-color:var(--border);background:var(--surface-soft)}.filter-bar .button{background:var(--surface);border:1px solid var(--border-strong);color:var(--text)}.customer-match{color:var(--blue-text)!important}.diagnostic-grid article,.automatic-backups{background:var(--surface-soft);border-color:var(--border)}.diagnostic-grid span,.diagnostic-grid small,.automatic-backups p,.backup-schedule-status{color:var(--muted)}.export-links .button{background:var(--surface-soft);border:1px solid var(--border-strong);color:var(--text)} .note-category{background:var(--blue-soft);color:var(--blue-text)}.backup-warning{background:var(--warning-soft);border-color:color-mix(in srgb,var(--warning) 40%,transparent);color:var(--warning)} .auth-screen{background:linear-gradient(145deg,color-mix(in srgb,var(--blue) 10%,var(--page)),var(--page))}.auth-card{box-shadow:0 22px 60px var(--shadow)} diff --git a/server.mjs b/server.mjs index c7880fa..37889dd 100644 --- a/server.mjs +++ b/server.mjs @@ -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(?:\/([^/]+))?$/); diff --git a/test/server.test.mjs b/test/server.test.mjs index 43e80c0..beff9be 100644 --- a/test/server.test.mjs +++ b/test/server.test.mjs @@ -47,12 +47,18 @@ test("authentication, roles, inventory, sale, and restock", async t => { response=await request(`/api/admin/models/${customModel.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({active:false})});assert.equal(response.status,200); response=await request("/api/products",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({uid:"U2",model:"V7 Ultra Plus",condition:"New",receivedAt:"2026-08-29"})});assert.equal(response.status,400,"archived models cannot be newly received"); response=await request(`/api/products/${item.id}/sell`,{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({customerName:"Test Customer",soldAt:"2026-08-29",paymentMethod:"Venmo",paymentReference:"TX-123",fulfillmentMethod:"Shipped",shipAddress1:"1 Main St",shipCity:"Philadelphia",shipState:"PA",shipZip:"19103",carrier:"UPS",trackingNumber:"1ZTEST",warrantyPresetId:ninetyDays.id})});const sale=await response.json();assert.equal(sale.status,"sold");assert.equal(sale.paymentMethod,"Venmo");assert.equal(sale.paymentReference,"TX-123");assert.equal(sale.fulfillmentMethod,"Shipped");assert.equal(sale.deliveryStatus,"Label Created");assert.equal(sale.warrantyName,"90 Days");assert.equal(sale.warrantyEndDate,"2026-11-27"); + response=await request("/api/products",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({uid:"BATCH-1",model:"V3 Plus",condition:"New",receivedAt:"2026-08-29"})});const batchItem1=await response.json(); + response=await request("/api/products",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({uid:"BATCH-2",model:"V5 Pro",condition:"New",receivedAt:"2026-08-29"})});const batchItem2=await response.json(); + response=await request("/api/sales",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({items:[{productId:batchItem1.id,salePrice:200},{productId:batchItem2.id,salePrice:275.5}],customerId:sale.customerId,customerName:"Test Customer",soldAt:"2026-08-29",paymentMethod:"Cash",fulfillmentMethod:"Meet",fulfillmentName:"Wawa",warrantyPresetId:ninetyDays.id})});assert.equal(response.status,201);const batchSale=await response.json();assert.equal(batchSale.count,2);assert.equal(batchSale.total,475.5);assert.ok(batchSale.products.every(x=>x.status==="sold"));assert.deepEqual(batchSale.products.map(x=>x.salePrice),[200,275.5]);assert.ok(batchSale.products.every(x=>x.customerId===sale.customerId)); + response=await request("/api/products",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({uid:"ATOMIC-1",model:"V3 Plus",condition:"New",receivedAt:"2026-08-29"})});const atomicItem=await response.json(); + response=await request("/api/sales",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({items:[{productId:atomicItem.id,salePrice:100},{productId:batchItem1.id,salePrice:100}],customerId:sale.customerId,customerName:"Test Customer",soldAt:"2026-08-29",paymentMethod:"Cash",fulfillmentMethod:"Meet",fulfillmentName:"Wawa",warrantyPresetId:ninetyDays.id})});assert.equal(response.status,400,"a batch containing an unavailable product is rejected"); + response=await request("/api/products",{headers:{cookie:adminCookie}});assert.equal((await response.json()).find(x=>x.id===atomicItem.id).status,"available","a failed batch leaves every product unchanged"); response=await request(`/api/products/${item.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({saleNotes:"Left at front desk",fulfillmentMethod:"Shipped",shipAddress1:"1 Main St",shipCity:"Philadelphia",shipState:"PA",shipZip:"19103",carrier:"UPS",trackingNumber:"1ZTEST",deliveryStatus:"Delivered",warrantyPresetId:customWarranty.id})});const updatedSale=await response.json();assert.equal(updatedSale.deliveryStatus,"Delivered");assert.equal(updatedSale.warrantyName,"45 Days");assert.equal(updatedSale.warrantyEndDate,"2026-10-13"); response=await request(`/api/products/${item.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({customerId:sale.customerId,customerName:"Test Customer",phone:"(215) 555-0100",soldAt:"2026-08-30",salePrice:325,paymentMethod:"PayPal",paymentReference:"PP-456",saleNotes:"Corrected sale",fulfillmentMethod:"Meet",fulfillmentName:"Wawa",fulfillmentNotes:"Met at front entrance",warrantyPresetId:customWarranty.id})});const corrected=await response.json();assert.equal(corrected.salePrice,325);assert.equal(corrected.paymentMethod,"PayPal");assert.equal(corrected.fulfillmentMethod,"Meet");assert.equal(corrected.warrantyEndDate,"2026-10-14","changing sale date recalculates warranty end"); response=await request("/api/customers",{headers:{cookie:adminCookie}});const customerDirectory=await response.json();assert.equal(customerDirectory.length,1);assert.equal(customerDirectory[0].phone,"(215) 555-0100"); response=await request("/api/export/sales",{headers:{cookie:viewerCookie}});assert.equal(response.status,200);assert.match(response.headers.get("content-type"),/text\/csv/);assert.match(await response.text(),/PP-456/); response=await request("/api/export/audit",{headers:{cookie:viewerCookie}});assert.equal(response.status,403,"read-only users cannot export the security audit"); - response=await request("/api/admin/diagnostics",{headers:{cookie:adminCookie}});const diagnostics=await response.json();assert.equal(diagnostics.appVersion,"3.3.1");assert.equal(diagnostics.dataWritable,true);assert.equal(diagnostics.backupSettings.enabled,false);assert.equal(diagnostics.backupSettings.frequency,"daily");assert.equal(diagnostics.backupSettings.nextScheduledBackup,null); + response=await request("/api/admin/diagnostics",{headers:{cookie:adminCookie}});const diagnostics=await response.json();assert.equal(diagnostics.appVersion,"3.4.0");assert.equal(diagnostics.dataWritable,true);assert.equal(diagnostics.backupSettings.enabled,false);assert.equal(diagnostics.backupSettings.frequency,"daily");assert.equal(diagnostics.backupSettings.nextScheduledBackup,null); response=await request("/api/admin/backup-settings",{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({enabled:true,frequency:"weekly",weekday:1,hour:3,retention:7})});assert.equal(response.status,200); response=await request("/api/admin/diagnostics",{headers:{cookie:adminCookie}});const scheduled=(await response.json()).backupSettings;assert.equal(scheduled.enabled,true);assert.equal(scheduled.frequency,"weekly");assert.equal(scheduled.weekday,1);assert.equal(scheduled.hour,3);assert.equal(scheduled.retention,7);assert.equal(scheduled.lastScheduledBackup,null);assert.ok(scheduled.nextScheduledBackup); response=await request(`/api/admin/warranties/${customWarranty.id}`,{method:"DELETE",headers:{cookie:adminCookie}});assert.equal(response.status,400,"a used warranty cannot be deleted");