From ceb28fb96068c77539eb4527e08ed8f885fd3528 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Thu, 15 Oct 2026 22:33:51 +0530 Subject: [PATCH 1/8] Allow checkout to close after failed operation recovery --- .../static/script/js/modules/js/extensions.js | 2 +- tests/extension-recovery-close.test.cjs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 tests/extension-recovery-close.test.cjs diff --git a/frontend/static/script/js/modules/js/extensions.js b/frontend/static/script/js/modules/js/extensions.js index 0ebbc0f23..846cb523c 100644 --- a/frontend/static/script/js/modules/js/extensions.js +++ b/frontend/static/script/js/modules/js/extensions.js @@ -562,7 +562,7 @@ if (method === "recover") { commandBusy = true; if (embedded?.onBusy) embedded.onBusy(true); - return request(base + "/recover", {}).then(afterCommand).then(function(result){commandBusy=false;if(embedded?.onBusy) embedded.onBusy(false);return result;},function(error){if(embedded?.onBusy) embedded.onBusy(true);throw error;}); + return request(base + "/recover", {}).then(afterCommand).then(function(result){commandBusy=false;if(embedded?.onBusy) embedded.onBusy(false);return result;},function(error){commandBusy=false;if(embedded?.onBusy) embedded.onBusy(false);if(embedded?.onError) embedded.onError(error);throw error;}); } if (method === "receipt") return request(base + "/receipt", input).then( diff --git a/tests/extension-recovery-close.test.cjs b/tests/extension-recovery-close.test.cjs new file mode 100644 index 000000000..a02ab7a5e --- /dev/null +++ b/tests/extension-recovery-close.test.cjs @@ -0,0 +1,19 @@ +const test=require('node:test'); +const assert=require('node:assert/strict'); +const fs=require('node:fs'); +const vm=require('node:vm'); +const {JSDOM}=require('jsdom'); +test('failed recovery unlocks Close but retains the saved operation',async()=>{ + const dom=new JSDOM('
'); + let frame,rejectRequest,closed=false;const busy=[]; + const PosnicPro={local:{get:()=> 'branch'},request:(req,ok,fail)=>{if(req.url.endsWith('/view'))ok({displayName:'Basket',view:{}});else if(req.url.endsWith('/recover'))rejectRequest=fail;else throw Error(req.url);}}; + vm.runInNewContext(fs.readFileSync(require.resolve('../frontend/static/script/js/modules/js/extensions.js'),'utf8'),{PosnicPro,document:dom.window.document,window:dom.window,setTimeout,console,PosnicExtensionFrame:{mount:args=>{frame=args;return{destroy(){}};}}}); + PosnicPro.extensions.refreshMenu=()=>{}; + await PosnicPro.extensions.showDetails('posnic.example',{container:dom.window.document.querySelector('main'),workspace:{presentation:'compact'},onBusy:value=>busy.push(value),onClose:()=>closed=true}); + const pending=frame.request('recover',{}); + assert.equal(busy.at(-1),true); + rejectRequest({responseJSON:{error:{code:'stock_effect_superseded',message:'stock_effect_superseded'}}}); + await assert.rejects(pending,/stock_effect_superseded/);assert.equal(busy.at(-1),false); + assert.deepEqual(JSON.parse(JSON.stringify(await frame.request('closeWorkspace',{}))),{closed:true}); + await new Promise(resolve=>setTimeout(resolve,5));assert.equal(closed,true); +}); From 79497413ea46ce2a67b58c1fd9868976c2c390a6 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Thu, 15 Oct 2026 23:16:41 +0530 Subject: [PATCH 2/8] Raise local stock fence before new extension commands on upgrade --- api/src/services/extension-namespace.js | 22 +++++++- api/tests/extension-namespace.integration.cjs | 52 +++++++++++++++++++ api/tests/extensions-routes.integration.cjs | 2 +- .../extension-report-export.browser.test.cjs | 1 + 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/api/src/services/extension-namespace.js b/api/src/services/extension-namespace.js index 6eb6bc38d..8c8b688d6 100644 --- a/api/src/services/extension-namespace.js +++ b/api/src/services/extension-namespace.js @@ -185,7 +185,26 @@ async function executeNamespace(db, scope, descriptor, actor, input, dependencie ) ) fail('extension_result_invalid', 422); - const sequence = (row.effectSequence || 0) + 1; + // Older sync agents copied item replay fences between installations while + // namespace counters stayed local. Raise the counter once, before claiming + // a NEW command. Never renumber a pending command: some of its effects may + // already have committed. Item receipts and stock remain untouched. + let sequenceFloor = row.effectSequence || 0; + if (row.localStockFenceVersion !== 1) { + const field = `extension_stock_streams.${key._id}.sequence`; + const cursor = db.collection('items').find( + { license: key.license, [field]: { $exists: true }, + $or: [{ branch_id: key.branch_id }, { 'branch_access.branch_id': key.branch_id }] }, + { projection: { [field]: 1 } } + ); + for await (const item of cursor) { + const receiptSequence = item.extension_stock_streams?.[key._id]?.sequence; + if (!Number.isSafeInteger(receiptSequence) || receiptSequence < 0) + fail('extension_stock_receipt_invalid'); + sequenceFloor = Math.max(sequenceFloor, receiptSequence); + } + } + const sequence = sequenceFloor + 1; if (!Number.isSafeInteger(sequence)) fail('extension_sequence_exhausted'); const locked = await namespaces.updateOne( { @@ -200,6 +219,7 @@ async function executeNamespace(db, scope, descriptor, actor, input, dependencie { $set: { effectSequence: sequence, + localStockFenceVersion: 1, pending: { sequence, operationId, diff --git a/api/tests/extension-namespace.integration.cjs b/api/tests/extension-namespace.integration.cjs index 4e35626bd..d1d0bd095 100644 --- a/api/tests/extension-namespace.integration.cjs +++ b/api/tests/extension-namespace.integration.cjs @@ -51,6 +51,58 @@ function fixture() { } const noEffect = { executeEffect: async () => ({ applied: true }) }; +test('upgrade advances above imported fences without deleting items or replaying stock', async () => { + const f = fixture(); + const token = require('node:crypto').createHash('sha256') + .update(`${f.scope.license}:${f.scope.branchId}:${f.descriptor.id}`).digest('hex'); + const item = { _id: new ObjectId(), license: f.scope.license, branch_id: f.scope.branchId, + track_inventory: true, item_status: 'regular', available_quantity: 5, + extension_stock_streams: { [token]: { sequence: 330, digest: 'old-receipt', applied: true } } }; + await db.collection('items').insertMany([item, + { ...item, _id: new ObjectId(), license: new ObjectId(), extension_stock_streams: { [token]: { sequence: 999 } } }, + { ...item, _id: new ObjectId(), branch_id: new ObjectId(), extension_stock_streams: { [token]: { sequence: 999 } } }]); + const { applyStockEffect } = require('../src/services/extension-stock-effects'); + const deps = { executeEffect: async context => applyStockEffect(db, f.scope, { + itemId: String(item._id), operationId: context.operationId, deltaMilli: -1000, + stream: { id: f.descriptor.id, sequence: context.sequence } }) }; + const result = await executeNamespace(db, f.scope, f.descriptor, f.actor, f.input, deps); + assert.deepEqual(await executeNamespace(db, f.scope, f.descriptor, f.actor, f.input, deps), result); + const saved = await db.collection('items').findOne({ _id: item._id }); + assert.equal(saved.available_quantity, 4); + assert.equal(saved.extension_stock_streams[token].sequence, 331); + const row = await db.collection('extension_namespaces').findOne({ _id: token }); + assert.equal(row.effectSequence, 331); + assert.equal(row.localStockFenceVersion, 1); + assert.equal(await db.collection('items').countDocuments({ license: f.scope.license }), 2); +}); + +test('upgrade does not renumber a pending effect or discard its receipts', async () => { + const f = fixture(); + await assert.rejects(executeNamespace(db, f.scope, f.descriptor, f.actor, f.input, + { executeEffect: async () => { throw Error('interrupted'); } }), /interrupted/); + const row = await db.collection('extension_namespaces').findOne({ license: f.scope.license }); + await db.collection('extension_namespaces').updateOne({ _id: row._id }, { $unset: { localStockFenceVersion: '' } }); + const item = { _id: new ObjectId(), license: f.scope.license, branch_id: f.scope.branchId, + available_quantity: 4, extension_stock_streams: { [row._id]: { sequence: 330 } } }; + await db.collection('items').insertOne(item); + await recoverNamespace(db, f.scope, f.descriptor, f.actor, { + executeEffect: async context => { assert.equal(context.sequence, 1); return { applied: true }; } }); + assert.deepEqual(await db.collection('items').findOne({ _id: item._id }), item); + assert.equal((await db.collection('extension_namespaces').findOne({ _id: row._id })).effectSequence, 1); +}); + +test('invalid imported fence refuses a new command without changing stock', async () => { + const f = fixture(); + const token = require('node:crypto').createHash('sha256') + .update(`${f.scope.license}:${f.scope.branchId}:${f.descriptor.id}`).digest('hex'); + const item = { _id: new ObjectId(), license: f.scope.license, branch_id: f.scope.branchId, + available_quantity: 5, extension_stock_streams: { [token]: { sequence: 'bad' } } }; + await db.collection('items').insertOne(item); + await assert.rejects(executeNamespace(db, f.scope, f.descriptor, f.actor, f.input, noEffect), /extension_stock_receipt_invalid/); + assert.deepEqual(await db.collection('items').findOne({ _id: item._id }), item); + assert.equal((await db.collection('extension_namespaces').findOne({ _id: token })).pending, undefined); +}); + test('repeated removal and restore retain state, effect receipts and other shops', async () => { const f = fixture(); await executeNamespace(db, f.scope, f.descriptor, f.actor, f.input, noEffect); diff --git a/api/tests/extensions-routes.integration.cjs b/api/tests/extensions-routes.integration.cjs index 2314f1558..a468807eb 100644 --- a/api/tests/extensions-routes.integration.cjs +++ b/api/tests/extensions-routes.integration.cjs @@ -139,7 +139,7 @@ test('signed view and catalogue retain staff, installation and branch boundaries headers: { Authorization: 'Bearer reader' }, }); assert.deepEqual((await list.json()).extensions, [ - { id: descriptor.id, displayName: descriptor.displayName, version: descriptor.version, enabled: true, installed: true }, + { id: descriptor.id, displayName: descriptor.displayName, version: descriptor.version, enabled: true, installed: true, salesWorkspace: null }, ]); assert.equal((await request('/view', { token: 'key' })).status, 403); const view = await (await request('/view', { token: 'reader' })).json(); diff --git a/tests/extension-report-export.browser.test.cjs b/tests/extension-report-export.browser.test.cjs index ef3dfdec5..ca27d0300 100644 --- a/tests/extension-report-export.browser.test.cjs +++ b/tests/extension-report-export.browser.test.cjs @@ -12,6 +12,7 @@ test('report bridge permits only bounded text and cleans up after exports and fa window.exportReport=eval('('+helper.trim()+')'); window.output=[]; window.PosnicPro={reportExport:{csv:(id,meta)=>{output.push({text:document.getElementById(id).textContent,html:document.getElementById(id).innerHTML,meta})},xls:()=>{throw Error('export failed')}},lazy:{load:async()=>{throw Error('PDF unavailable')}}}; + window.PosnicPro.i18n={t:(key,fallback)=>fallback||key}; },helper); const result=await page.evaluate(async()=>{ const data={format:'csv',range:'2026-10-01 to 2026-10-31',rows:[['','GBP','=1+1']]}; From 1ca12ce22e969025f17162417fda0ae628b46fd0 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Thu, 15 Oct 2026 23:59:26 +0530 Subject: [PATCH 3/8] Format stock receipt upgrade query --- api/src/services/extension-namespace.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/src/services/extension-namespace.js b/api/src/services/extension-namespace.js index 8c8b688d6..f03bd8b6a 100644 --- a/api/src/services/extension-namespace.js +++ b/api/src/services/extension-namespace.js @@ -193,8 +193,11 @@ async function executeNamespace(db, scope, descriptor, actor, input, dependencie if (row.localStockFenceVersion !== 1) { const field = `extension_stock_streams.${key._id}.sequence`; const cursor = db.collection('items').find( - { license: key.license, [field]: { $exists: true }, - $or: [{ branch_id: key.branch_id }, { 'branch_access.branch_id': key.branch_id }] }, + { + license: key.license, + [field]: { $exists: true }, + $or: [{ branch_id: key.branch_id }, { 'branch_access.branch_id': key.branch_id }], + }, { projection: { [field]: 1 } } ); for await (const item of cursor) { From ba4c6db75ddb73092f63b79e0419443cab181056 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 01:02:42 +0530 Subject: [PATCH 4/8] Fix branch switching and branch supplier imports --- .../migrations/supplier-email-index.js | 10 ++- api/src/repositories/supplier.repository.js | 13 +++- api/src/services/supplier.service.js | 6 +- .../database/optional-email-indexes.test.js | 24 +++++- .../repositories/supplier.repository.test.js | 17 +++++ frontend/layouts/header.html | 11 +++ frontend/static/script/js/core/PosnicPro.js | 27 ++++++- .../static/script/js/modules/js/branches.js | 17 +++++ .../static/script/js/modules/js/settings.js | 3 +- tests/branch-switch.test.cjs | 76 +++++++++++++++++++ 10 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 tests/branch-switch.test.cjs diff --git a/api/src/database/migrations/supplier-email-index.js b/api/src/database/migrations/supplier-email-index.js index 7e20c1bb2..a52670708 100644 --- a/api/src/database/migrations/supplier-email-index.js +++ b/api/src/database/migrations/supplier-email-index.js @@ -6,7 +6,7 @@ const options = { partialFilterExpression: { email: { $type: 'string', $gt: '' } }, }; -async function migrateContactEmailIndex(db, name, indexOptions) { +async function migrateContactEmailIndex(db, name, indexOptions, keys = { email: 1 }) { if (!(await db.listCollections({ name }, { nameOnly: true }).hasNext())) return; const collection = db.collection(name); const indexes = await collection.listIndexes().toArray(); @@ -14,7 +14,7 @@ async function migrateContactEmailIndex(db, name, indexOptions) { // a new uniqueness rule fit historical data. A duplicate is a deferred // migration, not a reason to take the entire till offline. try { - await collection.createIndex({ email: 1 }, indexOptions); + await collection.createIndex(keys, indexOptions); } catch (error) { if (error?.code !== 11000) throw error; console.warn( @@ -36,7 +36,11 @@ async function migrateContactEmailIndex(db, name, indexOptions) { } } -const migrateSupplierEmailIndex = (db) => migrateContactEmailIndex(db, 'suppliers', options); +const migrateSupplierEmailIndex = (db) => + migrateContactEmailIndex(db, 'suppliers', { + ...options, + name: 'supplier_branch_nonempty_email', + }, { license: 1, branch_id: 1, email: 1 }); const migrateCustomerEmailIndex = (db) => migrateContactEmailIndex(db, 'customers', { ...options, diff --git a/api/src/repositories/supplier.repository.js b/api/src/repositories/supplier.repository.js index 23c5efa64..63cc0e4db 100644 --- a/api/src/repositories/supplier.repository.js +++ b/api/src/repositories/supplier.repository.js @@ -72,11 +72,12 @@ class SupplierRepository extends BaseModel { /** * Find supplier by email */ - async findByEmail(email) { + async findByEmail(email, branchId = BaseModel.currentBranch) { const collection = await this.getCollection(this.collectionName); return await collection.findOne({ email, license: BaseModel.license, + branch_id: branchId, is_deleted: { $ne: true }, }); } @@ -228,6 +229,9 @@ class SupplierRepository extends BaseModel { throw new Error('Supplier not found'); } + const backup = await BaseModel.deletedDocumentBackup(this.collectionName, supplier); + if (!backup?.status) throw new Error(backup?.message || 'Supplier backup failed'); + // Backup to recycle bin collection const recycleCollection = await this.getCollection(this.collectionName + '_recycle_bin'); await recycleCollection.insertOne({ @@ -264,6 +268,13 @@ class SupplierRepository extends BaseModel { return { deletedCount: 0 }; } + // The common recycle record is also the deletion event consumed by sync. + // Keep the legacy backup below for existing restore screens. + for (const supplier of suppliers) { + const backup = await BaseModel.deletedDocumentBackup(this.collectionName, supplier); + if (!backup?.status) throw new Error(backup?.message || 'Supplier backup failed'); + } + // Backup to recycle bin collection const recycleCollection = await this.getCollection(this.collectionName + '_recycle_bin'); const backupDocs = suppliers.map((supplier) => ({ diff --git a/api/src/services/supplier.service.js b/api/src/services/supplier.service.js index bc4c88f9a..8b15d2c04 100644 --- a/api/src/services/supplier.service.js +++ b/api/src/services/supplier.service.js @@ -249,7 +249,7 @@ class SupplierService { // Check for duplicate email if (supplierData.email) { - const existingSupplier = await this.repository.findByEmail(supplierData.email); + const existingSupplier = await this.repository.findByEmail(supplierData.email, supplierData.branch_id); if (existingSupplier) { return { status: false, @@ -297,7 +297,7 @@ class SupplierService { // Check for duplicate email if email is being updated if (updateData.email && updateData.email !== existingSupplier.email) { - const duplicateSupplier = await this.repository.findByEmail(updateData.email); + const duplicateSupplier = await this.repository.findByEmail(updateData.email, existingSupplier.branch_id); if (duplicateSupplier) { return { status: false, @@ -650,7 +650,7 @@ class SupplierService { address: existingSupplier.address || '', }); } else { - if (supplier.email && (await this.repository.findByEmail(supplier.email))) { + if (supplier.email && (await this.repository.findByEmail(supplier.email, supplier.branch_id))) { validationErrors.push({ name: supplier.name, status: 'Email is already used by another supplier', diff --git a/api/tests/unit/database/optional-email-indexes.test.js b/api/tests/unit/database/optional-email-indexes.test.js index d4df81695..a67be93ac 100644 --- a/api/tests/unit/database/optional-email-indexes.test.js +++ b/api/tests/unit/database/optional-email-indexes.test.js @@ -25,6 +25,26 @@ const makeDb = (index = { name: 'email_1', key: { email: 1 }, unique: true }) => }; describe('migrateOptionalEmailIndexes', () => { + test('replaces the old business-wide supplier constraint only after the branch index exists', async () => { + const { db, collections } = makeDb({ name: 'supplier_nonempty_email', key: { email: 1 }, unique: true }); + await migrateOptionalEmailIndexes(db); + const suppliers = collections.get('suppliers'); + expect(suppliers.createIndex).toHaveBeenCalledWith( + { license: 1, branch_id: 1, email: 1 }, + expect.objectContaining({ name: 'supplier_branch_nonempty_email', unique: true }) + ); + expect(suppliers.createIndex.mock.invocationCallOrder[0]).toBeLessThan(suppliers.dropIndex.mock.invocationCallOrder[0]); + }); + + test('retains the old supplier constraint and contacts if branch uniqueness cannot be built', async () => { + const { db, collections } = makeDb(); + collections.get('suppliers').createIndex.mockRejectedValue({ code: 11000 }); + const warning = jest.spyOn(console, 'warn').mockImplementation(() => {}); + await migrateOptionalEmailIndexes(db); + expect(collections.get('suppliers').dropIndex).not.toHaveBeenCalled(); + expect(collections.get('suppliers').updateMany).not.toHaveBeenCalled(); + warning.mockRestore(); + }); test('unsets blank emails and replaces non-sparse unique indexes', async () => { const { db, collections } = makeDb(); await migrateOptionalEmailIndexes(db); @@ -36,7 +56,9 @@ describe('migrateOptionalEmailIndexes', () => { ) { expect(collection.updateMany).not.toHaveBeenCalled(); expect(collection.createIndex).toHaveBeenCalledWith( - { email: 1 }, + collection === collections.get('suppliers') + ? { license: 1, branch_id: 1, email: 1 } + : { email: 1 }, expect.objectContaining({ unique: true, partialFilterExpression: { email: { $type: 'string', $gt: '' } }, diff --git a/api/tests/unit/repositories/supplier.repository.test.js b/api/tests/unit/repositories/supplier.repository.test.js index 502b70cee..1b7e87c33 100644 --- a/api/tests/unit/repositories/supplier.repository.test.js +++ b/api/tests/unit/repositories/supplier.repository.test.js @@ -27,6 +27,7 @@ jest.mock('../../../src/models/base.model', () => { } MockBaseModel.prototype.getCollection = jest.fn(); MockBaseModel.simplifyFields = jest.fn((doc) => doc); + MockBaseModel.deletedDocumentBackup = jest.fn().mockResolvedValue({ status: true }); MockBaseModel.license = null; MockBaseModel.currentBranch = null; MockBaseModel.loggedUser = null; @@ -461,6 +462,22 @@ describe('SupplierRepository', () => { // ── bulkSoftDelete ─────────────────────────────────────────────────────────── describe('bulkSoftDelete', () => { + test('publishes a sync deletion record before removing suppliers', async () => { + const doc = { _id: FAKE_ID, branch_id: FAKE_BRANCH, license: FAKE_LICENSE }; + col.find.mockReturnValue(mkChain([doc])); + await repo.bulkSoftDelete([FAKE_ID]); + expect(BaseModel.deletedDocumentBackup).toHaveBeenCalledWith('suppliers', doc); + expect(BaseModel.deletedDocumentBackup.mock.invocationCallOrder[0]).toBeLessThan( + col.deleteMany.mock.invocationCallOrder[0] + ); + }); + + test('does not delete when the sync backup cannot be saved', async () => { + col.find.mockReturnValue(mkChain([{ _id: FAKE_ID }])); + BaseModel.deletedDocumentBackup.mockResolvedValueOnce({ status: false, message: 'Backup unavailable' }); + await expect(repo.bulkSoftDelete([FAKE_ID])).rejects.toThrow('Backup unavailable'); + expect(col.deleteMany).not.toHaveBeenCalled(); + }); test('backs up and deletes multiple suppliers', async () => { const docs = [{ _id: FAKE_ID }, { _id: FAKE_ID_2 }]; col.find.mockReturnValue(mkChain(docs)); diff --git a/frontend/layouts/header.html b/frontend/layouts/header.html index cc6044255..8d22ef669 100644 --- a/frontend/layouts/header.html +++ b/frontend/layouts/header.html @@ -121,6 +121,17 @@

Posnic

    +