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.
[](https://github.com/Posnic/POS/releases/latest)
[](docs/DEVELOPMENT.md#running-the-tests)
[](docs/DEVELOPMENT.md#running-the-tests)
-[](docs/API.md)
+[](docs/API.md)
[](LICENSE)
[](THIRD-PARTY-NOTICES.md)
[](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 @@
+
diff --git a/frontend/modules/settings_write.html b/frontend/modules/settings_write.html
index 28aae6323..794f89d6e 100644
--- a/frontend/modules/settings_write.html
+++ b/frontend/modules/settings_write.html
@@ -125,7 +125,7 @@ Themes
Ask Posnic
@@ -1260,11 +1260,7 @@ Tax System
-
+
@@ -1292,7 +1288,7 @@ Tax Defaults
@@ -3798,7 +3794,7 @@
Let the software write and read for
Estimated from what each call reported, at list prices, in your currency at a rounded rate. Live voice is counted by the minute of open line while the call is on. Your provider's dashboard is the final word on the bill.
-
+
Search by meaning with my OpenAI key
-
+
@@ -5149,6 +5142,7 @@ Applies to new items in this branch. Existing items keep their stock settings. You can still mark an item sold out manually.
+
Reminders appear in the POS bell for products with stock and a saved expiry date. Dates use the shop timezone. No email or WhatsApp messages are sent.
@@ -5251,11 +5246,7 @@
a ₹200 bill paid as ₹100 UPI + ₹100 cash.
-
+
diff --git a/frontend/static/script/js/core/PosnicPro.js b/frontend/static/script/js/core/PosnicPro.js
index b888abc3e..1188bf235 100644
--- a/frontend/static/script/js/core/PosnicPro.js
+++ b/frontend/static/script/js/core/PosnicPro.js
@@ -2955,7 +2955,7 @@ PosnicPro = {
$('#manage_li_deliverypartners').toggle(on('module_delivery_partners_enable'));
$('#manage_li_webshop').toggle(on('module_webshop_enable'));
$('#manage_li_theme').toggle(on('module_themes_enable'));
- $('#manage_li_ai').toggle(on('ai_enabled') || s.ask_posnic_enabled === true);
+ $('#manage_li_ai').toggle(s.ask_posnic_enabled === true);
$('[data-ask-entry]').toggle(s.ask_posnic_enabled === true);
if (PosnicPro.askposnic) PosnicPro.askposnic.setEnabled(s.ask_posnic_enabled === true);
$('#manage_li_recyclebin').toggle(on('module_recyclebin_enable'));
diff --git a/frontend/static/script/js/core/print-settings.js b/frontend/static/script/js/core/print-settings.js
index 43c6fa929..8cfc78204 100644
--- a/frontend/static/script/js/core/print-settings.js
+++ b/frontend/static/script/js/core/print-settings.js
@@ -79,7 +79,7 @@
state.sales.map(function (v, i) { return row('sales', v, i); }).join('') + row('invoice', state.invoice) + row('quotation', state.quotation) +
'
' + languageSettings() + '' +
(desktop() ? '' : '') +
- '
');
+ '');
features(featureState);
}
function collect() {
@@ -109,7 +109,8 @@
}
mirror(next);
host.find('[data-print-status]').text(PosnicPro.i18n.t('lang_print_settings_saved', 'Print settings saved.'));
- } catch (error) { host.find('[data-print-status]').text(error.message); }
+ return true;
+ } catch (error) { host.find('[data-print-status]').text(error.message); return false; }
finally { button.prop('disabled', false); }
}
async function mount(data) {
diff --git a/frontend/static/script/js/core/receipt-designer.js b/frontend/static/script/js/core/receipt-designer.js
index 55153e62a..561c2e31c 100644
--- a/frontend/static/script/js/core/receipt-designer.js
+++ b/frontend/static/script/js/core/receipt-designer.js
@@ -231,7 +231,11 @@
if (b.type === 'store') {
function storeText(key, fallback) { return String(b[key] || '').trim() || fallback || ''; }
var storeName = storeText('nameText', data.branch_name || data.store_name || PosnicPro.local.get('branchname'));
- var storeAddress = storeText('addressText', plain(data.printing_address || data.store_address || ''));
+ function addressText(value) {
+ var text = plain(value || '').trim();
+ return /^(not provided|not available|not set|n\/a|null|undefined)$/i.test(text) ? '' : text;
+ }
+ var storeAddress = addressText(b.addressText) || addressText(data.printing_address) || addressText(data.store_address);
var storePhone = storeText('phoneText', data.store_telephone);
var storeEmail = storeText('emailText', data.store_email);
var storeRegistration = storeText('registrationText', data.branch_gstin_number);
@@ -296,13 +300,14 @@
var igst = items.reduce(function (n, i) { return n + Number(i.igst_tax || 0); }, 0);
var cgst = items.reduce(function (n, i) { return n + Number(i.cgst_tax || 0); }, 0);
var sgst = items.reduce(function (n, i) { return n + Number(i.sgst_tax == null ? i.cgst_tax || 0 : i.sgst_tax); }, 0);
- if (Array.isArray(data.receipt_tax_rows) && data.receipt_tax_rows.length) data.receipt_tax_rows.forEach(function (row) { content += pair(row.label, money(row.amount)); });
+ var hasTaxAmount = function (amount) { return Number.isFinite(Number(amount)) && Number(Number(amount).toFixed(2)) !== 0; };
+ if (Array.isArray(data.receipt_tax_rows) && data.receipt_tax_rows.length) data.receipt_tax_rows.forEach(function (row) { if (hasTaxAmount(row.amount)) content += pair(row.label, money(row.amount)); });
else if (igst || cgst || sgst) {
- if (igst) content += pair('IGST', money(igst));
- if (cgst) content += pair('CGST', money(cgst));
- if (sgst) content += pair('SGST', money(sgst));
+ if (hasTaxAmount(igst)) content += pair('IGST', money(igst));
+ if (hasTaxAmount(cgst)) content += pair('CGST', money(cgst));
+ if (hasTaxAmount(sgst)) content += pair('SGST', money(sgst));
}
- else if (Number(data.tax)) content += pair('Tax', money(data.tax));
+ else if (hasTaxAmount(data.tax)) content += pair(contract.taxLabel(data, PosnicPro.local.get('country_setting')), money(data.tax));
(data.charges || []).forEach(function (c) { content += pair(c.name || 'Charge', money(Number(c.amount || 0) + Number(c.tax_amount || 0))); });
if (Number(data.round_off)) content += pair('Round Off', money(data.round_off));
content += pair('Total', money(data.items_total), true);
@@ -350,7 +355,7 @@
}).filter(Boolean);
rendered = pairFields(rendered);
var html = sheet ? composeSheet(rendered) : rendered.map(function (b) { return b.html; }).join('');
- if (designs.showBranding !== false) html += 'Made with Posnic · posnic.com';
+ if (designs.showBranding !== false) html += '' + esc(String(designs.brandingText || '').trim() || 'Made with Posnic · posnic.com') + '';
if (pendingGoods) html += '' + esc(label('Payment pending')) + '';
if (sheet) {
var notes = '';
diff --git a/frontend/static/script/js/modules/js/ask_posnic.js b/frontend/static/script/js/modules/js/ask_posnic.js
index e18c3ef25..d748a38fc 100644
--- a/frontend/static/script/js/modules/js/ask_posnic.js
+++ b/frontend/static/script/js/modules/js/ask_posnic.js
@@ -39,7 +39,7 @@ PosnicPro.askposnic = {
this.loadStatus();
},
settingsTab: function (key, focus) {
- if (!this.admin || ['general', 'knowledge', 'access', 'usage', 'schedules'].indexOf(key) === -1) return;
+ if (!this.enabled || !this.admin || ['general', 'knowledge', 'access', 'usage', 'schedules'].indexOf(key) === -1) return;
this.activeSettingsTab = key;
$('[data-ask-tab]').each(function () {
var active = $(this).attr('data-ask-tab') === key;
@@ -576,10 +576,12 @@ PosnicPro.askposnic = {
});
$('#ask_posnic_preferences_form').on('submit', function (event) {
event.preventDefault();
- PosnicPro.request({ url: 'ask-posnic/preferences', method: 'PUT', data: JSON.stringify({ own_key_semantic: $('#ask_pref_own_semantic').is(':checked'), own_key_semantic_budget: Number($('#ask_pref_embedding_budget').val()), help_enabled: $('#ask_pref_help').is(':checked'), insights_enabled: $('#ask_pref_insights').is(':checked'), actions_enabled: $('#ask_pref_actions').is(':checked'), store_conversations: $('#ask_pref_history').is(':checked'), retention_days: Number($('#ask_pref_retention').val()), default_period: $('#ask_pref_period').val(), response_language: $('#ask_pref_language').val(), help_instructions: $('#ask_help_instructions').val(), roles: { help: $('#ask_pref_help_roles').val() || [], insights: $('#ask_pref_insight_roles').val() || [], actions: $('#ask_pref_action_roles').val() || [] }, allowed_actions: $('.ask-allowed-action:checked').map(function () { return this.value; }).get() }) }, function (response) {
+ function savePreferences() { PosnicPro.request({ url: 'ask-posnic/preferences', method: 'PUT', data: JSON.stringify({ own_key_semantic: $('#ask_pref_own_semantic').is(':checked'), own_key_semantic_budget: Number($('#ask_pref_embedding_budget').val()), help_enabled: $('#ask_pref_help').is(':checked'), insights_enabled: $('#ask_pref_insights').is(':checked'), actions_enabled: $('#ask_pref_actions').is(':checked'), store_conversations: $('#ask_pref_history').is(':checked'), retention_days: Number($('#ask_pref_retention').val()), default_period: $('#ask_pref_period').val(), response_language: $('#ask_pref_language').val(), help_instructions: $('#ask_help_instructions').val(), roles: { help: $('#ask_pref_help_roles').val() || [], insights: $('#ask_pref_insight_roles').val() || [], actions: $('#ask_pref_action_roles').val() || [] }, allowed_actions: $('.ask-allowed-action:checked').map(function () { return this.value; }).get() }) }, function (response) {
if (response && response.type === 'success') { self.loadStatus(); PosnicPro.alert('success', response.message); }
else PosnicPro.alert('error', response.message || 'Could not save Ask Posnic settings.');
- });
+ }); }
+ if (self.activeSettingsTab === 'usage') PosnicPro.settings.ai.save(savePreferences);
+ else savePreferences();
});
$('#ask_posnic_schedules').on('click', '.ask-schedule-delete', function () {
PosnicPro.request({ url: 'ask-posnic/schedules/' + encodeURIComponent($(this).data('id')), method: 'DELETE' }, function (response) { if (response && response.type === 'success') self.loadSchedules(); });
diff --git a/frontend/static/script/js/modules/js/item-settings-report.js b/frontend/static/script/js/modules/js/item-settings-report.js
index 6344c1083..a2d58636a 100644
--- a/frontend/static/script/js/modules/js/item-settings-report.js
+++ b/frontend/static/script/js/modules/js/item-settings-report.js
@@ -15,11 +15,11 @@
},
load: function () {
if (!$('#inventory_approval_settings').length) {
- var panel = $('Inventory confirmation
Staff with item write permission confirm directly by default. Require manager approval for selected mobile inventory actions:
');
- $('').appendTo(panel.find('.card-body'));
+ var panel = $('Mobile inventory
Staff with item write permission confirm directly by default. Require manager approval for selected mobile inventory actions:
');
+ $('').appendTo(panel.find('.card-body'));
['count','receive','damage','loss'].forEach(function (mode) { $('