Add multi-item sales

This commit is contained in:
mfwadejr
2026-09-20 22:51:56 -04:00
parent dce101246b
commit e5dc37e0ce
8 changed files with 38 additions and 7 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog # 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 ## 3.3.1
- Moved automatic-backup scheduling into the Database backups section. - Moved automatic-backup scheduling into the Database backups section.
+2 -1
View File
@@ -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. 1. Receive an individually identifiable device into inventory.
2. Record its cost, model, condition, and notes. 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. 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. 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. - 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. - 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. - 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. - Attach transaction notes to a sale and time-stamped support notes to a customer.
- Browse inventory, sales, and customers in searchable 10-record pages. - 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. - Filter records by model and date, with additional payment, fulfillment, and warranty filters for sales.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vboxstock", "name": "vboxstock",
"version": "3.3.1", "version": "3.4.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "node": ">=22.13.0" }, "engines": { "node": ">=22.13.0" },
+4 -2
View File
@@ -82,9 +82,11 @@ function receiveForm() {
} }
function sellForm(id="") { function sellForm(id="") {
const available=state.products.filter(p=>p.status==="available"); const available=state.products.filter(p=>p.status==="available");
openModal(`<h2>Record a sale</h2><p>Enter the customer, payment, fulfillment, and warranty details.</p><form id="sellForm"><label>Product<select name="productId" required>${available.map(p=>`<option value="${p.id}" ${p.id===id?"selected":""}>${esc(p.model)}${esc(primaryId(p))}</option>`).join("")}</select></label>${customerFields()}<div class="form-row"><label>Payment method<select name="paymentMethod" required><option value="">Choose a method</option><option>Cash</option><option>Venmo</option><option>PayPal</option></select></label><label>Payment reference (optional)<input name="paymentReference" placeholder="Transaction ID or note"></label></div><div class="form-row"><label>Sale price<input name="salePrice" type="number" min="0" step=".01"></label><label>Date sold<input name="soldAt" type="date" value="${today()}" required></label></div><div class="form-row"><label>Delivery method<select name="fulfillmentMethod" required><option value="">Choose a method</option><option>Shipped</option><option>Dropped Off</option><option>Installed At</option><option>Meet</option></select></label><label>Warranty<select name="warrantyPresetId" required>${warrantyOptions()}</select></label></div><div class="fulfillment-fields"></div><label>Transaction notes (optional)<textarea name="saleNotes" rows="3" placeholder="Additional notes about this sale"></textarea></label><div class="form-actions"><button type="button" class="secondary" data-cancel>Cancel</button><button class="primary">Complete sale</button></div></form>`); if(!available.length){openModal('<h2>No available inventory</h2><p>Receive a product before recording a sale.</p><div class="form-actions"><button class="primary" data-cancel>Close</button></div>');$("[data-cancel]").onclick=closeModal;return;}
openModal(`<h2>Record a sale</h2><p>Select one or more products. Customer, payment, fulfillment, warranty, and notes apply to every selected item.</p><form id="sellForm"><fieldset class="sale-items"><legend>Products</legend><div class="sale-item-list">${available.map(p=>`<label class="sale-item"><input type="checkbox" name="productIds" value="${p.id}" ${p.id===id?"checked":""}><span><strong>${esc(p.model)}</strong><small>${esc(primaryId(p))}</small></span><span class="sale-item-price">Sale price<input type="number" min="0" step=".01" inputmode="decimal" data-price="${p.id}" aria-label="Sale price for ${esc(p.model)} ${esc(primaryId(p))}" ${p.id===id?"":"disabled"}></span></label>`).join("")}</div><div class="sale-summary" aria-live="polite"><span><strong data-selected-count>0</strong> selected</span><strong data-sale-total>${money.format(0)}</strong></div></fieldset>${customerFields()}<div class="form-row"><label>Payment method<select name="paymentMethod" required><option value="">Choose a method</option><option>Cash</option><option>Venmo</option><option>PayPal</option></select></label><label>Payment reference (optional)<input name="paymentReference" placeholder="Transaction ID or note"></label></div><label>Date sold<input name="soldAt" type="date" value="${today()}" required></label><div class="form-row"><label>Delivery method<select name="fulfillmentMethod" required><option value="">Choose a method</option><option>Shipped</option><option>Dropped Off</option><option>Installed At</option><option>Meet</option></select></label><label>Warranty<select name="warrantyPresetId" required>${warrantyOptions()}</select></label></div><div class="fulfillment-fields"></div><label>Transaction notes (optional)<textarea name="saleNotes" rows="3" placeholder="Additional notes about this sale"></textarea></label><div class="form-actions"><button type="button" class="secondary" data-cancel>Cancel</button><button class="primary">Complete sale</button></div></form>`);
bindFulfillment($("#sellForm")); 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 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); } } 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); } }
+1
View File
@@ -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}} .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}} .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}} .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}} .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}}
+1 -1
View File
@@ -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)} .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)} .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)} .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)} .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)} .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)} .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)}
+13 -1
View File
@@ -5,7 +5,7 @@ import { extname, join, normalize } from "node:path";
import { backup, DatabaseSync } from "node:sqlite"; import { backup, DatabaseSync } from "node:sqlite";
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto"; 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 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(); const SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map();
mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true}); 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"]); 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 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 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)} 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){ 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))}} 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==="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/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\/([^/]+)$/); 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})} 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(?:\/([^/]+))?$/); const notesMatch=url.pathname.match(/^\/api\/customers\/([^/]+)\/notes(?:\/([^/]+))?$/);
+7 -1
View File
@@ -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/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",{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/${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({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/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/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/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/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/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/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"); response=await request(`/api/admin/warranties/${customWarranty.id}`,{method:"DELETE",headers:{cookie:adminCookie}});assert.equal(response.status,400,"a used warranty cannot be deleted");