From 8138dfc9e3e206bc41dc7a79ecebbd69b063e039 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Sun, 18 Oct 2026 19:37:28 +0530 Subject: [PATCH 1/4] Support mobile product creation, opening stock and receipt designs --- api/src/routes/mobile-pos.routes.js | 12 +- api/src/services/mobile-pos.js | 3 +- api/src/services/mobile-products.js | 138 ++++++++++++++++++++++ api/src/services/mobile-receipt-design.js | 33 ++++++ api/tests/mobile-pos.integration.cjs | 26 ++++ api/tests/mobile-products.integration.cjs | 75 ++++++++++++ docs/mobile-product-capture-receipts.md | 48 ++++++++ 7 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 api/src/services/mobile-products.js create mode 100644 api/src/services/mobile-receipt-design.js create mode 100644 api/tests/mobile-products.integration.cjs create mode 100644 docs/mobile-product-capture-receipts.md diff --git a/api/src/routes/mobile-pos.routes.js b/api/src/routes/mobile-pos.routes.js index f7b68e588..57005b539 100644 --- a/api/src/routes/mobile-pos.routes.js +++ b/api/src/routes/mobile-pos.routes.js @@ -127,6 +127,9 @@ router.post( ); router.use(protect); const inventory = require('../services/mobile-inventory'); +router.get('/products/options', wrap(require('../services/mobile-products').options)); +router.get('/products/lookup', wrap(require('../services/mobile-products').lookup)); +router.post('/products', wrap(require('../services/mobile-products').create)); router.get('/availability-status', wrap(require('../services/item-availability').status)); router.get('/inventory/policy', wrap(inventory.policy)); router.post('/inventory/policy', wrap(inventory.policy)); @@ -383,7 +386,7 @@ router.post( payload = require('../helpers/bill-payload').buildBillPayload(sale, c.branch); payload.payments = [ { - label: sale.payment_mode === 'Upi' ? 'UPI (staff confirmed)' : 'Cash', + label: intent.sale.payment.method === 'upi' ? 'UPI (staff confirmed)' : sale.payment_mode || intent.sale.payment.method, amount: intent.sale.payment.method === 'cash' ? intent.sale.payment.received / 100 @@ -391,6 +394,13 @@ router.post( }, ]; if (intent.sale.payment.method === 'cash') payload.change = intent.sale.payment.change / 100; + payload.title = 'RECEIPT'; + if (payload.receiptDocument) Object.assign(payload.receiptDocument, { + receipt_settled: true, + payment_mode: sale.payment_mode || intent.sale.payment.method, + received_amount: intent.sale.payment.method === 'cash' ? intent.sale.payment.received / 100 : intent.sale.total / 100, + change_amount: intent.sale.payment.method === 'cash' ? intent.sale.payment.change / 100 : 0, + }); } else payload = { storeName: c.branch.branch_name || 'Posnic', diff --git a/api/src/services/mobile-pos.js b/api/src/services/mobile-pos.js index 97e73b4e3..7b55fdcbd 100644 --- a/api/src/services/mobile-pos.js +++ b/api/src/services/mobile-pos.js @@ -206,6 +206,7 @@ async function bootstrap(req) { snapshotVersion: key, offlineUntil: until.toISOString(), historyPolicy: { days: c.config.historyDays, maxReceipts: c.config.historyMaxReceipts }, + receiptProfile: await require('./mobile-receipt-design').profile(c.branch), quickTaxBps: c.config.quickTaxBps, quickTaxInclusive: c.config.quickTaxInclusive, permissions: { @@ -217,7 +218,7 @@ async function bootstrap(req) { voidLine: allowed(req.user, 'pos', 'void_line'), receiptPrint: allowed(req.user, 'pos', 'reprint_receipt'), customerWrite: allowed(req.user, 'customer'), - itemWrite: false, + itemWrite: allowed(req.user, 'item', 'read') && allowed(req.user, 'item', 'write'), priceOverride: false, manualUpi: c.config.paymentMethods.includes('upi') && c.config.upiAccounts.length > 0, }, diff --git a/api/src/services/mobile-products.js b/api/src/services/mobile-products.js new file mode 100644 index 000000000..97949bdec --- /dev/null +++ b/api/src/services/mobile-products.js @@ -0,0 +1,138 @@ +'use strict'; +const { ObjectId } = require('mongodb'); +const mobile = require('./mobile-pos'); +const inventory = require('./mobile-inventory'); +const barcodes = require('../utils/item-barcodes'); +const { withBranchScope } = require('./branch-scope'); +const text = (value, max = 160) => { + if (value == null) return ''; + if (typeof value !== 'string' || value.length > max || /[\x00-\x1f]/.test(value)) + mobile.fail('Invalid product details.', 400); + return value.trim(); +}; +async function access(req) { + const c = await mobile.context(req); + if (!mobile.allowed(req.user, 'item', 'read') || !mobile.allowed(req.user, 'item', 'write')) + mobile.fail('Item management permission is required.', 403); + return c; +} +const referenceScope = (c) => ({ ...withBranchScope({ license: c.license }, c.branchId), is_deleted: { $ne: true }, del_status: { $nin: [1, '1', true] } }); +async function options(req) { + const c = await access(req); + const q = text(req.query?.q, 100); + const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const filter = referenceScope(c); + if (q) filter.$or = ['name', 'category_name', 'supplier_name', 'unit_name'].map(key => ({ [key]: { $regex: escaped, $options: 'i' } })); + const rows = async (collection) => req.db.collection(collection).find(filter).sort({ _id: 1 }).limit(1001).toArray(); + const [categories, suppliers, taxes, units] = await Promise.all([ + rows('categories'), mobile.allowed(req.user, 'supplier', 'read') ? rows('suppliers') : [], rows('grouptax'), rows('unit'), + ]); + const map = (list) => list.slice(0, 1000).map(r => ({ id: String(r._id), name: String(r.name || r.category_name || r.supplier_name || r.unit_name || '') })); + return { + shopId: c.shopId, branchId: String(c.branchId), staffId: String(c.userId), + categories: map(categories), suppliers: map(suppliers), units: map(units), + taxes: taxes.slice(0, 1000).map(r => ({ id: String(r._id), name: String(r.name || 'Tax'), rate: Number(r.rate ?? r.tax_value ?? 0) })), + trackStock: c.branch.item_stock_default === 'track_quantities', + truncated: [categories, suppliers, taxes, units].some(rows => rows.length > 1000), + }; +} +async function lookup(req) { + const c = await access(req); + const barcode = text(req.query?.barcode, 128); + if (!barcode) mobile.fail('Scan or enter a barcode.', 400); + const rows = await req.db.collection('items').find({ + ...referenceScope(c), $or: [{ barcode_id: barcode }, { barcodes: barcode }], + }).sort({ _id: 1 }).limit(201).toArray(); + return { shopId: c.shopId, branchId: String(c.branchId), staffId: String(c.userId), + rows: rows.slice(0, 200).map(inventory.item), truncated: rows.length > 200 }; +} +async function create(req, deps = {}) { + const c = await access(req), b = req.body || {}; + if (b.scope?.shopId !== c.shopId || b.scope?.branchId !== String(c.branchId) || b.scope?.staffId !== String(c.userId)) + mobile.fail('Product belongs to another shop or user.', 409); + if (!/^[a-zA-Z0-9_-]{12,80}$/.test(b.id || '')) mobile.fail('Invalid request identity.', 400); + const p = b.product || {}; + const product = { + name: text(p.name), barcode: text(p.barcode, 128), sku: text(p.sku, 80), + brand: text(p.brand, 100), unit: text(p.unit, 30) || 'piece', categoryId: text(p.categoryId, 24), + supplierId: text(p.supplierId, 24), taxId: text(p.taxId, 24), + price: p.price, cost: p.cost ?? 0, mrp: p.mrp ?? 0, opening: p.opening ?? null, + tracked: p.tracked, weighed: p.weighed, taxInclusive: p.taxInclusive, + available: p.available, image: p.image || '', + }; + if (!product.name || ['price', 'cost', 'mrp'].some(k => !Number.isSafeInteger(product[k]) || product[k] < 0 || product[k] > 99999999999) || + ['tracked', 'weighed', 'taxInclusive', 'available'].some(k => typeof product[k] !== 'boolean') || + (product.opening !== null && (!product.tracked || !Number.isSafeInteger(product.opening) || product.opening < 0 || product.opening > 999999999999 || (!product.weighed && product.opening % 1000)))) + mobile.fail('Review the product price, unit and opening stock.', 400); + if (typeof product.image !== 'string' || product.image.length > 1400000) mobile.fail('Use a product photo under 1 MB.', 400); + const fingerprint = mobile.hash(mobile.canonical(product)); + const itemId = new ObjectId(mobile.hash([c.shopId, c.branchId, c.userId, b.id].join(':')).slice(0, 24)); + const collection = req.db.collection('items'); + return barcodes.withWriteLock({ licenseId: c.license }, async () => { + let saved = await collection.findOne({ _id: itemId, license: c.license, branch_id: c.branchId }); + if (saved && saved.mobile_creation?.fingerprint !== fingerprint) mobile.fail('This save was already used for different product details.', 409); + if (!saved) { + const clash = await barcodes.findConflict(collection, [product.barcode].filter(Boolean), { branchId: c.branchId, licenseId: c.license }); + if (clash) mobile.fail(clash.message, 409); + const reference = async (kind, id) => { + if (!id) return null; + if (!/^[a-f0-9]{24}$/i.test(id)) mobile.fail('Invalid product reference.', 400); + const row = await req.db.collection(kind).findOne({ ...referenceScope(c), _id: new ObjectId(id) }); + if (!row) mobile.fail('Refresh the product choices; a selected record is no longer available.', 409); + return row; + }; + if (product.supplierId && !mobile.allowed(req.user, 'supplier', 'read')) mobile.fail('Supplier access is required.', 403); + const [category, supplier, tax] = await Promise.all([reference('categories', product.categoryId), reference('suppliers', product.supplierId), reference('grouptax', product.taxId)]); + const rate = Number(tax?.rate ?? tax?.tax_value ?? 0); + if (!Number.isFinite(rate) || rate < 0 || rate > 100) mobile.fail('Unsupported tax rate.', 400); + let image = ''; + if (product.image) { + const { bytes, type } = require('./paper-order').image(product.image); + const store = require('../utils/image-store'); + const key = store.keyFor({ tenantId: req.tenant?._id || req.tenant?.id || req.tenantId || null, kind: 'items', buffer: bytes, mimeType: type }); + await store.saveLocal(key, bytes); + image = store.urlFor(key); + } + const at = new Date(); + const actor = String(req.user.username || req.user.name || c.userId); + const row = { + _id: itemId, license: c.license, branch_id: c.branchId, + branch_access: [{ branch_id: c.branchId, branch_name: c.branch.branch_name || '' }], + name: product.name, itemid: product.sku || 'M-' + String(itemId).slice(-12), sku: product.sku, + barcode_id: product.barcode, barcodes: [], brand: product.brand, unit: product.unit, + category_id: category?._id || '', category_name: category?.name || category?.category_name || '', + supplier_id: supplier?._id || '', supplier_name: supplier?.name || supplier?.supplier_name || '', + tax_id: tax?._id || '', tax_name: tax?.name || '', tax: rate, tax_fields: tax?.tax_fields || [], tax_type: product.taxInclusive ? 'inclusive' : 'exclusive', + selling_price: product.price / 100, company_price: product.cost / 100, mrp_price: product.mrp / 100, + track_inventory: product.tracked, available_quantity: (product.opening ?? 0) / 1000, + item_weight_machine_based: product.weighed, available_for_sale: product.available, + isAvailable: true, item_status: 'regular', image, discount_amount: 0, discount_percentage: 0, + created_date: at, updated_date: at, createdAt: at, updatedAt: at, + created_by: actor, updated_by: actor, created_by_id: c.userId, updated_by_id: c.userId, + mobile_creation: { id: b.id, fingerprint, actor: c.userId, at, opening: product.opening }, + }; + try { await collection.insertOne(row); } catch (e) { if (e.code !== 11000) throw e; } + saved = await collection.findOne({ _id: itemId, license: c.license, branch_id: c.branchId }); + if (saved?.mobile_creation?.fingerprint !== fingerprint) mobile.fail('Product save identity conflict.', 409); + } + if (deps.afterInsert) await deps.afterInsert(); + // The initial balance is part of the item insert, never an increment on retry. + // Recover its audit record even if the first response was lost after insertion. + if (saved.mobile_creation.opening !== null) { + const logId = new ObjectId(mobile.hash('opening:' + itemId).slice(0, 24)); + await req.db.collection('stocklogs').updateOne({ _id: logId }, { $setOnInsert: { + license: c.license, branch_id: c.branchId, view_item_id: itemId, item_name: saved.name, + item_barcode_id: saved.barcode_id, process: 'Mobile opening stock', reference: b.id, + action: 'Add', stocklog: true, opening_balance: 0, closing_balance: saved.mobile_creation.opening / 1000, + count: String(saved.mobile_creation.opening / 1000), date: saved.created_date, + created_date: saved.created_date, updated_date: saved.created_date, + changed_by: saved.created_by, changed_by_userid: c.userId, + } }, { upsert: true }); + require('../sync/outbox').enqueue({ collection: 'stocklogs', documentId: logId, reason: 'inventory' }); + } + require('../sync/outbox').enqueue({ collection: 'items', documentId: itemId, reason: 'inventory' }); + require('../sync/nudge').nudgeSyncAgent(); + return { shopId: c.shopId, branchId: String(c.branchId), staffId: String(c.userId), item: inventory.item(saved), confirmedAt: new Date().toISOString() }; + }); +} +module.exports = { options, lookup, create }; diff --git a/api/src/services/mobile-receipt-design.js b/api/src/services/mobile-receipt-design.js new file mode 100644 index 000000000..8ba7a8a97 --- /dev/null +++ b/api/src/services/mobile-receipt-design.js @@ -0,0 +1,33 @@ + +'use strict'; +const mobile = require('./mobile-pos'); +const design = require('../helpers/receipt-design'); +const fields = ['branch_name','printing_address','store_telephone','store_email','branch_gstin_number','branch_fssai_number','country','table_options','website','invoice_terms','quote_default_signature']; +async function profile(branch) { + if (!branch.receipt_designs) return null; + let receiptDesign; + try { receiptDesign = design.normalize(branch.receipt_designs); } catch { return null; } + const data = {}; + for (const key of fields) if (typeof branch[key] === 'string' || typeof branch[key] === 'boolean') data[key] = branch[key]; + let assetsComplete = true; + if (branch.logo && branch.logo !== 'store.png') { + if (design.image(branch.logo)) data.logo = branch.logo; + else { + const store = require('../utils/image-store'); + const resolved = store.resolve(branch.logo); + const key = resolved?.startsWith('/uploads/') ? resolved.slice(9) : ''; + const path = store.localPathFor(key) || (store.isLegacyFile(key) ? require("path").join(store.UPLOAD_DIR, key) : null); + if (path) { + try { + const bytes = await require('fs/promises').readFile(path); + if (bytes.length > 300000) throw Error('logo too large'); + const type = /\.png$/.test(key) ? 'png' : /\.webp$/.test(key) ? 'webp' : 'jpeg'; + data.logo = 'data:image/' + type + ';base64,' + bytes.toString('base64'); + } catch { assetsComplete = false; } + } else assetsComplete = false; + } + } + data.receipt_designs = receiptDesign; + return { version: mobile.hash(mobile.canonical(data)), data, assetsComplete }; +} +module.exports = { profile }; diff --git a/api/tests/mobile-pos.integration.cjs b/api/tests/mobile-pos.integration.cjs index 8364c6a1c..5258fdf7a 100644 --- a/api/tests/mobile-pos.integration.cjs +++ b/api/tests/mobile-pos.integration.cjs @@ -1310,3 +1310,29 @@ test('public mobile discovery exposes a shop name without credentials or interna assert.equal(result.status, 200); assert.deepEqual(result.data, { shopName: branch.branch_name }); }); + +test('new mobile product gets a signed sale catalogue; opening stock and sale each apply once', async () => { + const receiptContract=require('../src/helpers/receipt-design'); + const receiptDesign=receiptContract.normalize({version:1,defaultFormat:'80',layouts:Object.fromEntries(Object.keys(receiptContract.formats).map(k=>[k,{fontSize:12,blocks:['store','transaction','items','totals'].map(type=>({id:type,type,enabled:true}))}]))}); + await db.collection('branches').updateOne({_id:branch._id},{$set:{receipt_designs:receiptDesign}}); + + const before=(await call('/mobile/v1/bootstrap')).data; + const body={id:crypto.randomUUID(),scope:{shopId:before.shop.id,branchId:String(branch._id),staffId:String(user._id)},product:{name:'New supermarket milk',barcode:'890newproduct',price:6500,cost:5000,mrp:7000,opening:12000,tracked:true,weighed:false,taxInclusive:true,available:true}}; + const created=await call('/mobile/v1/products',body);assert.equal(created.status,200,JSON.stringify(created.data)); + const after=(await call('/mobile/v1/bootstrap')).data; + assert.notEqual(after.shop.snapshotVersion,before.shop.snapshotVersion); + assert.ok(after.items.some(i=>i.id===created.data.item.id)); + const purchase=sale({snapshotVersion:after.shop.snapshotVersion,cart:{id:crypto.randomUUID(),lines:[{id:crypto.randomUUID(),itemId:created.data.item.id,name:body.product.name,quantity:1,price:6500,taxBps:0,taxInclusive:true,snapshotVersion:after.shop.snapshotVersion}]},total:6500,tax:0,payment:{method:'cash',received:6500,change:0}}); + const accepted=await call('/mobile/v1/sales',{idempotencyKey:purchase.id,sale:purchase});assert.equal(accepted.status,200,JSON.stringify(accepted.data)); + assert.equal((await call('/mobile/v1/products',body)).status,200); + assert.equal((await db.collection('items').findOne({_id:new ObjectId(created.data.item.id)})).available_quantity,11); + assert.equal(await db.collection('stocklogs').countDocuments({view_item_id:new ObjectId(created.data.item.id),process:'Mobile opening stock'}),1); + assert.deepEqual(after.shop.receiptProfile.data.receipt_designs,receiptDesign); + const print=await call('/mobile/v1/print-jobs',{id:'receipt:'+purchase.id,saleId:purchase.id,document:'receipt'}); + assert.equal(print.status,200,JSON.stringify(print.data)); + const job=await db.collection('printjobs').findOne({_id:new ObjectId(print.data.id)}); + assert.equal(job.payload.receiptDocument.receipt_settled,true); + assert.deepEqual(job.payload.receiptDocument.receipt_designs,receiptDesign); + assert.equal(job.payload.receiptDocument.payment_mode,'Cash'); + +}); diff --git a/api/tests/mobile-products.integration.cjs b/api/tests/mobile-products.integration.cjs new file mode 100644 index 000000000..fce1fd936 --- /dev/null +++ b/api/tests/mobile-products.integration.cjs @@ -0,0 +1,75 @@ + +'use strict'; +const {test,before,after,beforeEach}=require('node:test'); +const assert=require('node:assert/strict'); +const {MongoMemoryServer}=require('mongodb-memory-server'); +const {MongoClient,ObjectId}=require('mongodb'); +const products=require('../src/services/mobile-products'); +const mobile=require('../src/services/mobile-pos'); +let server,client,db,req,license,branch,user; +before(async()=>{server=await MongoMemoryServer.create();client=await MongoClient.connect(server.getUri());db=client.db('products');}); +after(async()=>{await client?.close();await server?.stop();}); +beforeEach(async()=>{ + await db.dropDatabase();license=new ObjectId();branch=new ObjectId();user=new ObjectId(); + await db.collection('branches').insertOne({_id:branch,license,module_mobile_pos_enable:true}); + req={db,user:{_id:user,usertype:'owner',username:'Owner'},tenantContext:{licenseId:String(license),branchId:String(branch)},body:{id:'product-test-00001',scope:{shopId:mobile.hash(String(license)).slice(0,24),branchId:String(branch),staffId:String(user)},product:{name:'Milk 1 L',barcode:'8900000000001',price:6500,cost:5000,mrp:7000,opening:12000,tracked:true,weighed:false,taxInclusive:true,available:true}}}; +}); +test('product and opening stock are created once across concurrent retries; later sales are not overwritten',async()=>{ + const [a,b]=await Promise.all([products.create(req),products.create(req)]);assert.equal(a.item.id,b.item.id); + const where={_id:new ObjectId(a.item.id)}; + assert.equal((await db.collection('items').findOne(where)).available_quantity,12); + await db.collection('items').updateOne(where,{$inc:{available_quantity:-2}}); + await products.create(req);assert.equal((await db.collection('items').findOne(where)).available_quantity,10); + assert.equal(await db.collection('items').countDocuments(),1);assert.equal(await db.collection('stocklogs').countDocuments(),1); + const log=await db.collection('stocklogs').findOne({});assert.equal(log.opening_balance,0);assert.equal(log.closing_balance,12);assert.equal(String(log.changed_by_userid),String(user)); +}); +test('interrupted creation recovers the opening audit without reapplying stock',async()=>{ + await assert.rejects(products.create(req,{afterInsert:()=>{throw Error('crash')}}),/crash/); + assert.equal(await db.collection('stocklogs').countDocuments(),0); + await products.create(req);assert.equal(await db.collection('stocklogs').countDocuments(),1); + assert.equal((await db.collection('items').findOne({})).available_quantity,12); +}); +test('hidden and alternate barcode matches prevent accidental recreation',async()=>{ + await db.collection('items').insertOne({_id:new ObjectId(),license,branch_id:branch,name:'Hidden milk',available_for_sale:false,barcode_id:'different',barcodes:['8900000000001']}); + await assert.rejects(products.create(req),{status:409});assert.equal(await db.collection('stocklogs').countDocuments(),0); +}); +test('changed replay, revoked permission and another branch are rejected',async()=>{ + await products.create(req);req.body.product.opening=20000;await assert.rejects(products.create(req),{status:409}); + req.body.product.opening=12000;req.body.scope.branchId=String(new ObjectId());await assert.rejects(products.create(req),{status:409}); + req.user={_id:user,usertype:'employee',access:{}};await assert.rejects(products.create(req),{status:403}); +}); +test('explicit zero and fractional opening are supported; whole units reject fractions',async()=>{ + req.body.product.opening=0;await products.create(req);assert.equal((await db.collection('stocklogs').findOne({})).closing_balance,0); + req.body.id='product-test-00002';req.body.product.barcode='new';req.body.product.opening=1250;await assert.rejects(products.create(req),{status:400}); + req.body.product.weighed=true;req.body.product.unit='kg';const result=await products.create(req);assert.equal(result.item.quantityScale,1000);assert.equal(result.item.quantity,1250); +}); +test('category/supplier/tax choices are scoped; invalid references cannot create an item',async()=>{ + const tax=new ObjectId();await db.collection('grouptax').insertOne({_id:tax,license,branch_id:branch,name:'VAT 5%',rate:5,tax_fields:[]}); + await db.collection('categories').insertOne({_id:new ObjectId(),license,branch_id:new ObjectId(),name:'Other branch'}); + const options=await products.options(req);assert.equal(options.categories.length,0);assert.equal(options.taxes[0].rate,5); + req.body.product.taxId=String(tax);const result=await products.create(req);assert.equal(result.item.taxBps,500); + req.body.id='product-test-00003';req.body.product.barcode='other';req.body.product.categoryId=String(new ObjectId());await assert.rejects(products.create(req),{status:409}); +}); +const receipt = require('../src/services/mobile-receipt-design'); +test('product lookup includes hidden primary/alternate matches without requiring inventory mode',async()=>{ + await db.collection('branches').updateOne({_id:branch},{$set:{mobile_inventory_enabled:false}}); + await db.collection('items').insertMany([ + {_id:new ObjectId(),license,branch_id:branch,name:'Hidden',barcode_id:'other',barcodes:['000123'],available_for_sale:false,selling_price:12}, + {_id:new ObjectId(),license,branch_id:branch,name:'Available',barcode_id:'000123',selling_price:15}, + {_id:new ObjectId(),license,branch_id:new ObjectId(),name:'Another branch',barcode_id:'000123'}, + ]); + req.query={barcode:'000123'};const result=await products.lookup(req); + assert.equal(result.rows.length,2);assert.equal(result.rows.filter(r=>!r.active).length,1); + assert.deepEqual(result.rows.map(r=>r.price).sort(),[1200,1500]); + req.user={_id:user,usertype:'employee',access:{}};await assert.rejects(products.lookup(req),{status:403}); +}); +test('receipt snapshot contains only printable branch data and reports unavailable assets',async()=>{ + const contract=require('../src/helpers/receipt-design'); + const design=contract.normalize({version:1,defaultFormat:"80",layouts:Object.fromEntries(Object.keys(contract.formats).map(k=>[k,{fontSize:12,blocks:["store","transaction","items","totals"].map(type=>({id:type,type,enabled:true}))}]))}); + const source={receipt_designs:design,branch_name:'Receipt shop',store_email:'receipt@example.com',secret:'must not leak'}; + const a=await receipt.profile(source);assert.equal(a.data.branch_name,'Receipt shop');assert.equal(a.data.secret,undefined);assert.equal(a.assetsComplete,true); + const b=await receipt.profile({...source,branch_name:'Changed'});assert.notEqual(a.version,b.version); + const missing=await receipt.profile({...source,logo:'https://untrusted.example/logo.png'});assert.equal(missing.assetsComplete,false);assert.equal(missing.data.logo,undefined); + assert.equal(await receipt.profile({branch_name:'Legacy'}),null); + assert.equal(await receipt.profile({receipt_designs:{broken:true}}),null); +}); diff --git a/docs/mobile-product-capture-receipts.md b/docs/mobile-product-capture-receipts.md new file mode 100644 index 000000000..6c7d0cc9a --- /dev/null +++ b/docs/mobile-product-capture-receipts.md @@ -0,0 +1,48 @@ +# Mobile product creation and shared receipts + +Product creation uses existing item read/write permissions and Mobile POS access. +The authenticated API supplies branch categories, suppliers, taxes and units. +Barcode lookup includes hidden primary/alternate matches; creation refuses an +existing barcode. No external product database is assumed. + +Opening stock is optional and written in the initial canonical item insert. +A request ID binds the full payload and staff/shop/branch scope. Replays return +the same product without resetting later stock movements. A deterministic +stocklogs entry is recovered after an interrupted save. Photo files use the +existing content-addressed image store. Mobile obtains a fresh signed catalogue +before the new item can enter a sale. Offline creation remains a local draft. + +Receipt bootstrap data contains normalized receipt designs, printable branch +fields and local embedded logos only. Malformed legacy designs do not prevent +sign-in. Missing external assets are reported. Mobile/till print jobs carry the +normal desktop receipt document and are explicitly marked settled, including the +actual payment method. Direct Android printing renders the cached 58/80 mm layout +into PDF/raster ESC/POS; transport submission is not paper confirmation. + +Sync and rollout +---------------- +- items and stocklogs already use branch-scoped POS/Gateway/agent lanes. +- branches already carry receipt_designs through the shared configuration lane. +- Product photos and branch logos use the file lane, separately from documents. + The matching Gateway/agent patch rotates bounded pages rather than permanently + ignoring products after the first 2,000 or files after the first 200. +- Product request metadata belongs to the canonical item; repeating the same + opening write never increments stock. No payment/print commands are replicated. +- Phone drafts are local and scoped to server/shop/branch/staff. Users explicitly + review and submit them. Wi-Fi/Internet changes remain manual. +- Deploy the API and Gateway, publish the matching agent update, then release + Mobile beta.30. Local community servers need the updated POS API too. + +Local checks +------------ +Mobile: 131 unit tests, 69 browser tests, TypeScript and formatting checks. +API: product concurrency, crash recovery, alternate/hidden duplicates, scope, +permissions, decimal/zero opening, references, receipt-profile validation and an +HTTP create -> fresh catalogue -> sell -> retry -> print-job integration test. +Existing mobile inventory tests pass (20). Five full-application browser cases +in mobile-pos.integration.cjs require MOBILE_FULL_APP and were not run by the +ordinary API suite; the separate mobile browser suite is not a substitute. +Gateway: real temporary MongoDB and local HTTP/file transfers exercise large +catalogues, alternate images, cloud/till/other-till transfer and tenant refusal. +Physical Bluetooth/USB printers and iPhone system printing require device tests. +No customer data was used or changed by these tests. From 5d109446177d82bfe7603084693287beffd128bd Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Sun, 18 Oct 2026 19:41:11 +0530 Subject: [PATCH 2/4] Document product endpoints and satisfy API lint checks --- api/src/routes/mobile-pos.routes.js | 22 +- api/src/services/mobile-products.js | 281 +++++++++++++++++----- api/src/services/mobile-receipt-design.js | 33 ++- api/tests/mobile-pos.integration.cjs | 114 +++++++-- api/tests/mobile-products.integration.cjs | 268 ++++++++++++++++----- docs/API.md | 9 +- docs/openapi.json | 70 ++++++ 7 files changed, 633 insertions(+), 164 deletions(-) diff --git a/api/src/routes/mobile-pos.routes.js b/api/src/routes/mobile-pos.routes.js index 57005b539..e214f3d04 100644 --- a/api/src/routes/mobile-pos.routes.js +++ b/api/src/routes/mobile-pos.routes.js @@ -386,7 +386,10 @@ router.post( payload = require('../helpers/bill-payload').buildBillPayload(sale, c.branch); payload.payments = [ { - label: intent.sale.payment.method === 'upi' ? 'UPI (staff confirmed)' : sale.payment_mode || intent.sale.payment.method, + label: + intent.sale.payment.method === 'upi' + ? 'UPI (staff confirmed)' + : sale.payment_mode || intent.sale.payment.method, amount: intent.sale.payment.method === 'cash' ? intent.sale.payment.received / 100 @@ -395,12 +398,17 @@ router.post( ]; if (intent.sale.payment.method === 'cash') payload.change = intent.sale.payment.change / 100; payload.title = 'RECEIPT'; - if (payload.receiptDocument) Object.assign(payload.receiptDocument, { - receipt_settled: true, - payment_mode: sale.payment_mode || intent.sale.payment.method, - received_amount: intent.sale.payment.method === 'cash' ? intent.sale.payment.received / 100 : intent.sale.total / 100, - change_amount: intent.sale.payment.method === 'cash' ? intent.sale.payment.change / 100 : 0, - }); + if (payload.receiptDocument) + Object.assign(payload.receiptDocument, { + receipt_settled: true, + payment_mode: sale.payment_mode || intent.sale.payment.method, + received_amount: + intent.sale.payment.method === 'cash' + ? intent.sale.payment.received / 100 + : intent.sale.total / 100, + change_amount: + intent.sale.payment.method === 'cash' ? intent.sale.payment.change / 100 : 0, + }); } else payload = { storeName: c.branch.branch_name || 'Posnic', diff --git a/api/src/services/mobile-products.js b/api/src/services/mobile-products.js index 97949bdec..594579e68 100644 --- a/api/src/services/mobile-products.js +++ b/api/src/services/mobile-products.js @@ -6,7 +6,11 @@ const barcodes = require('../utils/item-barcodes'); const { withBranchScope } = require('./branch-scope'); const text = (value, max = 160) => { if (value == null) return ''; - if (typeof value !== 'string' || value.length > max || /[\x00-\x1f]/.test(value)) + if ( + typeof value !== 'string' || + value.length > max || + [...value].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) + ) mobile.fail('Invalid product details.', 400); return value.trim(); }; @@ -16,123 +20,276 @@ async function access(req) { mobile.fail('Item management permission is required.', 403); return c; } -const referenceScope = (c) => ({ ...withBranchScope({ license: c.license }, c.branchId), is_deleted: { $ne: true }, del_status: { $nin: [1, '1', true] } }); +const referenceScope = (c) => ({ + ...withBranchScope({ license: c.license }, c.branchId), + is_deleted: { $ne: true }, + del_status: { $nin: [1, '1', true] }, +}); async function options(req) { const c = await access(req); const q = text(req.query?.q, 100); const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const filter = referenceScope(c); - if (q) filter.$or = ['name', 'category_name', 'supplier_name', 'unit_name'].map(key => ({ [key]: { $regex: escaped, $options: 'i' } })); - const rows = async (collection) => req.db.collection(collection).find(filter).sort({ _id: 1 }).limit(1001).toArray(); + if (q) + filter.$or = ['name', 'category_name', 'supplier_name', 'unit_name'].map((key) => ({ + [key]: { $regex: escaped, $options: 'i' }, + })); + const rows = async (collection) => + req.db.collection(collection).find(filter).sort({ _id: 1 }).limit(1001).toArray(); const [categories, suppliers, taxes, units] = await Promise.all([ - rows('categories'), mobile.allowed(req.user, 'supplier', 'read') ? rows('suppliers') : [], rows('grouptax'), rows('unit'), + rows('categories'), + mobile.allowed(req.user, 'supplier', 'read') ? rows('suppliers') : [], + rows('grouptax'), + rows('unit'), ]); - const map = (list) => list.slice(0, 1000).map(r => ({ id: String(r._id), name: String(r.name || r.category_name || r.supplier_name || r.unit_name || '') })); + const map = (list) => + list.slice(0, 1000).map((r) => ({ + id: String(r._id), + name: String(r.name || r.category_name || r.supplier_name || r.unit_name || ''), + })); return { - shopId: c.shopId, branchId: String(c.branchId), staffId: String(c.userId), - categories: map(categories), suppliers: map(suppliers), units: map(units), - taxes: taxes.slice(0, 1000).map(r => ({ id: String(r._id), name: String(r.name || 'Tax'), rate: Number(r.rate ?? r.tax_value ?? 0) })), + shopId: c.shopId, + branchId: String(c.branchId), + staffId: String(c.userId), + categories: map(categories), + suppliers: map(suppliers), + units: map(units), + taxes: taxes.slice(0, 1000).map((r) => ({ + id: String(r._id), + name: String(r.name || 'Tax'), + rate: Number(r.rate ?? r.tax_value ?? 0), + })), trackStock: c.branch.item_stock_default === 'track_quantities', - truncated: [categories, suppliers, taxes, units].some(rows => rows.length > 1000), + truncated: [categories, suppliers, taxes, units].some((rows) => rows.length > 1000), }; } async function lookup(req) { const c = await access(req); const barcode = text(req.query?.barcode, 128); if (!barcode) mobile.fail('Scan or enter a barcode.', 400); - const rows = await req.db.collection('items').find({ - ...referenceScope(c), $or: [{ barcode_id: barcode }, { barcodes: barcode }], - }).sort({ _id: 1 }).limit(201).toArray(); - return { shopId: c.shopId, branchId: String(c.branchId), staffId: String(c.userId), - rows: rows.slice(0, 200).map(inventory.item), truncated: rows.length > 200 }; + const rows = await req.db + .collection('items') + .find({ + ...referenceScope(c), + $or: [{ barcode_id: barcode }, { barcodes: barcode }], + }) + .sort({ _id: 1 }) + .limit(201) + .toArray(); + return { + shopId: c.shopId, + branchId: String(c.branchId), + staffId: String(c.userId), + rows: rows.slice(0, 200).map(inventory.item), + truncated: rows.length > 200, + }; } async function create(req, deps = {}) { - const c = await access(req), b = req.body || {}; - if (b.scope?.shopId !== c.shopId || b.scope?.branchId !== String(c.branchId) || b.scope?.staffId !== String(c.userId)) + const c = await access(req), + b = req.body || {}; + if ( + b.scope?.shopId !== c.shopId || + b.scope?.branchId !== String(c.branchId) || + b.scope?.staffId !== String(c.userId) + ) mobile.fail('Product belongs to another shop or user.', 409); if (!/^[a-zA-Z0-9_-]{12,80}$/.test(b.id || '')) mobile.fail('Invalid request identity.', 400); const p = b.product || {}; const product = { - name: text(p.name), barcode: text(p.barcode, 128), sku: text(p.sku, 80), - brand: text(p.brand, 100), unit: text(p.unit, 30) || 'piece', categoryId: text(p.categoryId, 24), - supplierId: text(p.supplierId, 24), taxId: text(p.taxId, 24), - price: p.price, cost: p.cost ?? 0, mrp: p.mrp ?? 0, opening: p.opening ?? null, - tracked: p.tracked, weighed: p.weighed, taxInclusive: p.taxInclusive, - available: p.available, image: p.image || '', + name: text(p.name), + barcode: text(p.barcode, 128), + sku: text(p.sku, 80), + brand: text(p.brand, 100), + unit: text(p.unit, 30) || 'piece', + categoryId: text(p.categoryId, 24), + supplierId: text(p.supplierId, 24), + taxId: text(p.taxId, 24), + price: p.price, + cost: p.cost ?? 0, + mrp: p.mrp ?? 0, + opening: p.opening ?? null, + tracked: p.tracked, + weighed: p.weighed, + taxInclusive: p.taxInclusive, + available: p.available, + image: p.image || '', }; - if (!product.name || ['price', 'cost', 'mrp'].some(k => !Number.isSafeInteger(product[k]) || product[k] < 0 || product[k] > 99999999999) || - ['tracked', 'weighed', 'taxInclusive', 'available'].some(k => typeof product[k] !== 'boolean') || - (product.opening !== null && (!product.tracked || !Number.isSafeInteger(product.opening) || product.opening < 0 || product.opening > 999999999999 || (!product.weighed && product.opening % 1000)))) + if ( + !product.name || + ['price', 'cost', 'mrp'].some( + (k) => !Number.isSafeInteger(product[k]) || product[k] < 0 || product[k] > 99999999999 + ) || + ['tracked', 'weighed', 'taxInclusive', 'available'].some( + (k) => typeof product[k] !== 'boolean' + ) || + (product.opening !== null && + (!product.tracked || + !Number.isSafeInteger(product.opening) || + product.opening < 0 || + product.opening > 999999999999 || + (!product.weighed && product.opening % 1000))) + ) mobile.fail('Review the product price, unit and opening stock.', 400); - if (typeof product.image !== 'string' || product.image.length > 1400000) mobile.fail('Use a product photo under 1 MB.', 400); + if (typeof product.image !== 'string' || product.image.length > 1400000) + mobile.fail('Use a product photo under 1 MB.', 400); const fingerprint = mobile.hash(mobile.canonical(product)); - const itemId = new ObjectId(mobile.hash([c.shopId, c.branchId, c.userId, b.id].join(':')).slice(0, 24)); + const itemId = new ObjectId( + mobile.hash([c.shopId, c.branchId, c.userId, b.id].join(':')).slice(0, 24) + ); const collection = req.db.collection('items'); return barcodes.withWriteLock({ licenseId: c.license }, async () => { - let saved = await collection.findOne({ _id: itemId, license: c.license, branch_id: c.branchId }); - if (saved && saved.mobile_creation?.fingerprint !== fingerprint) mobile.fail('This save was already used for different product details.', 409); + let saved = await collection.findOne({ + _id: itemId, + license: c.license, + branch_id: c.branchId, + }); + if (saved && saved.mobile_creation?.fingerprint !== fingerprint) + mobile.fail('This save was already used for different product details.', 409); if (!saved) { - const clash = await barcodes.findConflict(collection, [product.barcode].filter(Boolean), { branchId: c.branchId, licenseId: c.license }); + const clash = await barcodes.findConflict(collection, [product.barcode].filter(Boolean), { + branchId: c.branchId, + licenseId: c.license, + }); if (clash) mobile.fail(clash.message, 409); const reference = async (kind, id) => { if (!id) return null; if (!/^[a-f0-9]{24}$/i.test(id)) mobile.fail('Invalid product reference.', 400); - const row = await req.db.collection(kind).findOne({ ...referenceScope(c), _id: new ObjectId(id) }); - if (!row) mobile.fail('Refresh the product choices; a selected record is no longer available.', 409); + const row = await req.db + .collection(kind) + .findOne({ ...referenceScope(c), _id: new ObjectId(id) }); + if (!row) + mobile.fail( + 'Refresh the product choices; a selected record is no longer available.', + 409 + ); return row; }; - if (product.supplierId && !mobile.allowed(req.user, 'supplier', 'read')) mobile.fail('Supplier access is required.', 403); - const [category, supplier, tax] = await Promise.all([reference('categories', product.categoryId), reference('suppliers', product.supplierId), reference('grouptax', product.taxId)]); + if (product.supplierId && !mobile.allowed(req.user, 'supplier', 'read')) + mobile.fail('Supplier access is required.', 403); + const [category, supplier, tax] = await Promise.all([ + reference('categories', product.categoryId), + reference('suppliers', product.supplierId), + reference('grouptax', product.taxId), + ]); const rate = Number(tax?.rate ?? tax?.tax_value ?? 0); - if (!Number.isFinite(rate) || rate < 0 || rate > 100) mobile.fail('Unsupported tax rate.', 400); + if (!Number.isFinite(rate) || rate < 0 || rate > 100) + mobile.fail('Unsupported tax rate.', 400); let image = ''; if (product.image) { const { bytes, type } = require('./paper-order').image(product.image); const store = require('../utils/image-store'); - const key = store.keyFor({ tenantId: req.tenant?._id || req.tenant?.id || req.tenantId || null, kind: 'items', buffer: bytes, mimeType: type }); + const key = store.keyFor({ + tenantId: req.tenant?._id || req.tenant?.id || req.tenantId || null, + kind: 'items', + buffer: bytes, + mimeType: type, + }); await store.saveLocal(key, bytes); image = store.urlFor(key); } const at = new Date(); const actor = String(req.user.username || req.user.name || c.userId); const row = { - _id: itemId, license: c.license, branch_id: c.branchId, + _id: itemId, + license: c.license, + branch_id: c.branchId, branch_access: [{ branch_id: c.branchId, branch_name: c.branch.branch_name || '' }], - name: product.name, itemid: product.sku || 'M-' + String(itemId).slice(-12), sku: product.sku, - barcode_id: product.barcode, barcodes: [], brand: product.brand, unit: product.unit, - category_id: category?._id || '', category_name: category?.name || category?.category_name || '', - supplier_id: supplier?._id || '', supplier_name: supplier?.name || supplier?.supplier_name || '', - tax_id: tax?._id || '', tax_name: tax?.name || '', tax: rate, tax_fields: tax?.tax_fields || [], tax_type: product.taxInclusive ? 'inclusive' : 'exclusive', - selling_price: product.price / 100, company_price: product.cost / 100, mrp_price: product.mrp / 100, - track_inventory: product.tracked, available_quantity: (product.opening ?? 0) / 1000, - item_weight_machine_based: product.weighed, available_for_sale: product.available, - isAvailable: true, item_status: 'regular', image, discount_amount: 0, discount_percentage: 0, - created_date: at, updated_date: at, createdAt: at, updatedAt: at, - created_by: actor, updated_by: actor, created_by_id: c.userId, updated_by_id: c.userId, + name: product.name, + itemid: product.sku || 'M-' + String(itemId).slice(-12), + sku: product.sku, + barcode_id: product.barcode, + barcodes: [], + brand: product.brand, + unit: product.unit, + category_id: category?._id || '', + category_name: category?.name || category?.category_name || '', + supplier_id: supplier?._id || '', + supplier_name: supplier?.name || supplier?.supplier_name || '', + tax_id: tax?._id || '', + tax_name: tax?.name || '', + tax: rate, + tax_fields: tax?.tax_fields || [], + tax_type: product.taxInclusive ? 'inclusive' : 'exclusive', + selling_price: product.price / 100, + company_price: product.cost / 100, + mrp_price: product.mrp / 100, + track_inventory: product.tracked, + available_quantity: (product.opening ?? 0) / 1000, + item_weight_machine_based: product.weighed, + available_for_sale: product.available, + isAvailable: true, + item_status: 'regular', + image, + discount_amount: 0, + discount_percentage: 0, + created_date: at, + updated_date: at, + createdAt: at, + updatedAt: at, + created_by: actor, + updated_by: actor, + created_by_id: c.userId, + updated_by_id: c.userId, mobile_creation: { id: b.id, fingerprint, actor: c.userId, at, opening: product.opening }, }; - try { await collection.insertOne(row); } catch (e) { if (e.code !== 11000) throw e; } + try { + await collection.insertOne(row); + } catch (e) { + if (e.code !== 11000) throw e; + } saved = await collection.findOne({ _id: itemId, license: c.license, branch_id: c.branchId }); - if (saved?.mobile_creation?.fingerprint !== fingerprint) mobile.fail('Product save identity conflict.', 409); + if (saved?.mobile_creation?.fingerprint !== fingerprint) + mobile.fail('Product save identity conflict.', 409); } if (deps.afterInsert) await deps.afterInsert(); // The initial balance is part of the item insert, never an increment on retry. // Recover its audit record even if the first response was lost after insertion. if (saved.mobile_creation.opening !== null) { const logId = new ObjectId(mobile.hash('opening:' + itemId).slice(0, 24)); - await req.db.collection('stocklogs').updateOne({ _id: logId }, { $setOnInsert: { - license: c.license, branch_id: c.branchId, view_item_id: itemId, item_name: saved.name, - item_barcode_id: saved.barcode_id, process: 'Mobile opening stock', reference: b.id, - action: 'Add', stocklog: true, opening_balance: 0, closing_balance: saved.mobile_creation.opening / 1000, - count: String(saved.mobile_creation.opening / 1000), date: saved.created_date, - created_date: saved.created_date, updated_date: saved.created_date, - changed_by: saved.created_by, changed_by_userid: c.userId, - } }, { upsert: true }); - require('../sync/outbox').enqueue({ collection: 'stocklogs', documentId: logId, reason: 'inventory' }); + await req.db.collection('stocklogs').updateOne( + { _id: logId }, + { + $setOnInsert: { + license: c.license, + branch_id: c.branchId, + view_item_id: itemId, + item_name: saved.name, + item_barcode_id: saved.barcode_id, + process: 'Mobile opening stock', + reference: b.id, + action: 'Add', + stocklog: true, + opening_balance: 0, + closing_balance: saved.mobile_creation.opening / 1000, + count: String(saved.mobile_creation.opening / 1000), + date: saved.created_date, + created_date: saved.created_date, + updated_date: saved.created_date, + changed_by: saved.created_by, + changed_by_userid: c.userId, + }, + }, + { upsert: true } + ); + require('../sync/outbox').enqueue({ + collection: 'stocklogs', + documentId: logId, + reason: 'inventory', + }); } - require('../sync/outbox').enqueue({ collection: 'items', documentId: itemId, reason: 'inventory' }); + require('../sync/outbox').enqueue({ + collection: 'items', + documentId: itemId, + reason: 'inventory', + }); require('../sync/nudge').nudgeSyncAgent(); - return { shopId: c.shopId, branchId: String(c.branchId), staffId: String(c.userId), item: inventory.item(saved), confirmedAt: new Date().toISOString() }; + return { + shopId: c.shopId, + branchId: String(c.branchId), + staffId: String(c.userId), + item: inventory.item(saved), + confirmedAt: new Date().toISOString(), + }; }); } module.exports = { options, lookup, create }; diff --git a/api/src/services/mobile-receipt-design.js b/api/src/services/mobile-receipt-design.js index 8ba7a8a97..35b332475 100644 --- a/api/src/services/mobile-receipt-design.js +++ b/api/src/services/mobile-receipt-design.js @@ -1,14 +1,31 @@ - 'use strict'; const mobile = require('./mobile-pos'); const design = require('../helpers/receipt-design'); -const fields = ['branch_name','printing_address','store_telephone','store_email','branch_gstin_number','branch_fssai_number','country','table_options','website','invoice_terms','quote_default_signature']; +const fields = [ + 'branch_name', + 'printing_address', + 'store_telephone', + 'store_email', + 'branch_gstin_number', + 'branch_fssai_number', + 'country', + 'table_options', + 'website', + 'invoice_terms', + 'quote_default_signature', +]; async function profile(branch) { if (!branch.receipt_designs) return null; let receiptDesign; - try { receiptDesign = design.normalize(branch.receipt_designs); } catch { return null; } + try { + receiptDesign = design.normalize(branch.receipt_designs); + } catch { + return null; + } const data = {}; - for (const key of fields) if (typeof branch[key] === 'string' || typeof branch[key] === 'boolean') data[key] = branch[key]; + for (const key of fields) + if (typeof branch[key] === 'string' || typeof branch[key] === 'boolean') + data[key] = branch[key]; let assetsComplete = true; if (branch.logo && branch.logo !== 'store.png') { if (design.image(branch.logo)) data.logo = branch.logo; @@ -16,14 +33,18 @@ async function profile(branch) { const store = require('../utils/image-store'); const resolved = store.resolve(branch.logo); const key = resolved?.startsWith('/uploads/') ? resolved.slice(9) : ''; - const path = store.localPathFor(key) || (store.isLegacyFile(key) ? require("path").join(store.UPLOAD_DIR, key) : null); + const path = + store.localPathFor(key) || + (store.isLegacyFile(key) ? require('path').join(store.UPLOAD_DIR, key) : null); if (path) { try { const bytes = await require('fs/promises').readFile(path); if (bytes.length > 300000) throw Error('logo too large'); const type = /\.png$/.test(key) ? 'png' : /\.webp$/.test(key) ? 'webp' : 'jpeg'; data.logo = 'data:image/' + type + ';base64,' + bytes.toString('base64'); - } catch { assetsComplete = false; } + } catch { + assetsComplete = false; + } } else assetsComplete = false; } } diff --git a/api/tests/mobile-pos.integration.cjs b/api/tests/mobile-pos.integration.cjs index 5258fdf7a..b6a4afdc9 100644 --- a/api/tests/mobile-pos.integration.cjs +++ b/api/tests/mobile-pos.integration.cjs @@ -1312,27 +1312,97 @@ test('public mobile discovery exposes a shop name without credentials or interna }); test('new mobile product gets a signed sale catalogue; opening stock and sale each apply once', async () => { - const receiptContract=require('../src/helpers/receipt-design'); - const receiptDesign=receiptContract.normalize({version:1,defaultFormat:'80',layouts:Object.fromEntries(Object.keys(receiptContract.formats).map(k=>[k,{fontSize:12,blocks:['store','transaction','items','totals'].map(type=>({id:type,type,enabled:true}))}]))}); - await db.collection('branches').updateOne({_id:branch._id},{$set:{receipt_designs:receiptDesign}}); - - const before=(await call('/mobile/v1/bootstrap')).data; - const body={id:crypto.randomUUID(),scope:{shopId:before.shop.id,branchId:String(branch._id),staffId:String(user._id)},product:{name:'New supermarket milk',barcode:'890newproduct',price:6500,cost:5000,mrp:7000,opening:12000,tracked:true,weighed:false,taxInclusive:true,available:true}}; - const created=await call('/mobile/v1/products',body);assert.equal(created.status,200,JSON.stringify(created.data)); - const after=(await call('/mobile/v1/bootstrap')).data; - assert.notEqual(after.shop.snapshotVersion,before.shop.snapshotVersion); - assert.ok(after.items.some(i=>i.id===created.data.item.id)); - const purchase=sale({snapshotVersion:after.shop.snapshotVersion,cart:{id:crypto.randomUUID(),lines:[{id:crypto.randomUUID(),itemId:created.data.item.id,name:body.product.name,quantity:1,price:6500,taxBps:0,taxInclusive:true,snapshotVersion:after.shop.snapshotVersion}]},total:6500,tax:0,payment:{method:'cash',received:6500,change:0}}); - const accepted=await call('/mobile/v1/sales',{idempotencyKey:purchase.id,sale:purchase});assert.equal(accepted.status,200,JSON.stringify(accepted.data)); - assert.equal((await call('/mobile/v1/products',body)).status,200); - assert.equal((await db.collection('items').findOne({_id:new ObjectId(created.data.item.id)})).available_quantity,11); - assert.equal(await db.collection('stocklogs').countDocuments({view_item_id:new ObjectId(created.data.item.id),process:'Mobile opening stock'}),1); - assert.deepEqual(after.shop.receiptProfile.data.receipt_designs,receiptDesign); - const print=await call('/mobile/v1/print-jobs',{id:'receipt:'+purchase.id,saleId:purchase.id,document:'receipt'}); - assert.equal(print.status,200,JSON.stringify(print.data)); - const job=await db.collection('printjobs').findOne({_id:new ObjectId(print.data.id)}); - assert.equal(job.payload.receiptDocument.receipt_settled,true); - assert.deepEqual(job.payload.receiptDocument.receipt_designs,receiptDesign); - assert.equal(job.payload.receiptDocument.payment_mode,'Cash'); + const receiptContract = require('../src/helpers/receipt-design'); + const receiptDesign = receiptContract.normalize({ + version: 1, + defaultFormat: '80', + layouts: Object.fromEntries( + Object.keys(receiptContract.formats).map((k) => [ + k, + { + fontSize: 12, + blocks: ['store', 'transaction', 'items', 'totals'].map((type) => ({ + id: type, + type, + enabled: true, + })), + }, + ]) + ), + }); + await db + .collection('branches') + .updateOne({ _id: branch._id }, { $set: { receipt_designs: receiptDesign } }); + const before = (await call('/mobile/v1/bootstrap')).data; + const body = { + id: crypto.randomUUID(), + scope: { shopId: before.shop.id, branchId: String(branch._id), staffId: String(user._id) }, + product: { + name: 'New supermarket milk', + barcode: '890newproduct', + price: 6500, + cost: 5000, + mrp: 7000, + opening: 12000, + tracked: true, + weighed: false, + taxInclusive: true, + available: true, + }, + }; + const created = await call('/mobile/v1/products', body); + assert.equal(created.status, 200, JSON.stringify(created.data)); + const after = (await call('/mobile/v1/bootstrap')).data; + assert.notEqual(after.shop.snapshotVersion, before.shop.snapshotVersion); + assert.ok(after.items.some((i) => i.id === created.data.item.id)); + const purchase = sale({ + snapshotVersion: after.shop.snapshotVersion, + cart: { + id: crypto.randomUUID(), + lines: [ + { + id: crypto.randomUUID(), + itemId: created.data.item.id, + name: body.product.name, + quantity: 1, + price: 6500, + taxBps: 0, + taxInclusive: true, + snapshotVersion: after.shop.snapshotVersion, + }, + ], + }, + total: 6500, + tax: 0, + payment: { method: 'cash', received: 6500, change: 0 }, + }); + const accepted = await call('/mobile/v1/sales', { idempotencyKey: purchase.id, sale: purchase }); + assert.equal(accepted.status, 200, JSON.stringify(accepted.data)); + assert.equal((await call('/mobile/v1/products', body)).status, 200); + assert.equal( + (await db.collection('items').findOne({ _id: new ObjectId(created.data.item.id) })) + .available_quantity, + 11 + ); + assert.equal( + await db + .collection('stocklogs') + .countDocuments({ + view_item_id: new ObjectId(created.data.item.id), + process: 'Mobile opening stock', + }), + 1 + ); + assert.deepEqual(after.shop.receiptProfile.data.receipt_designs, receiptDesign); + const print = await call('/mobile/v1/print-jobs', { + id: 'receipt:' + purchase.id, + saleId: purchase.id, + document: 'receipt', + }); + assert.equal(print.status, 200, JSON.stringify(print.data)); + const job = await db.collection('printjobs').findOne({ _id: new ObjectId(print.data.id) }); + assert.equal(job.payload.receiptDocument.receipt_settled, true); + assert.deepEqual(job.payload.receiptDocument.receipt_designs, receiptDesign); + assert.equal(job.payload.receiptDocument.payment_mode, 'Cash'); }); diff --git a/api/tests/mobile-products.integration.cjs b/api/tests/mobile-products.integration.cjs index fce1fd936..7435bf3d7 100644 --- a/api/tests/mobile-products.integration.cjs +++ b/api/tests/mobile-products.integration.cjs @@ -1,75 +1,215 @@ - 'use strict'; -const {test,before,after,beforeEach}=require('node:test'); -const assert=require('node:assert/strict'); -const {MongoMemoryServer}=require('mongodb-memory-server'); -const {MongoClient,ObjectId}=require('mongodb'); -const products=require('../src/services/mobile-products'); -const mobile=require('../src/services/mobile-pos'); -let server,client,db,req,license,branch,user; -before(async()=>{server=await MongoMemoryServer.create();client=await MongoClient.connect(server.getUri());db=client.db('products');}); -after(async()=>{await client?.close();await server?.stop();}); -beforeEach(async()=>{ - await db.dropDatabase();license=new ObjectId();branch=new ObjectId();user=new ObjectId(); - await db.collection('branches').insertOne({_id:branch,license,module_mobile_pos_enable:true}); - req={db,user:{_id:user,usertype:'owner',username:'Owner'},tenantContext:{licenseId:String(license),branchId:String(branch)},body:{id:'product-test-00001',scope:{shopId:mobile.hash(String(license)).slice(0,24),branchId:String(branch),staffId:String(user)},product:{name:'Milk 1 L',barcode:'8900000000001',price:6500,cost:5000,mrp:7000,opening:12000,tracked:true,weighed:false,taxInclusive:true,available:true}}}; +const { test, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { MongoClient, ObjectId } = require('mongodb'); +const products = require('../src/services/mobile-products'); +const mobile = require('../src/services/mobile-pos'); +let server, client, db, req, license, branch, user; +before(async () => { + server = await MongoMemoryServer.create(); + client = await MongoClient.connect(server.getUri()); + db = client.db('products'); }); -test('product and opening stock are created once across concurrent retries; later sales are not overwritten',async()=>{ - const [a,b]=await Promise.all([products.create(req),products.create(req)]);assert.equal(a.item.id,b.item.id); - const where={_id:new ObjectId(a.item.id)}; - assert.equal((await db.collection('items').findOne(where)).available_quantity,12); - await db.collection('items').updateOne(where,{$inc:{available_quantity:-2}}); - await products.create(req);assert.equal((await db.collection('items').findOne(where)).available_quantity,10); - assert.equal(await db.collection('items').countDocuments(),1);assert.equal(await db.collection('stocklogs').countDocuments(),1); - const log=await db.collection('stocklogs').findOne({});assert.equal(log.opening_balance,0);assert.equal(log.closing_balance,12);assert.equal(String(log.changed_by_userid),String(user)); +after(async () => { + await client?.close(); + await server?.stop(); }); -test('interrupted creation recovers the opening audit without reapplying stock',async()=>{ - await assert.rejects(products.create(req,{afterInsert:()=>{throw Error('crash')}}),/crash/); - assert.equal(await db.collection('stocklogs').countDocuments(),0); - await products.create(req);assert.equal(await db.collection('stocklogs').countDocuments(),1); - assert.equal((await db.collection('items').findOne({})).available_quantity,12); +beforeEach(async () => { + await db.dropDatabase(); + license = new ObjectId(); + branch = new ObjectId(); + user = new ObjectId(); + await db + .collection('branches') + .insertOne({ _id: branch, license, module_mobile_pos_enable: true }); + req = { + db, + user: { _id: user, usertype: 'owner', username: 'Owner' }, + tenantContext: { licenseId: String(license), branchId: String(branch) }, + body: { + id: 'product-test-00001', + scope: { + shopId: mobile.hash(String(license)).slice(0, 24), + branchId: String(branch), + staffId: String(user), + }, + product: { + name: 'Milk 1 L', + barcode: '8900000000001', + price: 6500, + cost: 5000, + mrp: 7000, + opening: 12000, + tracked: true, + weighed: false, + taxInclusive: true, + available: true, + }, + }, + }; }); -test('hidden and alternate barcode matches prevent accidental recreation',async()=>{ - await db.collection('items').insertOne({_id:new ObjectId(),license,branch_id:branch,name:'Hidden milk',available_for_sale:false,barcode_id:'different',barcodes:['8900000000001']}); - await assert.rejects(products.create(req),{status:409});assert.equal(await db.collection('stocklogs').countDocuments(),0); +test('product and opening stock are created once across concurrent retries; later sales are not overwritten', async () => { + const [a, b] = await Promise.all([products.create(req), products.create(req)]); + assert.equal(a.item.id, b.item.id); + const where = { _id: new ObjectId(a.item.id) }; + assert.equal((await db.collection('items').findOne(where)).available_quantity, 12); + await db.collection('items').updateOne(where, { $inc: { available_quantity: -2 } }); + await products.create(req); + assert.equal((await db.collection('items').findOne(where)).available_quantity, 10); + assert.equal(await db.collection('items').countDocuments(), 1); + assert.equal(await db.collection('stocklogs').countDocuments(), 1); + const log = await db.collection('stocklogs').findOne({}); + assert.equal(log.opening_balance, 0); + assert.equal(log.closing_balance, 12); + assert.equal(String(log.changed_by_userid), String(user)); }); -test('changed replay, revoked permission and another branch are rejected',async()=>{ - await products.create(req);req.body.product.opening=20000;await assert.rejects(products.create(req),{status:409}); - req.body.product.opening=12000;req.body.scope.branchId=String(new ObjectId());await assert.rejects(products.create(req),{status:409}); - req.user={_id:user,usertype:'employee',access:{}};await assert.rejects(products.create(req),{status:403}); +test('interrupted creation recovers the opening audit without reapplying stock', async () => { + await assert.rejects( + products.create(req, { + afterInsert: () => { + throw Error('crash'); + }, + }), + /crash/ + ); + assert.equal(await db.collection('stocklogs').countDocuments(), 0); + await products.create(req); + assert.equal(await db.collection('stocklogs').countDocuments(), 1); + assert.equal((await db.collection('items').findOne({})).available_quantity, 12); }); -test('explicit zero and fractional opening are supported; whole units reject fractions',async()=>{ - req.body.product.opening=0;await products.create(req);assert.equal((await db.collection('stocklogs').findOne({})).closing_balance,0); - req.body.id='product-test-00002';req.body.product.barcode='new';req.body.product.opening=1250;await assert.rejects(products.create(req),{status:400}); - req.body.product.weighed=true;req.body.product.unit='kg';const result=await products.create(req);assert.equal(result.item.quantityScale,1000);assert.equal(result.item.quantity,1250); +test('hidden and alternate barcode matches prevent accidental recreation', async () => { + await db + .collection('items') + .insertOne({ + _id: new ObjectId(), + license, + branch_id: branch, + name: 'Hidden milk', + available_for_sale: false, + barcode_id: 'different', + barcodes: ['8900000000001'], + }); + await assert.rejects(products.create(req), { status: 409 }); + assert.equal(await db.collection('stocklogs').countDocuments(), 0); }); -test('category/supplier/tax choices are scoped; invalid references cannot create an item',async()=>{ - const tax=new ObjectId();await db.collection('grouptax').insertOne({_id:tax,license,branch_id:branch,name:'VAT 5%',rate:5,tax_fields:[]}); - await db.collection('categories').insertOne({_id:new ObjectId(),license,branch_id:new ObjectId(),name:'Other branch'}); - const options=await products.options(req);assert.equal(options.categories.length,0);assert.equal(options.taxes[0].rate,5); - req.body.product.taxId=String(tax);const result=await products.create(req);assert.equal(result.item.taxBps,500); - req.body.id='product-test-00003';req.body.product.barcode='other';req.body.product.categoryId=String(new ObjectId());await assert.rejects(products.create(req),{status:409}); +test('changed replay, revoked permission and another branch are rejected', async () => { + await products.create(req); + req.body.product.opening = 20000; + await assert.rejects(products.create(req), { status: 409 }); + req.body.product.opening = 12000; + req.body.scope.branchId = String(new ObjectId()); + await assert.rejects(products.create(req), { status: 409 }); + req.user = { _id: user, usertype: 'employee', access: {} }; + await assert.rejects(products.create(req), { status: 403 }); +}); +test('explicit zero and fractional opening are supported; whole units reject fractions', async () => { + req.body.product.opening = 0; + await products.create(req); + assert.equal((await db.collection('stocklogs').findOne({})).closing_balance, 0); + req.body.id = 'product-test-00002'; + req.body.product.barcode = 'new'; + req.body.product.opening = 1250; + await assert.rejects(products.create(req), { status: 400 }); + req.body.product.weighed = true; + req.body.product.unit = 'kg'; + const result = await products.create(req); + assert.equal(result.item.quantityScale, 1000); + assert.equal(result.item.quantity, 1250); +}); +test('category/supplier/tax choices are scoped; invalid references cannot create an item', async () => { + const tax = new ObjectId(); + await db + .collection('grouptax') + .insertOne({ _id: tax, license, branch_id: branch, name: 'VAT 5%', rate: 5, tax_fields: [] }); + await db + .collection('categories') + .insertOne({ _id: new ObjectId(), license, branch_id: new ObjectId(), name: 'Other branch' }); + const options = await products.options(req); + assert.equal(options.categories.length, 0); + assert.equal(options.taxes[0].rate, 5); + req.body.product.taxId = String(tax); + const result = await products.create(req); + assert.equal(result.item.taxBps, 500); + req.body.id = 'product-test-00003'; + req.body.product.barcode = 'other'; + req.body.product.categoryId = String(new ObjectId()); + await assert.rejects(products.create(req), { status: 409 }); }); const receipt = require('../src/services/mobile-receipt-design'); -test('product lookup includes hidden primary/alternate matches without requiring inventory mode',async()=>{ - await db.collection('branches').updateOne({_id:branch},{$set:{mobile_inventory_enabled:false}}); - await db.collection('items').insertMany([ - {_id:new ObjectId(),license,branch_id:branch,name:'Hidden',barcode_id:'other',barcodes:['000123'],available_for_sale:false,selling_price:12}, - {_id:new ObjectId(),license,branch_id:branch,name:'Available',barcode_id:'000123',selling_price:15}, - {_id:new ObjectId(),license,branch_id:new ObjectId(),name:'Another branch',barcode_id:'000123'}, - ]); - req.query={barcode:'000123'};const result=await products.lookup(req); - assert.equal(result.rows.length,2);assert.equal(result.rows.filter(r=>!r.active).length,1); - assert.deepEqual(result.rows.map(r=>r.price).sort(),[1200,1500]); - req.user={_id:user,usertype:'employee',access:{}};await assert.rejects(products.lookup(req),{status:403}); +test('product lookup includes hidden primary/alternate matches without requiring inventory mode', async () => { + await db + .collection('branches') + .updateOne({ _id: branch }, { $set: { mobile_inventory_enabled: false } }); + await db.collection('items').insertMany([ + { + _id: new ObjectId(), + license, + branch_id: branch, + name: 'Hidden', + barcode_id: 'other', + barcodes: ['000123'], + available_for_sale: false, + selling_price: 12, + }, + { + _id: new ObjectId(), + license, + branch_id: branch, + name: 'Available', + barcode_id: '000123', + selling_price: 15, + }, + { + _id: new ObjectId(), + license, + branch_id: new ObjectId(), + name: 'Another branch', + barcode_id: '000123', + }, + ]); + req.query = { barcode: '000123' }; + const result = await products.lookup(req); + assert.equal(result.rows.length, 2); + assert.equal(result.rows.filter((r) => !r.active).length, 1); + assert.deepEqual(result.rows.map((r) => r.price).sort(), [1200, 1500]); + req.user = { _id: user, usertype: 'employee', access: {} }; + await assert.rejects(products.lookup(req), { status: 403 }); }); -test('receipt snapshot contains only printable branch data and reports unavailable assets',async()=>{ - const contract=require('../src/helpers/receipt-design'); - const design=contract.normalize({version:1,defaultFormat:"80",layouts:Object.fromEntries(Object.keys(contract.formats).map(k=>[k,{fontSize:12,blocks:["store","transaction","items","totals"].map(type=>({id:type,type,enabled:true}))}]))}); - const source={receipt_designs:design,branch_name:'Receipt shop',store_email:'receipt@example.com',secret:'must not leak'}; - const a=await receipt.profile(source);assert.equal(a.data.branch_name,'Receipt shop');assert.equal(a.data.secret,undefined);assert.equal(a.assetsComplete,true); - const b=await receipt.profile({...source,branch_name:'Changed'});assert.notEqual(a.version,b.version); - const missing=await receipt.profile({...source,logo:'https://untrusted.example/logo.png'});assert.equal(missing.assetsComplete,false);assert.equal(missing.data.logo,undefined); - assert.equal(await receipt.profile({branch_name:'Legacy'}),null); - assert.equal(await receipt.profile({receipt_designs:{broken:true}}),null); +test('receipt snapshot contains only printable branch data and reports unavailable assets', async () => { + const contract = require('../src/helpers/receipt-design'); + const design = contract.normalize({ + version: 1, + defaultFormat: '80', + layouts: Object.fromEntries( + Object.keys(contract.formats).map((k) => [ + k, + { + fontSize: 12, + blocks: ['store', 'transaction', 'items', 'totals'].map((type) => ({ + id: type, + type, + enabled: true, + })), + }, + ]) + ), + }); + const source = { + receipt_designs: design, + branch_name: 'Receipt shop', + store_email: 'receipt@example.com', + secret: 'must not leak', + }; + const a = await receipt.profile(source); + assert.equal(a.data.branch_name, 'Receipt shop'); + assert.equal(a.data.secret, undefined); + assert.equal(a.assetsComplete, true); + const b = await receipt.profile({ ...source, branch_name: 'Changed' }); + assert.notEqual(a.version, b.version); + const missing = await receipt.profile({ ...source, logo: 'https://untrusted.example/logo.png' }); + assert.equal(missing.assetsComplete, false); + assert.equal(missing.data.logo, undefined); + assert.equal(await receipt.profile({ branch_name: 'Legacy' }), null); + assert.equal(await receipt.profile({ receipt_designs: { broken: true } }), null); }); diff --git a/docs/API.md b/docs/API.md index 0f8419ada..e856d6845 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3,7 +3,7 @@ Generated from `api/src/routes/` by `npm run docs:api`. Do not edit this file by hand; edit the routes and regenerate. -**900 endpoints across 53 route groups.** +**903 endpoints across 53 route groups.** All paths below are relative to the API root. On a desktop install the API runs on a port derived per installation (see `local-ports.js`); in the cloud it sits @@ -15,8 +15,8 @@ behind the tenant hostname. Postman, Insomnia or Bruno, point Swagger UI or Redoc at it, or generate a client from it. It is OpenAPI 3.0.3. -**104 of 900 endpoints carry a request schema** (12%), recovered from -their `express-validator` middleware. The remaining 796 are listed with +**104 of 903 endpoints carry a request schema** (12%), recovered from +their `express-validator` middleware. The remaining 799 are listed with path, method and handler but no body, because inventing a schema is worse than admitting the gap. @@ -736,6 +736,9 @@ Mounted at `/mobile/v1`. Source: `api/src/routes/mobile-pos.routes.js`. | GET | `/mobile/v1/discovery` | — | `Boolean` | | POST | `/mobile/v1/enrolment-proof` | — | `hex` | | POST | `/mobile/v1/pair` | — | `handsetLifetimeSeconds` | +| GET | `/mobile/v1/products/options` | — | `products` | +| GET | `/mobile/v1/products/lookup` | — | `products` | +| POST | `/mobile/v1/products` | — | `products` | | GET | `/mobile/v1/availability-status` | — | `availability` | | GET | `/mobile/v1/inventory/policy` | — | `inventory.policy` | | POST | `/mobile/v1/inventory/policy` | — | `inventory.policy` | diff --git a/docs/openapi.json b/docs/openapi.json index 25cec2eef..0b16b8fbb 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -13093,6 +13093,76 @@ "description": "Request body is not yet documented. This endpoint has no validation middleware, so its shape cannot be derived from the source. Adding validation improves both runtime safety and this document." } }, + "/mobile/v1/products/options": { + "get": { + "summary": "products", + "operationId": "get_mobile_v1_products_options", + "tags": [ + "Mobile pos" + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Validation failed" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Server error" + } + } + } + }, + "/mobile/v1/products/lookup": { + "get": { + "summary": "products", + "operationId": "get_mobile_v1_products_lookup", + "tags": [ + "Mobile pos" + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Validation failed" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Server error" + } + } + } + }, + "/mobile/v1/products": { + "post": { + "summary": "products", + "operationId": "post_mobile_v1_products", + "tags": [ + "Mobile pos" + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Validation failed" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Server error" + } + }, + "description": "Request body is not yet documented. This endpoint has no validation middleware, so its shape cannot be derived from the source. Adding validation improves both runtime safety and this document." + } + }, "/mobile/v1/availability-status": { "get": { "summary": "availability", From 551ce370061d010d98a80cdf2e04bd985dd049d0 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Sun, 18 Oct 2026 19:44:11 +0530 Subject: [PATCH 3/4] Verify full mobile browser flow with optional PIN and explicit routes --- api/tests/mobile-pos.integration.cjs | 25 ++++++++----------------- docs/mobile-product-capture-receipts.md | 7 ++++--- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/api/tests/mobile-pos.integration.cjs b/api/tests/mobile-pos.integration.cjs index b6a4afdc9..cbf9d478d 100644 --- a/api/tests/mobile-pos.integration.cjs +++ b/api/tests/mobile-pos.integration.cjs @@ -569,20 +569,9 @@ test( await page.getByRole('textbox', { name: 'Username', exact: true }).fill(user.username); await page.getByRole('textbox', { name: 'Password', exact: true }).fill('test-password'); await page.getByRole('button', { name: 'Sign in', exact: true }).click(); - try { - await page - .getByRole('textbox', { name: 'PIN', exact: true }) - .fill('4829', { timeout: 8000 }); - } catch (e) { - console.log('MOBILE UI:', await page.locator('body').innerText()); - await page.screenshot({ - path: path.resolve(__dirname, '../../tmp/mobile-pos-signin-error.png'), - }); - throw e; - } - await page.getByRole('textbox', { name: 'Confirm PIN', exact: true }).fill('4829'); - await page.getByRole('button', { name: 'Save', exact: true }).click(); const product = page.getByTestId('item-' + String(item._id)); + await expect(product).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'PIN', exact: true })).toHaveCount(0); await expect(product.locator('img')).toHaveAttribute('src', /^data:image\/png/); await page.getByTestId('item-' + String(item._id)).click(); const before = await db.collection('sales').countDocuments(); @@ -689,8 +678,8 @@ test('branch payments migrate legacy data and mobile settings cannot overwrite f for (const useLocal of [false, true]) test( - 'account-first mobile UI reaches PIN using ' + - (useLocal ? 'the authenticated LAN till' : 'the cloud shop'), + 'account-first mobile UI opens Sell without mandatory PIN ' + + (useLocal ? 'with a nearby LAN till advertised' : 'with only the cloud shop'), { skip: !process.env.MOBILE_PREVIEW_DIR || !process.env.MOBILE_PLAYWRIGHT_PATH }, async () => { const { chromium, expect } = require( @@ -773,11 +762,13 @@ for (const useLocal of [false, true]) await page.goto(base.replace(/\/api$/, '/')); await expect(page.getByTestId('server-input')).toHaveCount(0); await page.getByRole('button', { name: 'Sign in', exact: true }).click(); - await expect(page.getByRole('textbox', { name: 'PIN', exact: true })).toBeVisible({ + await expect(page.getByTestId('item-' + String(item._id))).toBeVisible({ timeout: 15000, }); + await expect(page.getByRole('textbox', { name: 'PIN', exact: true })).toHaveCount(0); assert.equal(request.intent, 'login'); - assert.equal(pairedAt, useLocal ? '192.168.50.4' : 'mobile-test.example'); + // Cloud sign-in retains its approved internet authority; LAN is an explicit later switch. + assert.equal(pairedAt, 'mobile-test.example'); } finally { await browser.close(); } diff --git a/docs/mobile-product-capture-receipts.md b/docs/mobile-product-capture-receipts.md index 6c7d0cc9a..a3421ee40 100644 --- a/docs/mobile-product-capture-receipts.md +++ b/docs/mobile-product-capture-receipts.md @@ -39,9 +39,10 @@ Mobile: 131 unit tests, 69 browser tests, TypeScript and formatting checks. API: product concurrency, crash recovery, alternate/hidden duplicates, scope, permissions, decimal/zero opening, references, receipt-profile validation and an HTTP create -> fresh catalogue -> sell -> retry -> print-job integration test. -Existing mobile inventory tests pass (20). Five full-application browser cases -in mobile-pos.integration.cjs require MOBILE_FULL_APP and were not run by the -ordinary API suite; the separate mobile browser suite is not a substitute. +Existing mobile inventory tests pass (20). All 28 mobile-pos integration cases +also pass with MOBILE_FULL_APP, the actual web build and Playwright enabled, +including real login, optional PIN, offline cash sale/reconnect and cloud authorization. +Eight focused product/receipt integration cases pass separately. Gateway: real temporary MongoDB and local HTTP/file transfers exercise large catalogues, alternate images, cloud/till/other-till transfer and tenant refusal. Physical Bluetooth/USB printers and iPhone system printing require device tests. From 79e4a062a983557a71f6676517d6556fdcd0eddc Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Sun, 18 Oct 2026 19:50:21 +0530 Subject: [PATCH 4/4] docs: update API endpoint count for mobile products --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e2bc1ce01..b5223280a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ before making a package-level licence statement. [![Latest release](https://img.shields.io/github/v/release/Posnic/POS?include_prereleases&label=latest&color=blue)](https://github.com/Posnic/POS/releases/latest) [![Tests](https://img.shields.io/badge/tests-9%2C000%2B%20passing-brightgreen)](docs/DEVELOPMENT.md#running-the-tests) [![Coverage](https://img.shields.io/badge/coverage-69%25%20statements-yellow)](docs/DEVELOPMENT.md#running-the-tests) -[![API](https://img.shields.io/badge/REST%20API-900%20endpoints-blue)](docs/API.md) +[![API](https://img.shields.io/badge/REST%20API-903%20endpoints-blue)](docs/API.md) [![Source licence](https://img.shields.io/badge/source%20licence-AGPL--3.0-blue)](LICENSE) [![Package notices](https://img.shields.io/badge/package%20notices-component%20licences-informational)](THIRD-PARTY-NOTICES.md) [![Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)](https://github.com/Posnic/POS/releases/latest) @@ -235,7 +235,7 @@ the free edition worse. This is written down in [GOVERNANCE.md](docs/GOVERNANCE. | [Contributor quickstart](docs/CONTRIBUTOR_QUICKSTART.md) | Local setup, test commands, issue map, PR flow and safety rules | | [Developer guide](docs/DEVELOPMENT.md) | Setup, tests, conventions, good first issues | | [Architecture](docs/ARCHITECTURE.md) | How it fits together, and the parts that bite | -| [REST API](docs/API.md) | 900 endpoints, generated from the routes | +| [REST API](docs/API.md) | 903 endpoints, generated from the routes | | [Hardware](docs/HARDWARE_MATRIX.md) | Printers, scanners, drawers, scales — and how far each claim is checked | | [India e-invoicing](docs/INDIA_EINVOICING_DESIGN.md) | Research, readiness inventory and design for GST e-invoicing as an optional feature; no live IRP submission is built | | [India GST return gaps](docs/INDIA_GST_RETURNS_GAPS.md) | What the GST reports compute today, measured against GSTR-1, 3B and 9 |