import { withApi,json,readJson,HttpError,positiveNumber } from '../../_lib/http.js'; import { requireUser } from '../../_lib/auth.js'; function transactionNo(){const d=new Date(),date=d.toISOString().slice(0,10).replaceAll('-','');return `POS-${date}-${crypto.randomUUID().slice(0,8).toUpperCase()}`;} function priceType(value,fallback='retail'){return value==='wholesale'?'wholesale':fallback==='wholesale'?'wholesale':'retail';} function cleanClientId(value){const v=String(value||'').trim();return /^[A-Za-z0-9-]{8,80}$/.test(v)?v:'';} function cleanTransaction(value){const v=String(value||'').trim();return /^[A-Za-z0-9-]{8,80}$/.test(v)?v:'';} function cleanCreatedAt(value){const v=String(value||'').trim();return /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(v)?v:'';} async function findExistingSale(db,id){ if(!id)return null; const sale=await db.prepare(`SELECT s.*,u.full_name cashier_name FROM sales s LEFT JOIN users u ON u.id=s.cashier_id WHERE s.id=?`).bind(id).first(); if(!sale)return null; const {results:items=[]}=await db.prepare(`SELECT si.product_id,si.product_name name,si.barcode,si.quantity,si.unit_price,si.cost,si.line_total FROM sale_items si WHERE si.sale_id=? ORDER BY rowid`).bind(id).all(); return {id:sale.id,transaction_no:sale.transaction_no,created_at:sale.created_at,cashier_name:sale.cashier_name||'',pricing_mode:sale.pricing_mode||'retail',items,subtotal:Number(sale.subtotal||0),discount:Number(sale.discount||0),total:Number(sale.total||0),payment_method:sale.payment_method,amount_paid:Number(sale.amount_paid||0),change_due:Number(sale.change_due||0),reference_no:sale.reference_no||''}; } export const onRequestPost=withApi(async context=>{ const user=await requireUser(context),d=await readJson(context.request); const clientSaleId=cleanClientId(d.client_sale_id),existing=await findExistingSale(context.env.DB,clientSaleId); if(existing)return json({sale:existing,duplicate:true},200); if(!Array.isArray(d.items)||!d.items.length)throw new HttpError(400,'Cart is empty'); const fallbackMode=priceType(d.pricing_mode),payment=['cash','gcash','maya','bank_transfer'].includes(d.payment_method)?d.payment_method:null; if(!payment)throw new HttpError(400,'Invalid payment method'); if(payment!=='cash'&&!String(d.reference_no||'').trim())throw new HttpError(400,'Reference number is required for digital payments'); const requested=new Map(),ids=new Set(); for(const item of d.items){ const id=Number(item.product_id),quantity=positiveNumber(item.quantity,'Quantity'),mode=priceType(item.price_type,fallbackMode); if(!Number.isInteger(id)||id<1)throw new HttpError(400,'Invalid product'); const key=`${id}:${mode}`,old=requested.get(key); requested.set(key,{product_id:id,quantity:(old?.quantity||0)+quantity,price_type:mode});ids.add(id); } const productIds=[...ids],placeholders=productIds.map(()=>'?').join(','); const {results:products}=await context.env.DB.prepare(`SELECT id,name,barcode,unit,stock,retail_price,wholesale_price,cost FROM products WHERE active=1 AND id IN (${placeholders})`).bind(...productIds).all(); if(products.length!==productIds.length)throw new HttpError(400,'One or more products are unavailable'); const byId=new Map(products.map(p=>[Number(p.id),p])),totalsByProduct=new Map(); for(const r of requested.values())totalsByProduct.set(r.product_id,(totalsByProduct.get(r.product_id)||0)+r.quantity); for(const [id,quantity] of totalsByProduct){const p=byId.get(id);if(quantity>Number(p.stock))throw new HttpError(409,`${p.name} has only ${p.stock} in stock`);} const lines=[...requested.values()].map(r=>{const p=byId.get(r.product_id),unit_price=r.price_type==='wholesale'?Number(p.wholesale_price):Number(p.retail_price);return{product_id:p.id,name:p.name,barcode:p.barcode,unit:p.unit,quantity:r.quantity,price_type:r.price_type,unit_price,cost:Number(p.cost),line_total:r.quantity*unit_price};}); const subtotal=lines.reduce((sum,line)=>sum+line.line_total,0),discount=Math.min(subtotal,positiveNumber(d.discount??0,'Discount',{allowZero:true})),total=subtotal-discount,amount=positiveNumber(d.amount_tendered,'Payment amount',{allowZero:true}); if(amountl.price_type==='wholesale'),allRetail=lines.every(l=>l.price_type==='retail'),dbMode=allWholesale?'wholesale':'retail',displayMode=allWholesale?'wholesale':allRetail?'retail':'mixed'; const saleId=clientSaleId||crypto.randomUUID(),tx=cleanTransaction(d.client_transaction_no)||transactionNo(),change=amount-total,createdAt=cleanCreatedAt(d.client_created_at)||new Date().toISOString().slice(0,19).replace('T',' '); const statements=[context.env.DB.prepare(`INSERT INTO sales(id,transaction_no,cashier_id,customer_name,pricing_mode,subtotal,discount,total,payment_method,amount_paid,change_due,reference_no,status,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,'completed',?)`).bind(saleId,tx,user.id,String(d.customer_name||''),dbMode,subtotal,discount,total,payment,amount,change,String(d.reference_no||''),createdAt)]; for(const l of lines){ statements.push(context.env.DB.prepare('INSERT INTO sale_items(sale_id,product_id,product_name,barcode,quantity,unit_price,cost,line_total) VALUES(?,?,?,?,?,?,?,?)').bind(saleId,l.product_id,l.name,l.barcode,l.quantity,l.unit_price,l.cost,l.line_total)); statements.push(context.env.DB.prepare('UPDATE products SET stock=stock-?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND stock>=?').bind(l.quantity,l.product_id,l.quantity)); statements.push(context.env.DB.prepare("INSERT INTO stock_movements(product_id,type,quantity,unit_cost,reference_no,notes,created_by) VALUES(?,'sale',?,?,?,?,?)").bind(l.product_id,-l.quantity,l.cost,tx,`POS sale - ${l.price_type} price`,user.id)); } statements.push(context.env.DB.prepare('INSERT INTO payments(sale_id,method,amount,reference_no) VALUES(?,?,?,?)').bind(saleId,payment,amount,String(d.reference_no||''))); await context.env.DB.batch(statements); return json({sale:{id:saleId,transaction_no:tx,created_at:createdAt,cashier_name:user.full_name,pricing_mode:displayMode,items:lines,subtotal,discount,total,payment_method:payment,amount_paid:amount,change_due:change,reference_no:String(d.reference_no||'')}},201); });