diff --git a/api/src/controllers/customers.controller.js b/api/src/controllers/customers.controller.js
index 6ad6ca54c..78657cf7e 100644
--- a/api/src/controllers/customers.controller.js
+++ b/api/src/controllers/customers.controller.js
@@ -190,6 +190,7 @@ class CustomerController extends BaseController {
name: req.body.name,
email: req.body.email || '',
phone: req.body.phone || '',
+ allow_shared_phone: normalizeBoolean(req.body.allow_shared_phone),
alternatePhone: req.body.alternatePhone,
address: req.body.address || '',
city: req.body.city || '',
@@ -310,6 +311,9 @@ class CustomerController extends BaseController {
// Extract update data
const updateData = { ...req.body };
+ if (req.body.allow_shared_phone !== undefined) {
+ updateData.allow_shared_phone = normalizeBoolean(req.body.allow_shared_phone);
+ }
// Convert branch_id to ObjectId if present
if (updateData.branch_id) {
@@ -428,6 +432,14 @@ class CustomerController extends BaseController {
return this.sendError(res, 'Search term is required', 400, null);
}
+ await require('../services/sale-customer').recover(
+ await BaseModel.getDb(),
+ {
+ branchId: new ObjectId(branchId),
+ license: new ObjectId(BaseModel.license),
+ },
+ searchTerm
+ );
const options = { page, limit, branchId };
// Call service
diff --git a/api/src/database/migrations/supplier-email-index.js b/api/src/database/migrations/supplier-email-index.js
index 85e55c702..1df9fe678 100644
--- a/api/src/database/migrations/supplier-email-index.js
+++ b/api/src/database/migrations/supplier-email-index.js
@@ -40,15 +40,16 @@ const supplierKeys = { license: 1, branch_id: 1, email: 1 };
const supplierOptions = { ...options, name: 'supplier_branch_nonempty_email' };
const migrateSupplierEmailIndex = (db) =>
- migrateContactEmailIndex(
- db,
- 'suppliers',
- supplierOptions,
- supplierKeys
- );
+ migrateContactEmailIndex(db, 'suppliers', supplierOptions, supplierKeys);
const migrateCustomerEmailIndex = (db) =>
migrateContactEmailIndex(db, 'customers', {
...options,
name: 'customer_nonempty_email',
});
-module.exports = { migrateSupplierEmailIndex, migrateCustomerEmailIndex, options, supplierKeys, supplierOptions };
+module.exports = {
+ migrateSupplierEmailIndex,
+ migrateCustomerEmailIndex,
+ options,
+ supplierKeys,
+ supplierOptions,
+};
diff --git a/api/src/helpers/gst-place.js b/api/src/helpers/gst-place.js
index acfa037c9..b9f5fa992 100644
--- a/api/src/helpers/gst-place.js
+++ b/api/src/helpers/gst-place.js
@@ -22,8 +22,28 @@ function customerState(data, branch, customer) {
const isDefault =
!data.customer_id ||
(branch.default_customer && String(data.customer_id) === String(branch.default_customer));
+ const explicit = String(data.customer_state || '').trim();
+ if (explicit) return explicit;
if (isDefault) return supplier;
return String(data.customer_state || customer?.state || '').trim() || supplier;
}
-module.exports = { stateKey, isInterState, customerState };
+function placeOfSupply(data, branch, customer) {
+ return String(data.place_of_supply || '').trim() || customerState(data, branch, customer);
+}
+function validate(data) {
+ for (const field of ['customer_state', 'place_of_supply']) {
+ if (data[field] && !gststate.some((row) => row.id === stateKey(data[field])))
+ throw new Error('Select a valid Indian state.');
+ }
+ const gstin = String(data.customer_gst_number || '')
+ .trim()
+ .toUpperCase();
+ if (gstin && !/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/.test(gstin))
+ throw new Error('Enter a valid customer GSTIN.');
+ if (gstin && !data.customer_state)
+ data.customer_state = gststate.find((row) => row.id === gstin.slice(0, 2))?.value || '';
+ if (gstin && data.customer_state && gstin.slice(0, 2) !== stateKey(data.customer_state))
+ throw new Error('GSTIN and customer state do not match.');
+}
+module.exports = { stateKey, isInterState, customerState, placeOfSupply, validate };
diff --git a/api/src/models/customer.model.js b/api/src/models/customer.model.js
index 9c91b69e2..b1189ebd2 100644
--- a/api/src/models/customer.model.js
+++ b/api/src/models/customer.model.js
@@ -41,8 +41,8 @@ class CustomerModel extends BaseModel {
type: 'String',
select: true,
required: true,
- unique: true,
},
+ allow_shared_phone: { type: 'Boolean', select: true, default: false },
alternatePhone: { type: 'String', select: true },
// Address Information
diff --git a/api/src/models/sale.model.js b/api/src/models/sale.model.js
index 79338ac87..63138ae1a 100644
--- a/api/src/models/sale.model.js
+++ b/api/src/models/sale.model.js
@@ -400,6 +400,8 @@ const saleSchema = new mongoose.Schema(
trim: true,
},
customer_phone: String,
+ place_of_supply: { type: String, trim: true },
+ customer_creation_pending: { type: Boolean, default: false },
customer_email: {
type: String,
trim: true,
@@ -1148,6 +1150,7 @@ class LegacySaleModel {
customer_phone: { type: 'String', select: true },
customer_email: { type: 'String', select: true },
customer_state: { type: 'String', select: true },
+ place_of_supply: { type: 'String', select: true },
customer_country: { type: 'String', select: true },
customer_gst_type: { type: 'String', select: true },
customer_gst_number: { type: 'String', select: true },
diff --git a/api/src/repositories/customer.repository.js b/api/src/repositories/customer.repository.js
index 65438fd47..29d9b70bd 100644
--- a/api/src/repositories/customer.repository.js
+++ b/api/src/repositories/customer.repository.js
@@ -85,10 +85,11 @@ class CustomerRepository extends BaseModel {
/**
* Find customer by phone
*/
- async findByPhone(phone) {
+ async findByPhone(phone, excludeId) {
const collection = await this.getCollection(this.collectionName);
return await collection.findOne({
phone,
+ ...(excludeId ? { _id: { $ne: new ObjectId(excludeId) } } : {}),
license: BaseModel.license,
is_deleted: { $ne: true },
});
diff --git a/api/src/repositories/sale.repository.js b/api/src/repositories/sale.repository.js
index 67d14b962..d30dda77c 100644
--- a/api/src/repositories/sale.repository.js
+++ b/api/src/repositories/sale.repository.js
@@ -136,6 +136,7 @@ const gstStateCode = (gstin, stateName) => {
.trim()
.toLowerCase();
if (!name) return '';
+ if (/^\d{2}$/.test(name)) return name;
if (!_gstStateByName) {
_gstStateByName = new Map();
try {
@@ -946,7 +947,9 @@ class SalesRepository {
);
if (perLineGstAmount > 0) {
- const customerState = (doc.customer_state || '').toString().trim();
+ const customerState = (doc.place_of_supply || doc.customer_state || '')
+ .toString()
+ .trim();
const branchState = (branchDoc?.state || '').toString().trim();
const isInterState = require('../helpers/gst-place').isInterState(
@@ -4110,6 +4113,7 @@ class SalesRepository {
customer_phone: 1,
customer_email: 1,
customer_state: 1,
+ place_of_supply: 1,
customer_country: 1,
customer_gst_type: 1,
customer_gst_number: 1,
@@ -4466,6 +4470,7 @@ class SalesRepository {
customer_phone: 1,
customer_email: 1,
customer_state: 1,
+ place_of_supply: 1,
customer_country: 1,
customer_gst_type: 1,
customer_gst_number: 1,
@@ -6348,7 +6353,7 @@ class SalesRepository {
_id: {
item_sales_id: '$sales_id',
item_date: '$date',
- item_customer_state: '$customer_state',
+ item_customer_state: { $ifNull: ['$place_of_supply', '$customer_state'] },
item_customer_gst_number: '$customer_gst_number',
item_tax: '$items.tax',
item_subtotal: '$items_subtotal',
@@ -6398,7 +6403,7 @@ class SalesRepository {
_id: {
return_sales_id: '$sales_id',
return_sales_date: '$date',
- return_customer_state: '$customer_state',
+ return_customer_state: { $ifNull: ['$place_of_supply', '$customer_state'] },
// the credit-note rows printed a placeholder GSTIN; carry
// the real one so the 9B table shows the actual customer
return_customer_gst_number: '$customer_gst_number',
@@ -6502,7 +6507,7 @@ class SalesRepository {
$and: [
{
branch_id: branchId,
- customer_state: { $not: { $eq: branchState } },
+ $expr: { $ne: [{ $ifNull: ['$place_of_supply', '$customer_state'] }, branchState] },
customer_gst_type: { $in: ['consumer'] },
},
{
@@ -6522,7 +6527,7 @@ class SalesRepository {
_id: {
item_sales_id: '$sales_id',
item_date: '$date',
- item_customer_state: '$customer_state',
+ item_customer_state: { $ifNull: ['$place_of_supply', '$customer_state'] },
item_customer_gst_number: '$customer_gst_number',
item_tax: '$items.tax',
item_subtotal: '$items_subtotal',
@@ -6904,7 +6909,7 @@ class SalesRepository {
$and: [
{
branch_id: new ObjectId(branch_id),
- customer_state: { $not: { $eq: branch_state } },
+ $expr: { $ne: [{ $ifNull: ['$place_of_supply', '$customer_state'] }, branch_state] },
customer_gst_type: { $in: ['consumer', 'composite'] },
},
{
@@ -6922,7 +6927,7 @@ class SalesRepository {
{
$group: {
_id: {
- item_customer_state: '$customer_state',
+ item_customer_state: { $ifNull: ['$place_of_supply', '$customer_state'] },
customer_gst_type: '$customer_gst_type',
},
total_amount: { $sum: '$items_subtotal' },
@@ -7132,6 +7137,7 @@ class SalesRepository {
},
date: { $first: '$date' },
customer_state: { $first: '$customer_state' },
+ place_of_supply: { $first: '$place_of_supply' },
invoice_value: { $first: '$sales_total' },
items_value: { $sum: '$items.total_amount' },
igst: { $sum: '$items.igst_tax' },
@@ -7167,7 +7173,9 @@ class SalesRepository {
// Invoice value: the sale's own total, not a sum of lines -
// charges, round-off and bill-level discounts belong in it.
val: round(Number(row.invoice_value) || 0),
- pos: gstStateCode(ctin, row.customer_state),
+ pos: row.place_of_supply
+ ? gstStateCode('', row.place_of_supply)
+ : gstStateCode(ctin, row.customer_state),
rchrg: 'N',
inv_typ: 'R',
itms: [],
diff --git a/api/src/services/customer.service.js b/api/src/services/customer.service.js
index 5a30b297c..1fd2e31b3 100644
--- a/api/src/services/customer.service.js
+++ b/api/src/services/customer.service.js
@@ -230,7 +230,7 @@ class CustomerService {
}
// Check for duplicate phone (only if phone is provided)
- if (customerData.phone) {
+ if (customerData.phone && customerData.allow_shared_phone !== true) {
const existingByPhone = await this.repository.findByPhone(customerData.phone);
if (existingByPhone) {
return {
@@ -296,8 +296,12 @@ class CustomerService {
}
// Check for duplicate phone if phone is being updated
- if (updateData.phone && updateData.phone !== existing.phone) {
- const existingByPhone = await this.repository.findByPhone(updateData.phone);
+ const sharedPhone = updateData.allow_shared_phone ?? existing.allow_shared_phone;
+ const phone = updateData.phone ?? existing.phone;
+ const revokedSharing =
+ updateData.allow_shared_phone === false && existing.allow_shared_phone === true;
+ if (phone && (phone !== existing.phone || revokedSharing) && sharedPhone !== true) {
+ const existingByPhone = await this.repository.findByPhone(phone, id);
if (existingByPhone && existingByPhone._id.toString() !== id) {
return {
status: false,
diff --git a/api/src/services/sale-customer.js b/api/src/services/sale-customer.js
new file mode 100644
index 000000000..d8f9c14e9
--- /dev/null
+++ b/api/src/services/sale-customer.js
@@ -0,0 +1,131 @@
+'use strict';
+const { ObjectId } = require('mongodb');
+const { createHash } = require('crypto');
+const validator = require('validator');
+const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+function customerId(scope, name) {
+ return new ObjectId(
+ createHash('sha256')
+ .update(
+ JSON.stringify([
+ 'sale-customer',
+ String(scope.license),
+ String(scope.branchId),
+ name.trim().replace(/\s+/g, ' ').toLowerCase(),
+ ])
+ )
+ .digest('hex')
+ .slice(0, 24)
+ );
+}
+
+// Planning is read-only: failed validation, previews and abandoned baskets
+// must never create contacts. The committed sale is the durable recovery record.
+async function plan(db, scope, data) {
+ if (data.create_customer_on_save !== true || data.customer_id) return null;
+ const name = String(data.customer_name || '')
+ .trim()
+ .replace(/\s+/g, ' ');
+ if (!name) return null;
+ if (name.length > 200) throw new Error('Customer name is too long.');
+ for (const [key, limit] of [
+ ['phone', 40],
+ ['email', 254],
+ ['address', 500],
+ ]) {
+ if (String(data['customer_' + key] || '').length > limit)
+ throw new Error('Customer ' + key + ' is too long.');
+ }
+ const email = String(data.customer_email || '').trim();
+ if (email && !validator.isEmail(email)) throw new Error('Enter a valid customer email.');
+ const collection = db.collection('customers');
+ const matches = await collection
+ .find({
+ license: scope.license,
+ branch_id: scope.branchId,
+ is_deleted: { $ne: true },
+ name: new RegExp('^' + escape(name) + '$', 'i'),
+ })
+ .limit(2)
+ .toArray();
+ if (matches.length > 1)
+ throw new Error('More than one customer has this name. Select the correct customer.');
+ if (matches.length) return { id: matches[0]._id, pending: false, customer: matches[0] };
+ const id = customerId(scope, name);
+ if (await collection.findOne({ _id: id, is_deleted: true }))
+ throw new Error('This customer was deleted. Restore the customer or use a different name.');
+ if (
+ email &&
+ (await collection.findOne({ license: scope.license, email, is_deleted: { $ne: true } }))
+ )
+ throw new Error('This email belongs to a saved customer. Select that customer.');
+ return { id, pending: true };
+}
+
+async function finish(db, scope, saleId) {
+ const sales = db.collection('sales');
+ const sale = await sales.findOne({
+ _id: new ObjectId(saleId),
+ license: scope.license,
+ branch_id: scope.branchId,
+ customer_creation_pending: true,
+ });
+ if (!sale) return;
+ const id = customerId(scope, sale.customer_name);
+ if (String(id) !== String(sale.customer_id))
+ throw new Error('Customer creation identity mismatch.');
+ const now = new Date();
+ const customer = {
+ _id: id,
+ name: sale.customer_name,
+ license: scope.license,
+ branch_id: scope.branchId,
+ branch_name: sale.branch_name || '',
+ branch_access: [{ branch_id: scope.branchId, branch_name: sale.branch_name || '' }],
+ phone: sale.customer_phone || '',
+ address: sale.customer_address || '',
+ state: sale.customer_state || '',
+ country: sale.customer_country || '',
+ gst_type: sale.customer_gst_type || 'consumer',
+ gst_number: sale.customer_gst_number || '',
+ balance: 0,
+ wallet: 0,
+ partial_balance: false,
+ is_deleted: false,
+ created_date: now,
+ updated_date: now,
+ created_by: sale.created_by || '',
+ created_by_id: sale.created_by_id,
+ };
+ if (sale.customer_email) customer.email = sale.customer_email;
+ // Stable across concurrent saves and offline tills; never overwrite details,
+ // balances, or a later deletion when replaying a saved sale.
+ try {
+ await db
+ .collection('customers')
+ .updateOne({ _id: id }, { $setOnInsert: customer }, { upsert: true });
+ } catch (error) {
+ if (error.code !== 11000 || !(await db.collection('customers').findOne({ _id: id })))
+ throw error;
+ }
+ await sales.updateOne(
+ { _id: sale._id, license: scope.license, branch_id: scope.branchId },
+ { $set: { customer_creation_pending: false, updated_date: now } }
+ );
+}
+
+async function recover(db, scope, query) {
+ if (!scope.license || !scope.branchId) return;
+ const pending = await db
+ .collection('sales')
+ .find({
+ license: scope.license,
+ branch_id: scope.branchId,
+ customer_creation_pending: true,
+ customer_name: new RegExp(escape(String(query).slice(0, 200)), 'i'),
+ })
+ .limit(20)
+ .toArray();
+ for (const sale of pending) await finish(db, scope, sale._id);
+}
+module.exports = { customerId, plan, finish, recover };
diff --git a/api/src/services/sale.service.js b/api/src/services/sale.service.js
index bd7963e02..8fc17a29f 100644
--- a/api/src/services/sale.service.js
+++ b/api/src/services/sale.service.js
@@ -284,25 +284,33 @@ const processSale = async (
return { status: false, message: ERROR_MESSAGES.BRANCH_LICENSE_REQUIRED };
}
- const savedAnswer = (saleId, saleNumber, duplicate = false) => ({
- status: true,
- data: {
- _id: saleId,
- sales_id: saleId,
- sale_number: saleNumber,
- sms: context.branchSettings?.sales_sms || false,
- whatsapp: context.branchSettings?.whatsapp_receipt || false,
- print: context.branchSettings?.printall || false,
- mail: context.branchSettings?.sales_mail || false,
- waring: 'success',
- name: (data.customer_name || '').trim(),
- phone: (data.customer_phone || '').trim(),
- customer_balance: duplicate ? 0 : customer ? customer.balance || 0 : 0,
- country_sort: context.branchSettings?.sortname || 'in',
- ...(duplicate ? { duplicate: true } : {}),
- },
- message: 'Sale saved successfully',
- });
+ const savedAnswer = async (saleId, saleNumber, duplicate = false) => {
+ if (data.create_customer_on_save === true)
+ await require('./sale-customer').finish(
+ await BaseModel.getDb(),
+ { branchId, license: licenseId },
+ saleId
+ );
+ return {
+ status: true,
+ data: {
+ _id: saleId,
+ sales_id: saleId,
+ sale_number: saleNumber,
+ sms: context.branchSettings?.sales_sms || false,
+ whatsapp: context.branchSettings?.whatsapp_receipt || false,
+ print: context.branchSettings?.printall || false,
+ mail: context.branchSettings?.sales_mail || false,
+ waring: 'success',
+ name: (data.customer_name || '').trim(),
+ phone: (data.customer_phone || '').trim(),
+ customer_balance: duplicate ? 0 : customer ? customer.balance || 0 : 0,
+ country_sort: context.branchSettings?.sortname || 'in',
+ ...(duplicate ? { duplicate: true } : {}),
+ },
+ message: 'Sale saved successfully',
+ };
+ };
// A retry returns its committed result before current catalogue changes
// can invalidate the original, already accepted request.
@@ -313,7 +321,7 @@ const processSale = async (
String(userId || ''),
extensionSubmission || submittedPayload
);
- if (existing) return savedAnswer(existing._id, existing.sales_id, true);
+ if (existing) return await savedAnswer(existing._id, existing.sales_id, true);
}
// 2. Check Plan / Max Sales (Skipped for now, assuming valid plan)
@@ -422,12 +430,27 @@ const processSale = async (
existingSale,
data.outlet_revision
);
+ const customerPlan =
+ data.create_customer_on_save === true
+ ? await require('./sale-customer').plan(
+ await BaseModel.getDb(),
+ { branchId, license: licenseId },
+ data
+ )
+ : null;
+ if (customerPlan) data.customer_id = String(customerPlan.id);
const pricingBranch = context.branchSettings || {};
const pricingCustomer = data.customer_id
? await customerRepository.findById(data.customer_id)
: null;
const gstPlace = require('../helpers/gst-place');
const supplierState = context.branchState || pricingBranch.state || '';
+ if (pricingBranch.indian_gst === 'gst_on') gstPlace.validate(data);
+ const salePlaceOfSupply = gstPlace.placeOfSupply(
+ data,
+ { ...pricingBranch, state: supplierState },
+ pricingCustomer
+ );
const saleCustomerState = gstPlace.customerState(
data,
{ ...pricingBranch, state: supplierState },
@@ -669,7 +692,7 @@ const processSale = async (
const indianGstSetting = context.branchSettings?.indian_gst || 'gst_off';
- const interPlace = gstPlace.isInterState(supplierState, saleCustomerState);
+ const interPlace = gstPlace.isInterState(supplierState, salePlaceOfSupply);
if (indianGstSetting === 'gst_on' && gstValue > 0) {
if (interPlace) {
igst_value = gstValue;
@@ -1262,6 +1285,10 @@ const processSale = async (
referrer_name: customer ? customer.referrer_name : '',
customer_id: data.customer_id ? new ObjectId(data.customer_id) : null,
customer_name: (data.customer_name || '').trim(),
+ customer_creation_pending:
+ customerPlan?.pending === true ||
+ (existingSale?.customer_creation_pending === true &&
+ String(existingSale.customer_id) === String(data.customer_id)),
customer_address: (data.customer_address || '').trim(),
customer_phone: (data.customer_phone || '').trim(),
...(outlet
@@ -1282,6 +1309,7 @@ const processSale = async (
: {}),
customer_email: (data.customer_email || '').trim(),
customer_state: saleCustomerState,
+ place_of_supply: salePlaceOfSupply,
customer_country: (data.customer_country || '').trim(),
country_sort: finalCountrySort,
customer_gst_type: (data.customer_gst_type || '').trim(),
@@ -1445,7 +1473,7 @@ const processSale = async (
extensionSubmission || submittedPayload,
finalSaleData
);
- if (existing) return savedAnswer(existing._id, existing.sales_id, true);
+ if (existing) return await savedAnswer(existing._id, existing.sales_id, true);
}
let seatingAttempt = null;
@@ -1463,7 +1491,7 @@ const processSale = async (
{ branchId, license: licenseId },
{ actor: String(userId || ''), request_id: data.idempotencyKey, payload: data }
);
- if (existing) return savedAnswer(existing._id, existing.sales_id, true);
+ if (existing) return await savedAnswer(existing._id, existing.sales_id, true);
}
// Inventory Verification BEFORE Insert (PHP lines 653-690)
@@ -1576,7 +1604,11 @@ const processSale = async (
if (seatingAttempt?.existing) {
for (const reservation of stockReservations.values())
await itemRepository.updateStock(reservation.itemId, reservation.quantity);
- return savedAnswer(seatingAttempt.existing._id, seatingAttempt.existing.sales_id, true);
+ return await savedAnswer(
+ seatingAttempt.existing._id,
+ seatingAttempt.existing.sales_id,
+ true
+ );
}
}
// If the unique bill-number index catches a one-in-a-million clash,
@@ -1596,7 +1628,7 @@ const processSale = async (
String(userId || ''),
extensionSubmission || submittedPayload
);
- if (existing) return savedAnswer(existing._id, existing.sales_id, true);
+ if (existing) return await savedAnswer(existing._id, existing.sales_id, true);
}
if (seatingAttempt && error.code === 11000) {
const existing = await seatingDb.collection('sales').findOne({
@@ -1605,7 +1637,7 @@ const processSale = async (
license: licenseId,
seating_request_id: seatingAttempt.claim.id,
});
- if (existing) return savedAnswer(existing._id, existing.sales_id, true);
+ if (existing) return await savedAnswer(existing._id, existing.sales_id, true);
}
throw error;
}
@@ -2047,7 +2079,7 @@ const processSale = async (
console.warn('[takeaway] Completion reconciliation pending:', error.message);
}
}
- return savedAnswer(saleId, salePrefixedId);
+ return await savedAnswer(saleId, salePrefixedId);
} catch (error) {
console.error('processSale Error:', error);
if (error.name === 'DocumentNotFoundError') {
diff --git a/api/tests/unit/controllers/customers.controller.test.js b/api/tests/unit/controllers/customers.controller.test.js
index 361619f14..11607d87d 100644
--- a/api/tests/unit/controllers/customers.controller.test.js
+++ b/api/tests/unit/controllers/customers.controller.test.js
@@ -23,6 +23,9 @@
jest.mock('express-async-handler', () => (fn) => fn);
jest.mock('../../../src/services/customer.service');
+jest.mock('../../../src/services/sale-customer', () => ({
+ recover: jest.fn().mockResolvedValue(),
+}));
jest.mock('express-validator', () => ({ validationResult: jest.fn() }));
// base.service transitive mock
@@ -44,6 +47,7 @@ jest.mock('../../../src/models/base.model', () => {
static license = null;
static loggedUser = null;
static loggedUserName = '';
+ static getDb = jest.fn().mockResolvedValue({});
}
MockBaseModel.prototype.getCollection = mockGetCollection;
return MockBaseModel;
diff --git a/api/tests/unit/helpers/gst-place.test.js b/api/tests/unit/helpers/gst-place.test.js
index cddd9d081..40997b346 100644
--- a/api/tests/unit/helpers/gst-place.test.js
+++ b/api/tests/unit/helpers/gst-place.test.js
@@ -14,10 +14,27 @@ test.each([
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: 'walkin-id', customer_state: '29' }, branch)).toBe('29');
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');
});
+
+test('name-only state and delivery override determine place of supply', () => {
+ const gst = require('../../../src/helpers/gst-place');
+ expect(gst.customerState({ customer_state: 'Karnataka' }, { state: 'Goa' })).toBe('Karnataka');
+ expect(
+ gst.placeOfSupply({ customer_state: 'Goa', place_of_supply: '29' }, { state: 'Goa' })
+ ).toBe('29');
+ expect(gst.placeOfSupply({}, { state: 'Goa' })).toBe('Goa');
+ expect(() =>
+ gst.validate({ customer_state: 'Goa', customer_gst_number: '29ABCDE1234F1Z5' })
+ ).toThrow(/do not match/);
+ expect(() =>
+ gst.validate({
+ customer_state: 'Karnataka',
+ place_of_supply: 'Goa',
+ customer_gst_number: '29ABCDE1234F1Z5',
+ })
+ ).not.toThrow();
+});
diff --git a/api/tests/unit/models/customer.model.test.js b/api/tests/unit/models/customer.model.test.js
index b3d644d9f..dc091c517 100644
--- a/api/tests/unit/models/customer.model.test.js
+++ b/api/tests/unit/models/customer.model.test.js
@@ -100,8 +100,9 @@ describe('CustomerModel — this.fields: unique constraints', () => {
expect(cm.fields.email.unique).toBe(true);
});
- test('phone has unique:true', () => {
- expect(cm.fields.phone.unique).toBe(true);
+ test('shared phone numbers are controlled by explicit opt-in, not a global unique constraint', () => {
+ expect(cm.fields.phone.unique).not.toBe(true);
+ expect(cm.fields.allow_shared_phone.default).toBe(false);
});
});
diff --git a/api/tests/unit/repositories/customer.repository.test.js b/api/tests/unit/repositories/customer.repository.test.js
index 38a34c3a8..988de4e6c 100644
--- a/api/tests/unit/repositories/customer.repository.test.js
+++ b/api/tests/unit/repositories/customer.repository.test.js
@@ -318,6 +318,17 @@ describe('CustomerRepository (class, extends BaseModel)', () => {
// ── findByPhone ────────────────────────────────────────────────────────────
describe('findByPhone', () => {
+ test('excludes the edited customer while preserving tenant scope', async () => {
+ await repository.findByPhone('9876543210', '507f1f77bcf86cd799439011');
+ expect(mockCollection.findOne).toHaveBeenCalledWith(
+ expect.objectContaining({
+ phone: '9876543210',
+ _id: { $ne: expect.anything() },
+ license: BaseModel.license,
+ })
+ );
+ });
+
test('calls collection.findOne with phone', async () => {
await repository.findByPhone('9876543210');
expect(mockCollection.findOne).toHaveBeenCalledWith(
diff --git a/api/tests/unit/repositories/gstr1-export.test.js b/api/tests/unit/repositories/gstr1-export.test.js
index e61e3363b..b3fae0c26 100644
--- a/api/tests/unit/repositories/gstr1-export.test.js
+++ b/api/tests/unit/repositories/gstr1-export.test.js
@@ -206,6 +206,25 @@ describe('GSTR-1 B2B export', () => {
expect(res.data.find((r) => r.ctin === '').inv[0].pos).toBe('03');
});
+ test('explicit delivery state overrides the customer GSTIN state', async () => {
+ rows([
+ {
+ _id: { sales_id: 'INV-POS', ctin: TN, rate: 18 },
+ date: new Date('2026-08-05T00:00:00Z'),
+ customer_state: 'Tamil Nadu',
+ place_of_supply: 'Punjab',
+ invoice_value: 1180,
+ items_value: 1180,
+ igst: 180,
+ cgst: 0,
+ sgst: 0,
+ },
+ ]);
+ const res = await run();
+ expect(res.data[0].inv[0].pos).toBe('03');
+ expect(res.data[0].ctin).toBe(TN);
+ });
+
test('invoice date is rendered DD-MM-YYYY as the tool expects', async () => {
rows([
{
diff --git a/api/tests/unit/services/customer.service.test.js b/api/tests/unit/services/customer.service.test.js
index 6f2a6e315..0e043b50a 100644
--- a/api/tests/unit/services/customer.service.test.js
+++ b/api/tests/unit/services/customer.service.test.js
@@ -490,6 +490,21 @@ describe('CustomerService', () => {
});
});
+ test('an explicit shared-phone opt-in permits a separate customer identity', async () => {
+ repo.findByPhone.mockResolvedValue(makeMockCustomer());
+ repo.create.mockResolvedValue(makeMockCustomer());
+ const result = await service.createCustomer({
+ ...validPayload(),
+ phone: '9876543210',
+ allow_shared_phone: true,
+ });
+ expect(result.status).toBe(true);
+ expect(repo.findByPhone).not.toHaveBeenCalled();
+ expect(repo.create).toHaveBeenCalledWith(
+ expect.objectContaining({ allow_shared_phone: true })
+ );
+ });
+
test('initializes loyalty when enableLoyalty is true', async () => {
repo.create.mockResolvedValue(makeMockCustomer());
const payload = { ...validPayload(), enableLoyalty: true };
@@ -612,7 +627,7 @@ describe('CustomerService', () => {
await service.updateCustomer(CUSTOMER_ID, { phone: '222' });
- expect(repo.findByPhone).toHaveBeenCalledWith('222');
+ expect(repo.findByPhone).toHaveBeenCalledWith('222', CUSTOMER_ID);
});
test('returns duplicate phone error when another customer has new phone', async () => {
@@ -631,6 +646,32 @@ describe('CustomerService', () => {
});
});
+ test('an existing shared-phone opt-in survives edits that omit the preference', async () => {
+ repo.findById.mockResolvedValue({ ...existing(), allow_shared_phone: true });
+ repo.update.mockResolvedValue({});
+ const result = await service.updateCustomer(CUSTOMER_ID, { phone: '222' });
+ expect(result.status).toBe(true);
+ expect(repo.findByPhone).not.toHaveBeenCalled();
+ });
+
+ test('turning shared-phone permission off restores duplicate checks on a changed phone', async () => {
+ repo.findById.mockResolvedValue({ ...existing(), allow_shared_phone: true });
+ repo.findByPhone.mockResolvedValue({ _id: { toString: () => OTHER_ID } });
+ const result = await service.updateCustomer(CUSTOMER_ID, {
+ phone: '222',
+ allow_shared_phone: false,
+ });
+ expect(result.status).toBe(false);
+ });
+
+ test('revoking sharing checks an unchanged phone against other customers', async () => {
+ repo.findById.mockResolvedValue({ ...existing(), allow_shared_phone: true });
+ repo.findByPhone.mockResolvedValue({ _id: { toString: () => OTHER_ID } });
+ const result = await service.updateCustomer(CUSTOMER_ID, { allow_shared_phone: false });
+ expect(result.status).toBe(false);
+ expect(repo.findByPhone).toHaveBeenCalledWith(existing().phone, CUSTOMER_ID);
+ });
+
test('returns {status:true, data:updatedCustomer} on success', async () => {
const updated = makeMockCustomer({ name: 'Updated' });
repo.findById.mockResolvedValueOnce(existing()).mockResolvedValueOnce(updated);
diff --git a/api/tests/unit/services/sale-customer.integration.cjs b/api/tests/unit/services/sale-customer.integration.cjs
new file mode 100644
index 000000000..7d117f756
--- /dev/null
+++ b/api/tests/unit/services/sale-customer.integration.cjs
@@ -0,0 +1,31 @@
+const {test,before,after,beforeEach}=require('node:test');
+const assert=require('node:assert/strict');
+const {MongoMemoryServer}=require('mongodb-memory-server');
+const {MongoClient,ObjectId}=require('mongodb');
+const service=require('../../../src/services/sale-customer');
+let server,client,db;
+const scope={branchId:new ObjectId(),license:new ObjectId()};
+const input={create_customer_on_save:true,customer_name:'New Buyer',customer_state:'Goa'};
+before(async()=>{server=await MongoMemoryServer.create();client=await MongoClient.connect(server.getUri());db=client.db('sale_customer_test');});
+after(async()=>{await client?.close();await server?.stop();});
+beforeEach(async()=>{await db.dropDatabase();});
+async function saved(data=input){const planned=await service.plan(db,scope,data);const sale={_id:new ObjectId(),branch_id:scope.branchId,license:scope.license,customer_id:planned.id,customer_name:data.customer_name,customer_phone:data.customer_phone||'',customer_state:data.customer_state,customer_creation_pending:planned.pending};await db.collection('sales').insertOne(sale);return sale;}
+test('planning an abandoned/failed sale writes no customer; successful save creates name-only customer',async()=>{
+ const planned=await service.plan(db,scope,input);assert(planned.pending);assert.equal(await db.collection('customers').countDocuments(),0);
+ const sale=await saved();await service.finish(db,scope,sale._id);const customer=await db.collection('customers').findOne({_id:sale.customer_id});assert.equal(customer.name,'New Buyer');assert.equal(customer.phone,'');assert.equal(customer.state,'Goa');assert(customer.updated_date instanceof Date);
+ assert.equal((await db.collection('sales').findOne({_id:sale._id})).customer_creation_pending,false);
+});
+test('concurrent saves/retries reuse stable id and do not overwrite edited contact data',async()=>{
+ const a=await saved(),b=await saved();assert.equal(String(a.customer_id),String(b.customer_id));await Promise.all([service.finish(db,scope,a._id),service.finish(db,scope,b._id)]);assert.equal(await db.collection('customers').countDocuments(),1);
+ await db.collection('customers').updateOne({_id:a.customer_id},{$set:{phone:'changed',balance:99}});await service.finish(db,scope,a._id);assert.equal((await db.collection('customers').findOne({_id:a.customer_id})).balance,99);
+});
+test('exact existing name is reused case-insensitively; ambiguous names require selection',async()=>{
+ const id=new ObjectId();await db.collection('customers').insertOne({_id:id,branch_id:scope.branchId,license:scope.license,name:'NEW BUYER'});assert.equal(String((await service.plan(db,scope,input)).id),String(id));await db.collection('customers').insertOne({branch_id:scope.branchId,license:scope.license,name:'New Buyer'});await assert.rejects(service.plan(db,scope,input),/Select the correct/);
+});
+test('committed sale recovery works after restart and never crosses branches or revives deletion',async()=>{
+ const sale=await saved();const other={...scope,branchId:new ObjectId()};await service.recover(db,other,'New');assert.equal(await db.collection('customers').countDocuments(),0);await service.recover(db,scope,'New');assert.equal(await db.collection('customers').countDocuments(),1);
+ await db.collection('customers').updateOne({_id:sale.customer_id},{$set:{is_deleted:true}});await assert.rejects(service.plan(db,scope,input),/deleted/);await service.finish(db,scope,sale._id);assert.equal((await db.collection('customers').findOne({_id:sale.customer_id})).is_deleted,true);
+});
+test('cloud and other till reuse the same identifier and preserve references on round-trip',async()=>{
+ const sale=await saved();await service.finish(db,scope,sale._id);const docs=await db.collection('customers').find().toArray();const cloud=client.db('sale_customer_cloud'),till=client.db('sale_customer_other');await cloud.dropDatabase();await till.dropDatabase();for(const target of [cloud,till]){for(const doc of docs)await target.collection('customers').replaceOne({_id:doc._id},doc,{upsert:true});assert.equal(String((await service.plan(target,scope,input)).id),String(sale.customer_id));}assert.notEqual(String(service.customerId({...scope,branchId:new ObjectId()},input.customer_name)),String(sale.customer_id));await cloud.dropDatabase();await till.dropDatabase();
+});
diff --git a/api/tests/unit/services/sale.service.test.js b/api/tests/unit/services/sale.service.test.js
index fe88197ce..28acbcd63 100644
--- a/api/tests/unit/services/sale.service.test.js
+++ b/api/tests/unit/services/sale.service.test.js
@@ -194,7 +194,7 @@ describe('SalesService', () => {
['64f8f2f4c2b9c0a1e4b22222', ' tamil nadu ', '', false],
['64f8f2f4c2b9c0a1e4b22222', 'Karnataka', '', true],
['64f8f2f4c2b9c0a1e4b22222', '29', '', true],
- ['64f8f2f4c2b9c0a1e4b22222', '29', '64f8f2f4c2b9c0a1e4b22222', false],
+ ['64f8f2f4c2b9c0a1e4b22222', '29', '64f8f2f4c2b9c0a1e4b22222', true],
])(
'saves GST by customer state (%s, %s, default %s)',
async (customer_id, customer_state, default_customer, inter) => {
@@ -223,7 +223,7 @@ describe('SalesService', () => {
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');
+ if (!customer_state) expect(saved.customer_state).toBe('Tamil Nadu');
}
);
@@ -330,6 +330,75 @@ describe('SalesService', () => {
expect(mockCustomerRepositoryInstance.findById).not.toHaveBeenCalled();
});
+ test('auto-create is finalized only after a successful sale write', async () => {
+ const helper = require('../../../src/services/sale-customer');
+ const id = new (require('mongodb').ObjectId)();
+ const plan = jest.spyOn(helper, 'plan').mockResolvedValue({ id, pending: true });
+ const finish = jest.spyOn(helper, 'finish').mockResolvedValue();
+ try {
+ mockItemRepositoryInstance.findItemById.mockResolvedValue(makeItemDoc());
+ const result = await salesService.processSale(
+ makeSaleData({ customer_id: '', create_customer_on_save: true }),
+ '',
+ 'Add',
+ makeContext()
+ );
+ expect(result.status).toBe(true);
+ expect(salesRepository.create.mock.calls[0][0].customer_creation_pending).toBe(true);
+ expect(String(salesRepository.create.mock.calls[0][0].customer_id)).toBe(String(id));
+ expect(finish.mock.invocationCallOrder[0]).toBeGreaterThan(
+ salesRepository.create.mock.invocationCallOrder[0]
+ );
+ } finally {
+ plan.mockRestore();
+ finish.mockRestore();
+ }
+ });
+ test('a rejected sale never materializes the planned customer', async () => {
+ const helper = require('../../../src/services/sale-customer');
+ const plan = jest
+ .spyOn(helper, 'plan')
+ .mockResolvedValue({ id: new (require('mongodb').ObjectId)(), pending: true });
+ const finish = jest.spyOn(helper, 'finish').mockResolvedValue();
+ try {
+ mockItemRepositoryInstance.findItemById.mockResolvedValue(null);
+ const result = await salesService.processSale(
+ makeSaleData({ customer_id: '', create_customer_on_save: true }),
+ '',
+ 'Add',
+ makeContext()
+ );
+ expect(result.status).toBe(false);
+ expect(finish).not.toHaveBeenCalled();
+ } finally {
+ plan.mockRestore();
+ finish.mockRestore();
+ }
+ });
+ test('delivery state overrides customer state for GST without changing the customer snapshot', async () => {
+ mockItemRepositoryInstance.findItemById.mockResolvedValue(
+ makeItemDoc({ tax: 18, tax_type: 'exclusive' })
+ );
+ const result = await salesService.processSale(
+ makeSaleData({
+ customer_state: 'Tamil Nadu',
+ place_of_supply: 'Karnataka',
+ sales_total: '236',
+ multi_payment: { Cash: 236 },
+ }),
+ '',
+ 'Add',
+ makeContext({
+ branchState: 'Tamil Nadu',
+ branchSettings: { sortname: 'IN', indian_gst: 'gst_on' },
+ })
+ );
+ expect(result.status).toBe(true);
+ const saved = salesRepository.create.mock.calls[0][0];
+ expect(saved.items[0].igst_tax).toBe(36);
+ expect(saved.customer_state).toBe('Tamil Nadu');
+ expect(saved.place_of_supply).toBe('Karnataka');
+ });
test('name-only customers cannot create a credit account implicitly', async () => {
const result = await salesService.processSale(
makeSaleData({ customer_id: '', customer_name: 'Visitor', partial_check: 'true' }),
diff --git a/docs/ORGANIZED_RECORD_FORMS.md b/docs/ORGANIZED_RECORD_FORMS.md
index cb61f678b..69d661a15 100644
--- a/docs/ORGANIZED_RECORD_FORMS.md
+++ b/docs/ORGANIZED_RECORD_FORMS.md
@@ -20,3 +20,11 @@ New customers, suppliers and branches start with the current shop's country, sta
Ship the API city persistence change and frontend assets together. Electron menu organization needs the next desktop installer; the web form layout does not. Preserve independent recovery windows. Do not publish the desktop latest channel before the requested private installer test.
Local verification includes actual-template DOM regression tests for control ownership and values, tab validation, state-selection races, existing phone/permission/settings tests, API branch creation, translation coverage, frontend build, and isolated Electron-rendered layout previews. These previews do not save live shop records.
+
+## Form polish and shared customer phones
+
+Editors now size to their content, with a viewport height limit and scrolling for long forms. Small record editors use a 560px maximum width; user, customer, supplier, branch and product-category editors retain their wider layout. The branch edit page fills its available content area. Account choices are labelled Staff account and API integration; legacy normal roles display as Staff. Email validation accepts plus addresses and longer top-level domains. Required phone/state validation remains in place.
+
+Customer phone sharing is an explicit per-record opt-in, unchecked for new records. The API normalizes and persists customers.allow_shared_phone; omitted values on edits preserve the saved preference. Duplicate prevention remains the default. Revoking sharing checks for another customer with the same phone, excluding the edited record by ID. Emails and record IDs retain their existing behavior. The live target shop has no unique phone index, so no index removal or data migration is required.
+
+The new boolean is shared business data in the existing branch-scoped customers document and batch lane in the POS, Gateway and agent manifests. Whole-document replication retains the field; IDs, branch access, timestamps, conflict handling and tombstones remain unchanged. On reconnect, the saved preference must travel with the customer to cloud and other authorized tills. No new collections or Gateway/agent configuration are needed. Deploy frontend and API together; desktop offline use needs the updated bundled API. Live cross-device create/update/delete, retry and reconnection were not exercised, and no desktop installer was published for this polish.
diff --git a/frontend/modals/customer.html b/frontend/modals/customer.html
index 82b57da8d..c7b016439 100644
--- a/frontend/modals/customer.html
+++ b/frontend/modals/customer.html
@@ -28,6 +28,7 @@
+
@@ -893,4 +894,4 @@
-
\ No newline at end of file
+
diff --git a/frontend/modals/user.html b/frontend/modals/user.html
index 894ecd45a..39f0043d9 100644
--- a/frontend/modals/user.html
+++ b/frontend/modals/user.html
@@ -47,7 +47,7 @@