Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions api/src/controllers/customers.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '',
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
15 changes: 8 additions & 7 deletions api/src/database/migrations/supplier-email-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
22 changes: 21 additions & 1 deletion api/src/helpers/gst-place.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
2 changes: 1 addition & 1 deletion api/src/models/customer.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions api/src/models/sale.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
Expand Down
3 changes: 2 additions & 1 deletion api/src/repositories/customer.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
Expand Down
24 changes: 16 additions & 8 deletions api/src/repositories/sale.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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'] },
},
{
Expand All @@ -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',
Expand Down Expand Up @@ -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'] },
},
{
Expand All @@ -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' },
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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: [],
Expand Down
10 changes: 7 additions & 3 deletions api/src/services/customer.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
131 changes: 131 additions & 0 deletions api/src/services/sale-customer.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading
Loading