diff --git a/README.md b/README.md index dd05ab66d..2cd6773f6 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-894%20endpoints-blue)](docs/API.md) +[![API](https://img.shields.io/badge/REST%20API-895%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) | 894 endpoints, generated from the routes | +| [REST API](docs/API.md) | 895 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 | diff --git a/api/src/helpers/bill-payload.js b/api/src/helpers/bill-payload.js index 00f714382..9da6183df 100644 --- a/api/src/helpers/bill-payload.js +++ b/api/src/helpers/bill-payload.js @@ -254,15 +254,7 @@ function taxRows(sale, branch, allocation = null) { const taxMinor = money.toMinor(tax, policy); const firstHalf = Math.ceil(taxMinor / 2); const rate = gstRate(sale); - const stateKey = (value) => { - const key = String(value || '') - .trim() - .toLowerCase(); - const match = require('../json/gst_state_code.json').gststate.find( - (row) => row.id === key.padStart(2, '0') || row.value.toLowerCase() === key - ); - return match ? match.id : key; - }; + const { stateKey } = require('./gst-place'); const supplierState = stateKey(sale.branch_state || branch.state); const supplyState = stateKey(sale.place_of_supply || sale.customer_state); if (supplierState && supplyState && supplierState !== supplyState) { diff --git a/api/src/helpers/gst-place.js b/api/src/helpers/gst-place.js new file mode 100644 index 000000000..acfa037c9 --- /dev/null +++ b/api/src/helpers/gst-place.js @@ -0,0 +1,29 @@ +const { gststate } = require('../json/gst_state_code.json'); + +function stateKey(value) { + const key = String(value || '') + .trim() + .toLowerCase() + .replace(/\s+/g, ' '); + const state = gststate.find( + (row) => row.id === key.padStart(2, '0') || row.value.toLowerCase() === key + ); + return state ? state.id : key; +} + +function isInterState(supplierState, customerState) { + const supplier = stateKey(supplierState); + const customer = stateKey(customerState); + return Boolean(supplier && customer && supplier !== customer); +} + +function customerState(data, branch, customer) { + const supplier = String(branch.state || '').trim(); + const isDefault = + !data.customer_id || + (branch.default_customer && String(data.customer_id) === String(branch.default_customer)); + if (isDefault) return supplier; + return String(data.customer_state || customer?.state || '').trim() || supplier; +} + +module.exports = { stateKey, isInterState, customerState }; diff --git a/api/src/helpers/receipt-design.js b/api/src/helpers/receipt-design.js index a4cb19662..de51ef159 100644 --- a/api/src/helpers/receipt-design.js +++ b/api/src/helpers/receipt-design.js @@ -5,6 +5,29 @@ else root.PosnicReceiptDesign = factory(); })(typeof window !== 'undefined' ? window : this, function () { 'use strict'; + function taxLabel(data, fallbackCountry) { + data = data || {}; + const country = String( + data.branch_country || data.country || data.sortname || fallbackCountry || '' + ) + .trim() + .toLowerCase(); + if (['in', 'ind', 'india', 'au', 'aus', 'australia'].includes(country)) return 'GST'; + if ( + [ + 'gb', + 'gbr', + 'uk', + 'united kingdom', + 'united kingdom of great britain and northern ireland', + 'za', + 'zaf', + 'south africa', + ].includes(country) + ) + return 'VAT'; + return 'Tax'; + } function taxRegistrationLabel(data, fallbackCountry) { data = data || {}; const country = String( @@ -120,6 +143,11 @@ showBranding: value.showBranding !== false, layouts: {}, }; + if (value.brandingText != null) { + if (typeof value.brandingText !== 'string' || value.brandingText.length > 200) + throw new Error('Receipt footer text must be at most 200 characters.'); + out.brandingText = value.brandingText.trim(); + } Object.keys(formats).forEach(function (format) { const layout = layoutFor(value, format); if (!layout || !Array.isArray(layout.blocks) || layout.blocks.length > 40) @@ -267,6 +295,7 @@ return out; } return { + taxLabel: taxLabel, taxRegistrationLabel: taxRegistrationLabel, formats: formats, fields: fields, diff --git a/api/src/models/branch.model.js b/api/src/models/branch.model.js index 6854779b5..e783dfcf7 100644 --- a/api/src/models/branch.model.js +++ b/api/src/models/branch.model.js @@ -98,7 +98,7 @@ const branchSchema = new Schema( whatsapp_device_id: { type: String, default: '' }, item_stock_default: { type: String, enum: ['always_available', 'track_quantities'] }, inventory_policy: { - enabled: { type: Boolean, default: false }, + enabled: { type: Boolean, default: true }, approvalActions: { type: [String], enum: ['count', 'receive', 'damage', 'loss'], diff --git a/api/src/repositories/sale.repository.js b/api/src/repositories/sale.repository.js index 60f0de45c..67d14b962 100644 --- a/api/src/repositories/sale.repository.js +++ b/api/src/repositories/sale.repository.js @@ -949,10 +949,10 @@ class SalesRepository { const customerState = (doc.customer_state || '').toString().trim(); const branchState = (branchDoc?.state || '').toString().trim(); - const isInterState = - customerState && - branchState && - customerState.toLowerCase() !== branchState.toLowerCase(); + const isInterState = require('../helpers/gst-place').isInterState( + branchState, + customerState + ); if (isInterState) { cloned.igst_tax = perLineGstAmount; diff --git a/api/src/routes/mobile-pos.routes.js b/api/src/routes/mobile-pos.routes.js index d0dca1901..a3a15ba92 100644 --- a/api/src/routes/mobile-pos.routes.js +++ b/api/src/routes/mobile-pos.routes.js @@ -28,6 +28,29 @@ const limiter = rateLimit({ legacyHeaders: false, }); +// Discovery exposes only public display names, never branch/staff identifiers. +router.get( + '/discovery', + rateLimit({ windowMs: 60000, limit: 60 }), + wrap(async (req) => { + const rows = await req.db + .collection('branches') + .find( + { module_mobile_pos_enable: { $nin: [false, 0, '0', 'false'] } }, + { projection: { _id: 0, branch_name: 1 } } + ) + .limit(10) + .toArray(); + return { + shopName: [ + ...new Set(rows.map((row) => String(row.branch_name || '').trim()).filter(Boolean)), + ] + .join(' · ') + .slice(0, 160), + }; + }) +); + router.post( '/enrolment-proof', rateLimit({ windowMs: 60000, limit: 120, standardHeaders: true, legacyHeaders: false }), diff --git a/api/src/services/mobile-inventory.js b/api/src/services/mobile-inventory.js index f67fcc443..c2e2524d2 100644 --- a/api/src/services/mobile-inventory.js +++ b/api/src/services/mobile-inventory.js @@ -1,4 +1,5 @@ 'use strict'; +const inventoryEnabled = require('../utils/mobile-inventory-policy'); // Inventory uses the existing item balance and stocklogs. The effect marker is // written atomically with the balance, so a retry cannot apply a line twice. @@ -25,8 +26,8 @@ const revision = (row) => const wall = (c) => ({ license: c.license, branch_id: c.branchId }); async function access(req, write = false) { const c = await mobile.context(req, Boolean(req.handsetDevice)); - if (req.handsetDevice && c.branch.inventory_policy?.enabled !== true) - mobile.fail('Enable mobile inventory in Core Settings → Items/Inventory.', 403); + if (req.handsetDevice && !inventoryEnabled(c.branch)) + mobile.fail('Enable mobile inventory in Core Settings → Inventory → Mobile inventory.', 403); if (write && req.handsetDevice) { const scope = req.body?.scope; if ( @@ -497,7 +498,7 @@ async function policy(req) { if (req.method === 'POST') { if (!canManage) mobile.fail('An administrator must change inventory policy.', 403); const actions = req.body?.approvalActions; - const enabled = req.body?.enabled ?? c.branch.inventory_policy?.enabled === true; + const enabled = req.body?.enabled ?? inventoryEnabled(c.branch); if ( typeof enabled !== 'boolean' || !Array.isArray(actions) || @@ -519,7 +520,7 @@ async function policy(req) { } return { canManage, - enabled: c.branch.inventory_policy?.enabled === true, + enabled: inventoryEnabled(c.branch), approvalActions: permissions(req, c).approvalActions, }; } diff --git a/api/src/services/mobile-pos.js b/api/src/services/mobile-pos.js index d7afa3842..a8d2a9764 100644 --- a/api/src/services/mobile-pos.js +++ b/api/src/services/mobile-pos.js @@ -1,4 +1,5 @@ 'use strict'; +const inventoryEnabled = require('../utils/mobile-inventory-policy'); // Mobile writes have a durable intent before any effects. Each effect has its // own stable identity, so a lost response or process restart can resume safely @@ -13,7 +14,7 @@ const { fail } = require('../utils/branch-access'); const { allowed } = require('../utils/mobile-pos-access'); const canConnect = (user, branch) => allowed(user, 'sales') || - (allowed(user, 'item', 'read') && (!branch || branch.inventory_policy?.enabled === true)); + (allowed(user, 'item', 'read') && (!branch || inventoryEnabled(branch))); const minor = (n) => Math.round(Number(n || 0) * 100); function canonical(v) { if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']'; @@ -220,7 +221,7 @@ async function bootstrap(req) { upiAccounts: c.config.upiAccounts, defaultUpiAccountId: c.config.defaultUpiAccountId || undefined, capabilities: { - inventory: c.branch.inventory_policy?.enabled === true && allowed(req.user, 'item', 'read'), + inventory: inventoryEnabled(c.branch) && allowed(req.user, 'item', 'read'), saleSync: true, devicePairing: true, tillPrint: true, diff --git a/api/src/services/sale.service.js b/api/src/services/sale.service.js index 1726d29d9..bd7963e02 100644 --- a/api/src/services/sale.service.js +++ b/api/src/services/sale.service.js @@ -426,6 +426,13 @@ const processSale = async ( const pricingCustomer = data.customer_id ? await customerRepository.findById(data.customer_id) : null; + const gstPlace = require('../helpers/gst-place'); + const supplierState = context.branchState || pricingBranch.state || ''; + const saleCustomerState = gstPlace.customerState( + data, + { ...pricingBranch, state: supplierState }, + pricingCustomer + ); let priceList; if (pricingCustomer?.category_id) { const pricingDb = await BaseModel.getDb(); @@ -662,7 +669,7 @@ const processSale = async ( const indianGstSetting = context.branchSettings?.indian_gst || 'gst_off'; - const interPlace = (data.customer_state || '').trim() !== (context.branchState || '').trim(); + const interPlace = gstPlace.isInterState(supplierState, saleCustomerState); if (indianGstSetting === 'gst_on' && gstValue > 0) { if (interPlace) { igst_value = gstValue; @@ -1274,7 +1281,7 @@ const processSale = async ( } : {}), customer_email: (data.customer_email || '').trim(), - customer_state: (data.customer_state || '').trim(), + customer_state: saleCustomerState, customer_country: (data.customer_country || '').trim(), country_sort: finalCountrySort, customer_gst_type: (data.customer_gst_type || '').trim(), diff --git a/api/src/utils/mobile-inventory-policy.js b/api/src/utils/mobile-inventory-policy.js new file mode 100644 index 000000000..cef01b070 --- /dev/null +++ b/api/src/utils/mobile-inventory-policy.js @@ -0,0 +1,5 @@ +'use strict'; + +// Available by default; an explicit administrator opt-out survives upgrades. +module.exports = (branch) => + branch?.inventory_policy?.enabled == null || branch.inventory_policy.enabled === true; diff --git a/api/tests/api/rest-smoke.test.js b/api/tests/api/rest-smoke.test.js index 1d61e2025..5d709a23a 100644 --- a/api/tests/api/rest-smoke.test.js +++ b/api/tests/api/rest-smoke.test.js @@ -168,7 +168,9 @@ describeIfDb('the REST API, against a real database', () => { * branch would not save. It cannot be caught with a mock: the constraint * lives in MongoDB, not in the code. */ - const customers = client.db(DB_NAME).collection('customers'); + // Keep this deliberate legacy-index fixture separate from startup migrations, + // which normalize blank emails in the real customers collection asynchronously. + const customers = client.db(DB_NAME).collection('customers_legacy_index_fixture'); await customers.createIndex({ email: 1 }, { unique: true, sparse: true }); await customers.insertOne({ name: 'First', phone: '' }); diff --git a/api/tests/mobile-inventory.integration.cjs b/api/tests/mobile-inventory.integration.cjs index 9263ac2e9..32e91e351 100644 --- a/api/tests/mobile-inventory.integration.cjs +++ b/api/tests/mobile-inventory.integration.cjs @@ -173,9 +173,9 @@ test('inventory-only staff can enroll without gaining selling permission', async .updateOne({ _id: row.branch_id }, { $set: { module_mobile_pos_enable: false } }); await assert.rejects(inventory.catalogue(req), { status: 403 }); }); -test('approval policy is administrator-only and persists explicit opt-in', async () => { +test('approval policy is administrator-only and persists selected approval actions', async () => { assert.deepEqual((await inventory.policy(req)).approvalActions, []); - assert.equal((await inventory.policy(req)).enabled, false); + assert.equal((await inventory.policy(req)).enabled, true); req.method = 'POST'; req.body = { enabled: true, approvalActions: ['damage'] }; assert.deepEqual((await inventory.policy(req)).approvalActions, ['damage']); @@ -193,16 +193,14 @@ test('stock history reads the shared desktop audit and remains branch scoped', a assert.equal(history.rows[0].opening, '10'); assert.equal(history.rows[0].closing, '15'); assert.equal(history.rows[0].reason, 'Mobile inventory receive'); - await db - .collection('stocklogs') - .insertOne({ - _id: new ObjectId(), - view_item_id: row._id, - license: row.license, - branch_id: new ObjectId(), - created_date: new Date(), - process: 'Other branch', - }); + await db.collection('stocklogs').insertOne({ + _id: new ObjectId(), + view_item_id: row._id, + license: row.license, + branch_id: new ObjectId(), + created_date: new Date(), + process: 'Other branch', + }); assert.equal((await inventory.history(req)).rows.length, 1); }); test('catalogue is fully paged, including categories and suppliers from the server', async () => { @@ -267,3 +265,26 @@ test('null lines and case-variant duplicate ObjectIds are rejected before writin ]; assert.throws(() => inventory.validate(body), { status: 400 }); }); + +test('mobile inventory defaults on, preserves explicit off and existing approval choices', async () => { + req.method = 'GET'; + assert.equal((await inventory.policy(req)).enabled, true); + req.handsetDevice = 'test-phone'; + assert.equal((await inventory.catalogue(req)).rows.length, 1); + await db.collection('branches').updateOne( + { _id: row.branch_id }, + { + $set: { inventory_policy: { enabled: false, approvalActions: ['count'] } }, + } + ); + assert.equal((await inventory.policy(req)).enabled, false); + assert.deepEqual((await inventory.policy(req)).approvalActions, ['count']); + await assert.rejects(inventory.catalogue(req), { status: 403 }); + req.method = 'POST'; + req.body = { approvalActions: ['count'] }; + assert.equal((await inventory.policy(req)).enabled, false); + req.body.enabled = true; + assert.equal((await inventory.policy(req)).enabled, true); + assert.equal((await inventory.catalogue(req)).rows.length, 1); + assert.equal((await db.collection('items').findOne({ _id: row._id })).available_quantity, 10); +}); diff --git a/api/tests/mobile-pos.integration.cjs b/api/tests/mobile-pos.integration.cjs index ccffa91d7..8364c6a1c 100644 --- a/api/tests/mobile-pos.integration.cjs +++ b/api/tests/mobile-pos.integration.cjs @@ -1304,3 +1304,9 @@ test('mobile snapshot sync carries disabled methods and still accepts a previous await db.collection('branches').replaceOne({ _id: branch._id }, savedBranch); } }); + +test('public mobile discovery exposes a shop name without credentials or internal identifiers', async () => { + const result = await call('/mobile/v1/discovery', undefined, null); + assert.equal(result.status, 200); + assert.deepEqual(result.data, { shopName: branch.branch_name }); +}); diff --git a/api/tests/unit/helpers/gst-place.test.js b/api/tests/unit/helpers/gst-place.test.js new file mode 100644 index 000000000..cddd9d081 --- /dev/null +++ b/api/tests/unit/helpers/gst-place.test.js @@ -0,0 +1,23 @@ +const { stateKey, isInterState, customerState } = require('../../../src/helpers/gst-place'); + +test.each([ + ['Tamil Nadu', '33', false], + ['33', ' tamil nadu ', false], + ['Tamil Nadu', 'Karnataka', true], + ['33', '29', true], + ['33', '', false], + ['', '29', false], +])('GST state comparison %s / %s is interstate: %s', (supplier, customer, expected) => { + expect(isInterState(supplier, customer)).toBe(expected); +}); + +test('walk-in and configured default customers use the branch state', () => { + const branch = { state: 'Tamil Nadu', default_customer: 'walkin-id' }; + expect(customerState({}, branch)).toBe('Tamil Nadu'); + expect(customerState({ customer_id: 'walkin-id', customer_state: '29' }, branch)).toBe( + 'Tamil Nadu' + ); + expect(customerState({ customer_id: 'named-id' }, branch, { state: '29' })).toBe('29'); + expect(customerState({ customer_id: 'named-id' }, branch)).toBe('Tamil Nadu'); + expect(stateKey(' 33 ')).toBe('33'); +}); diff --git a/api/tests/unit/services/sale.service.test.js b/api/tests/unit/services/sale.service.test.js index 26f6aa551..fe88197ce 100644 --- a/api/tests/unit/services/sale.service.test.js +++ b/api/tests/unit/services/sale.service.test.js @@ -187,6 +187,46 @@ describe('SalesService', () => { mockRegisterRepositoryInstance.validateSessionOwner.mockResolvedValue({ status: true }); }); + test.each([ + ['', undefined, '', false], + ['64f8f2f4c2b9c0a1e4b22222', '', '', false], + ['64f8f2f4c2b9c0a1e4b22222', '33', '', false], + ['64f8f2f4c2b9c0a1e4b22222', ' tamil nadu ', '', false], + ['64f8f2f4c2b9c0a1e4b22222', 'Karnataka', '', true], + ['64f8f2f4c2b9c0a1e4b22222', '29', '', true], + ['64f8f2f4c2b9c0a1e4b22222', '29', '64f8f2f4c2b9c0a1e4b22222', false], + ])( + 'saves GST by customer state (%s, %s, default %s)', + async (customer_id, customer_state, default_customer, inter) => { + mockItemRepositoryInstance.findItemById.mockResolvedValue( + makeItemDoc({ selling_price: 100, tax: 18, tax_type: 'exclusive' }) + ); + const result = await salesService.processSale( + makeSaleData({ + customer_id, + customer_state, + sales_total: '236', + multi_payment: { Cash: 236 }, + }), + '', + 'Add', + makeContext({ + branchState: 'Tamil Nadu', + branchSettings: { sortname: 'IN', indian_gst: 'gst_on', default_customer }, + }) + ); + expect(result.status).toBe(true); + const saved = salesRepository.create.mock.calls[0][0]; + expect(saved.items[0].igst_tax).toBe(inter ? 36 : 0); + expect(saved.items[0].cgst_tax).toBe(inter ? 0 : 18); + expect(saved.items[0].sgst_tax).toBe(inter ? 0 : 18); + expect(saved.items[0].tax_components.map((component) => component.name)).toEqual( + inter ? ['IGST'] : ['CGST', 'SGST'] + ); + if (!customer_state || default_customer) expect(saved.customer_state).toBe('Tamil Nadu'); + } + ); + afterEach(() => { process.removeListener(KOT_EVENT, onKotReady); consoleErrorSpy.mockRestore(); diff --git a/docs/API.md b/docs/API.md index 6ff9a51b0..c026ff575 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. -**894 endpoints across 53 route groups.** +**895 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 894 endpoints carry a request schema** (12%), recovered from -their `express-validator` middleware. The remaining 790 are listed with +**104 of 895 endpoints carry a request schema** (12%), recovered from +their `express-validator` middleware. The remaining 791 are listed with path, method and handler but no body, because inventing a schema is worse than admitting the gap. @@ -730,6 +730,7 @@ Mounted at `/mobile/v1`. Source: `api/src/routes/mobile-pos.routes.js`. | Method | Path | Body documented | Handler | |---|---|---|---| +| GET | `/mobile/v1/discovery` | — | `Boolean` | | POST | `/mobile/v1/enrolment-proof` | — | `hex` | | POST | `/mobile/v1/pair` | — | `handsetLifetimeSeconds` | | GET | `/mobile/v1/inventory/policy` | — | `inventory.policy` | diff --git a/docs/MOBILE_INVENTORY_IMPLEMENTATION.md b/docs/MOBILE_INVENTORY_IMPLEMENTATION.md index ee4bd7e42..70b8e8faf 100644 --- a/docs/MOBILE_INVENTORY_IMPLEMENTATION.md +++ b/docs/MOBILE_INVENTORY_IMPLEMENTATION.md @@ -2,7 +2,7 @@ The mobile client and POS server must both include this change. Beta 20 does not include inventory. This document records implemented scope; it does not mark the complete inventory roadmap finished. -Mobile inventory is off by default. An administrator enables **Enable mobile inventory (pilot)** under **Core Settings → Items/Inventory**, then refreshes the phone's connection/catalogue. Existing explicit approval choices are preserved. +Mobile inventory is enabled by default for new branches and branches without a saved choice. An explicit saved disable remains off. Administrators manage **Enable mobile inventory** and approval actions under **Core Settings → Inventory → Mobile inventory**, then refresh the phone's connection/catalogue. Existing approval choices and role permissions are preserved. ## Implemented @@ -12,7 +12,7 @@ Mobile inventory is off by default. An administrator enables **Enable mobile inv - Persistent partial-count, direct-receiving, damage and loss drafts. Counted zero differs from an item never added to a count. Quantities use thousandths, with whole-unit validation. - Server prepare/review/confirm/status/cancel endpoints. A durable request identity and atomic per-item effect prevent duplicate stock changes after a lost reply or restart. Conflicted operations report applied and unapplied lines separately. - Current server ACL and branch/tenant checks, including approval policy rechecking at confirmation. An unchanged count uses an atomic expected-quantity/date/revision check; a changed item requires another observation. Pending sales on this phone block count confirmation. -- Direct confirmation by default. Administrators can require approval for selected actions under Core Settings → Items/Inventory. Owners/admins approve in Inventory → Stock Counts or in the phone's operation history. +- Direct confirmation by default. Administrators can require approval for selected actions under Core Settings → Inventory → Mobile inventory. Owners/admins approve in Inventory → Stock Counts or in the phone's operation history. - Stock changes update the existing item balance and stocklogs; the normal POS sync transports these records. Operation journals remain on the issuing server and are never replayed against a different server. - Mobile labels and messages in all 18 app languages (beta translations; native-speaker review remains necessary). @@ -33,3 +33,9 @@ Local tests use a disposable MongoDB database, never production shop stock: - Mobile: `npm run check`, web export and Playwright tests including receiving/review/confirm and saved zero counts across restart. Device acceptance still needs: offline count/restart, reconnect, interrupted confirmation, actual cashier/admin roles, branch isolation, fractional quantities, camera permission recovery and the installed desktop's sync behavior. + +## Settings rollout and synchronization + +The policy remains `branches.inventory_policy`, scoped to the selected licensed branch. Saving it updates `updated_date`; POS and Gateway classify branches as global tenant data, and the sync agent carries branches on its existing batch lane. No new collection or transport is introduced. Saved true/false and approval choices travel with the branch; unset is interpreted consistently by bootstrap, inventory access, policy reads and the branch model after this update. Operation journals remain local to their issuing server. + +Deploy the POS API and frontend together, then update connected tills to the same implementation. Refresh the mobile snapshot after changing policy. The menu is a server/web change and does not require a new APK. Mixed-version servers may interpret an unset policy differently until upgraded; saving an explicit choice avoids that ambiguity. Live cloud-to-till and other-till delivery is not verified by the local API tests. diff --git a/docs/openapi.json b/docs/openapi.json index 780ee3ed1..2d5099212 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -12953,6 +12953,29 @@ "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/discovery": { + "get": { + "summary": "Boolean", + "operationId": "get_mobile_v1_discovery", + "tags": [ + "Mobile pos" + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Validation failed" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Server error" + } + } + } + }, "/mobile/v1/enrolment-proof": { "post": { "summary": "hex", diff --git a/frontend/layouts/sidebar.html b/frontend/layouts/sidebar.html index 8c02d7fb1..04a13ab94 100644 --- a/frontend/layouts/sidebar.html +++ b/frontend/layouts/sidebar.html @@ -570,7 +570,7 @@