Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c8004e6
Merge pull request #1358 from Posnic/chore/require-feature-sync-review
sridharkalaibala Oct 15, 2026
f54b1f7
Merge pull request #1360 from Posnic/fix/desktop-toolbar-recovery-con…
sridharkalaibala Oct 15, 2026
5827bc1
Merge pull request #1362 from Posnic/fix/sales-history-default-today
sridharkalaibala Oct 15, 2026
4c4ecdd
Merge pull request #1366 from Posnic/main
sridharkalaibala Oct 16, 2026
e37b6a8
Fix system default receipt printer resolution
sridharkalaibala Oct 16, 2026
3e1d8d6
Merge remote-tracking branch 'origin/develop' into fix/system-default…
sridharkalaibala Oct 16, 2026
e7da635
Allow custom receipt footer text and omit placeholder addresses
sridharkalaibala Oct 16, 2026
53c4fb8
Hide zero receipt taxes and align sample tax labels with country
sridharkalaibala Oct 16, 2026
fdb58a5
Expose public shop names for Mobile POS discovery
sridharkalaibala Oct 16, 2026
7d94809
Document the Mobile POS shop discovery endpoint
sridharkalaibala Oct 16, 2026
7f39d81
Format discovery route and integration test
sridharkalaibala Oct 16, 2026
fce9c1e
Register receipt footer placeholder for translation
sridharkalaibala Oct 16, 2026
af840fc
Isolate legacy index regression fixture from startup migrations
sridharkalaibala Oct 16, 2026
f0b0765
Translate receipt footer placeholder in Dutch
sridharkalaibala Oct 16, 2026
f3964a2
Merge pull request #1369 from Posnic/fix/system-default-autoprint
sridharkalaibala Oct 16, 2026
5e9683a
Document public mobile discovery and update endpoint count
sridharkalaibala Oct 16, 2026
724b259
Merge branch 'develop' into codex/mobile-discovery-names
sridharkalaibala Oct 16, 2026
4cc97bd
Use branch state for walk-in GST and normalize customer states
sridharkalaibala Oct 16, 2026
4c97faa
Merge pull request #1370 from Posnic/codex/mobile-discovery-names
sridharkalaibala Oct 16, 2026
66aed4a
Merge remote-tracking branch 'origin/develop' into fix/gst-customer-s…
sridharkalaibala Oct 16, 2026
7eafc95
Merge pull request #1372 from Posnic/fix/gst-customer-state
sridharkalaibala Oct 16, 2026
34416c1
Make mobile inventory discoverable and available by default
sridharkalaibala Oct 16, 2026
1470858
Merge pull request #1373 from Posnic/fix/mobile-inventory-settings
sridharkalaibala Oct 16, 2026
ac802f1
Hide disabled Ask Posnic menus and consolidate settings saves
sridharkalaibala Oct 16, 2026
dc49074
Merge pull request #1374 from Posnic/fix/settings-single-save
sridharkalaibala Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
Expand Down
10 changes: 1 addition & 9 deletions api/src/helpers/bill-payload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
29 changes: 29 additions & 0 deletions api/src/helpers/gst-place.js
Original file line number Diff line number Diff line change
@@ -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 };
29 changes: 29 additions & 0 deletions api/src/helpers/receipt-design.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -267,6 +295,7 @@
return out;
}
return {
taxLabel: taxLabel,
taxRegistrationLabel: taxRegistrationLabel,
formats: formats,
fields: fields,
Expand Down
2 changes: 1 addition & 1 deletion api/src/models/branch.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
8 changes: 4 additions & 4 deletions api/src/repositories/sale.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
23 changes: 23 additions & 0 deletions api/src/routes/mobile-pos.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
9 changes: 5 additions & 4 deletions api/src/services/mobile-inventory.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 (
Expand Down Expand Up @@ -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) ||
Expand All @@ -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,
};
}
Expand Down
5 changes: 3 additions & 2 deletions api/src/services/mobile-pos.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(',') + ']';
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions api/src/services/sale.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down
5 changes: 5 additions & 0 deletions api/src/utils/mobile-inventory-policy.js
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 3 additions & 1 deletion api/tests/api/rest-smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '' });
Expand Down
45 changes: 33 additions & 12 deletions api/tests/mobile-inventory.integration.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
});
6 changes: 6 additions & 0 deletions api/tests/mobile-pos.integration.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
23 changes: 23 additions & 0 deletions api/tests/unit/helpers/gst-place.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
Loading
Loading