tango-app-api-payment-subscription 3.5.19 → 3.5.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tango-app-api-payment-subscription",
3
- "version": "3.5.19",
3
+ "version": "3.5.21",
4
4
  "description": "paymentSubscription",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -29,7 +29,7 @@
29
29
  "nodemon": "^3.1.0",
30
30
  "puppeteer": "^24.41.0",
31
31
  "swagger-ui-express": "^5.0.0",
32
- "tango-api-schema": "^2.6.36",
32
+ "tango-api-schema": "^2.6.37",
33
33
  "tango-app-api-middleware": "^3.6.18",
34
34
  "winston": "^3.12.0",
35
35
  "winston-daily-rotate-file": "^5.0.0",
@@ -579,14 +579,20 @@ export async function brandInvoiceList( req, res ) {
579
579
  return res.sendError( 'No data', 204 );
580
580
  }
581
581
 
582
+ // Footer totals are shown in a single ₹ figure, but individual invoices may
583
+ // be in USD ($). Convert dollar invoices to INR at today's rate before
584
+ // summing so the ₹ total is correct (mirrors the table's per-invoice symbol
585
+ // → one combined rupee total).
586
+ const usdRate = await getUsdInrRate();
587
+ const toInr = ( inv, value ) => ( inv.currency === 'dollar' ? ( Number( value ) || 0 ) * usdRate : ( Number( value ) || 0 ) );
582
588
  let summary = {
583
589
  totalInvoices: allInvoices.length,
584
- totalInvoiced: allInvoices.reduce( ( sum, inv ) => sum + ( inv.totalAmount || 0 ), 0 ),
590
+ totalInvoiced: Math.round( allInvoices.reduce( ( sum, inv ) => sum + toInr( inv, inv.totalAmount ), 0 ) ),
585
591
  // Footer totals over the FULL filtered set (not just the current page):
586
- // stores, amount excl. GST and amount incl. GST.
592
+ // stores, amount excl. GST and amount incl. GST (USD → INR converted).
587
593
  totalStores: allInvoices.reduce( ( sum, inv ) => sum + ( inv.stores || 0 ), 0 ),
588
- totalAmountExclGst: allInvoices.reduce( ( sum, inv ) => sum + ( inv.amount || 0 ), 0 ),
589
- totalAmountInclGst: allInvoices.reduce( ( sum, inv ) => sum + ( inv.totalAmount || 0 ), 0 ),
594
+ totalAmountExclGst: Math.round( allInvoices.reduce( ( sum, inv ) => sum + toInr( inv, inv.amount ), 0 ) ),
595
+ totalAmountInclGst: Math.round( allInvoices.reduce( ( sum, inv ) => sum + toInr( inv, inv.totalAmount ), 0 ) ),
590
596
  pendingApproval: allInvoices.filter( ( inv ) => [ 'pendingCsm', 'pendingFinance', 'pendingApproval' ].includes( inv.status ) ).length,
591
597
  pendingPayment: allInvoices.filter( ( inv ) => inv.paymentStatus === 'unpaid' && inv.status === 'approved' ).length,
592
598
  paid: allInvoices.filter( ( inv ) => inv.paymentStatus === 'paid' ).length,
@@ -13,7 +13,7 @@ import htmlpdf from 'html-pdf-node';
13
13
  import archiver from 'archiver';
14
14
  import * as basepricingService from '../services/basePrice.service.js';
15
15
  import * as paymentAccountService from '../services/paymentAccount.service.js';
16
- import { symbolFor } from '../utils/currency.js';
16
+ import { symbolFor, roundAmount, wordFor } from '../utils/currency.js';
17
17
  import { invoiceStatusEnum } from '../dtos/validation.dtos.js';
18
18
  import { findOneApplicationDefault } from '../services/applicationDefault.service.js';
19
19
  import * as assignedStoreService from '../services/assignedStore.service.js';
@@ -162,13 +162,14 @@ export async function createInvoice( req, res ) {
162
162
  }
163
163
  // Recompute totals from the (possibly expanded) line items so a
164
164
  // multi-month advance invoice bills the full period.
165
+ const customCurrency = req.body.currency || 'inr';
165
166
  const customAmount = customAdvanceMonths > 1 ?
166
- Math.round( customProducts.reduce( ( s, p ) => s + ( Number( p.amount ) || 0 ), 0 ) ) :
167
- Math.round( Number( req.body.amount ) || 0 );
167
+ roundAmount( customProducts.reduce( ( s, p ) => s + ( Number( p.amount ) || 0 ), 0 ), customCurrency ) :
168
+ roundAmount( Number( req.body.amount ) || 0, customCurrency );
168
169
  const customTotal = customAdvanceMonths > 1 ?
169
- Math.round( customProducts.reduce( ( s, p ) => s + ( Number( p.amount ) || 0 ), 0 ) +
170
- ( Array.isArray( req.body.tax ) ? req.body.tax.reduce( ( s, t ) => s + ( Number( t.taxAmount ) || 0 ) * customAdvanceMonths, 0 ) : 0 ) ) :
171
- Math.round( Number( req.body.totalAmount ) || 0 );
170
+ roundAmount( customProducts.reduce( ( s, p ) => s + ( Number( p.amount ) || 0 ), 0 ) +
171
+ ( Array.isArray( req.body.tax ) ? req.body.tax.reduce( ( s, t ) => s + ( Number( t.taxAmount ) || 0 ) * customAdvanceMonths, 0 ) : 0 ), customCurrency ) :
172
+ roundAmount( Number( req.body.totalAmount ) || 0, customCurrency );
172
173
 
173
174
  const data = {
174
175
  invoice: `${invPrefix}${Finacialyear}-${invoiceNo}`,
@@ -287,11 +288,16 @@ export async function createInvoice( req, res ) {
287
288
  let baseDate = isAdvance ? dayjs().add( 1, 'month' ).startOf( 'month' ) : dayjs();
288
289
  let products;
289
290
 
291
+ // Effective price type: when group-wise pricing applies, the GROUP's own
292
+ // price type (from its basepricing doc) wins; otherwise the client's.
293
+ const { priceType: groupPriceType } = await resolveBasePricingScope( group, getClient );
294
+ const effectivePriceType = groupPriceType || getClient?.priceType;
295
+
290
296
  if ( advanceCoverProducts ) {
291
297
  // Reuse the advance invoice's current-month line items (store counts +
292
298
  // prices already set) and bill them as a normal monthly invoice.
293
299
  products = advanceCoverProducts;
294
- } else if ( getClient?.priceType === 'standard' ) {
300
+ } else if ( effectivePriceType === 'standard' ) {
295
301
  products = await standardPrice( group, getClient, baseDate );
296
302
  } else {
297
303
  products = await stepPrice( group, getClient );
@@ -391,7 +397,7 @@ export async function createInvoice( req, res ) {
391
397
  productName: 'oneTimeFee',
392
398
  period: 'fullmonth',
393
399
  storeCount: newStoreCount,
394
- amount: Math.round( newStoreCount * oneTimeFeePerStore * 100 ) / 100,
400
+ amount: roundAmount( newStoreCount * oneTimeFeePerStore, group.currency ),
395
401
  price: oneTimeFeePerStore,
396
402
  description: `One-Time Fee - ${newStoreCount} stores`,
397
403
  HsnNumber: '998314',
@@ -412,21 +418,21 @@ export async function createInvoice( req, res ) {
412
418
  // existing GST/IGST/CGST/SGST behavior for legacy records that have
413
419
  // no taxCalculationType set yet.
414
420
  if ( group.taxCalculationType === 'international' ) {
415
- totalAmount = Math.round( amount );
421
+ totalAmount = roundAmount( amount, group.currency );
416
422
  } else if ( group.gst && group.gst.slice( 0, 2 ) == '33' ) {
417
423
  let taxAmount = ( amount * 18 ) / 100;
418
- totalAmount = Math.round( amount + taxAmount );
424
+ totalAmount = roundAmount( amount + taxAmount, group.currency );
419
425
  taxList.push(
420
426
  {
421
427
  'currency': '₹',
422
428
  'type': 'CGST',
423
429
  'value': 9,
424
- 'taxAmount': ( ( amount * 9 ) / 100 ).toFixed( 2 ),
430
+ 'taxAmount': String( roundAmount( ( amount * 9 ) / 100, group.currency ) ),
425
431
  }, {
426
432
  'currency': '₹',
427
433
  'type': 'SGST',
428
434
  'value': 9,
429
- 'taxAmount': ( ( amount * 9 ) / 100 ).toFixed( 2 ),
435
+ 'taxAmount': String( roundAmount( ( amount * 9 ) / 100, group.currency ) ),
430
436
  },
431
437
  );
432
438
  } else {
@@ -434,13 +440,13 @@ export async function createInvoice( req, res ) {
434
440
  if ( group.currency === 'inr' ) {
435
441
  taxAmount = ( amount * 18 ) / 100;
436
442
  }
437
- totalAmount = Math.round( amount + taxAmount );
443
+ totalAmount = roundAmount( amount + taxAmount, group.currency );
438
444
  taxList.push(
439
445
  {
440
446
  'currency': '₹',
441
447
  'type': 'IGST',
442
448
  'value': 18,
443
- 'taxAmount': ( taxAmount ).toFixed( 2 ),
449
+ 'taxAmount': String( roundAmount( taxAmount, group.currency ) ),
444
450
  },
445
451
  );
446
452
  }
@@ -523,14 +529,14 @@ export async function createInvoice( req, res ) {
523
529
  invoice: req.body.invoiceId ? req.body.invoiceId : `${invPrefix}${Finacialyear}-${invoiceNo}`,
524
530
  products: products,
525
531
  status: 'pendingCsm',
526
- amount: Math.round( amount ),
532
+ amount: roundAmount( amount, group.currency ),
527
533
  invoiceIndex: req.body.invoiceId ? findInvoice.invoiceIndex : invoiceNo,
528
534
  tax: taxList,
529
535
  companyName: ( group.registeredCompanyName || '' ).toUpperCase(),
530
536
  companyAddress: address,
531
537
  PlaceOfSupply: group.placeOfSupply,
532
538
  GSTNumber: group.gst,
533
- totalAmount: Math.round( totalAmount ),
539
+ totalAmount: roundAmount( totalAmount, group.currency ),
534
540
  clientId: group.clientId,
535
541
  paymentMethod: 'Online',
536
542
  billingDate: new Date( invoicedate ),
@@ -746,8 +752,8 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
746
752
  // store's position within the product.
747
753
  const price = priceForStore( s.productName, productPosition[s.productName] );
748
754
  const runningCost = s.workingdays >= monthDays ?
749
- Math.round( price * units * 100 ) / 100 :
750
- Math.round( ( price / monthDays ) * s.workingdays * units * 100 ) / 100;
755
+ roundAmount( price * units, invoiceInfo.currency ) :
756
+ roundAmount( ( price / monthDays ) * s.workingdays * units, invoiceInfo.currency );
751
757
  return {
752
758
  productName: s.productName ? s.productName.charAt( 0 ).toUpperCase() + s.productName.slice( 1 ) : '',
753
759
  currencyType: invoiceCurrency,
@@ -765,8 +771,9 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
765
771
  };
766
772
  } );
767
773
 
768
- const totalAmount = Math.round( data.reduce( ( a, x ) => a + ( x.runningCost || 0 ), 0 ) * 100 ) / 100;
769
- const totalFormatted = totalAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
774
+ const totalAmount = roundAmount( data.reduce( ( a, x ) => a + ( x.runningCost || 0 ), 0 ), invoiceInfo.currency );
775
+ const fractionDigits = invoiceInfo.currency === 'inr' ? 0 : 2;
776
+ const totalFormatted = totalAmount.toLocaleString( 'en-IN', { minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits } );
770
777
  return { data, totalAmount, totalFormatted };
771
778
  }
772
779
 
@@ -781,20 +788,23 @@ export async function invoiceDownload( req, res ) {
781
788
  // client.paymentInvoice or virtualAccount.currency causes historical
782
789
  // invoices to re-render in the wrong currency if those settings change.
783
790
  const invoiceCurrency = symbolFor( invoiceInfo.currency );
791
+ // INR shows whole numbers; every other currency keeps 2 decimals.
792
+ const fractionDigits = invoiceInfo.currency === 'inr' ? 0 : 2;
793
+ const moneyFmt = { minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits };
784
794
  invoiceInfo.products.forEach( ( item, index ) => {
785
795
  item.index = index + 1;
786
796
  let [ firstWord, secondWord ] = item.productName.replace( /([a-z])([A-Z])/g, '$1 $2' ).split( ' ' );
787
797
  firstWord = firstWord.charAt( 0 ).toUpperCase() + firstWord.slice( 1 );
788
798
  item.productName = firstWord + ' ' + secondWord;
789
- item.price = item.price .toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
790
- item.amount = item.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
799
+ item.price = roundAmount( item.price, invoiceInfo.currency ).toLocaleString( 'en-IN', moneyFmt );
800
+ item.amount = roundAmount( item.amount, invoiceInfo.currency ).toLocaleString( 'en-IN', moneyFmt );
791
801
  item.currency = invoiceCurrency;
792
802
  } );
793
803
 
794
804
 
795
805
  let invoiceDate = dayjs( invoiceInfo.billingDate ).format( 'DD/MM/YYYY' );
796
806
 
797
- invoiceInfo.totalAmount = Math.round( invoiceInfo.totalAmount );
807
+ invoiceInfo.totalAmount = roundAmount( invoiceInfo.totalAmount, invoiceInfo.currency );
798
808
  let AmountinWords = inWords( invoiceInfo.totalAmount );
799
809
  let getgroup;
800
810
  let days = getgroup?.paymentTerm ? getgroup?.paymentTerm : '30';
@@ -809,10 +819,10 @@ export async function invoiceDownload( req, res ) {
809
819
  invoiceData = {
810
820
  ...invoiceInfo._doc,
811
821
  clientName: clientDetails.clientName,
812
- amount: invoiceInfo.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
822
+ amount: roundAmount( invoiceInfo.amount, invoiceInfo.currency ).toLocaleString( 'en-IN', moneyFmt ),
813
823
  extendDays: getgroup?.paymentTerm ? getgroup?.paymentTerm : '30',
814
824
  address: clientDetails.billingDetails.billingAddress,
815
- subtotal: invoiceInfo.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
825
+ subtotal: roundAmount( invoiceInfo.amount, invoiceInfo.currency ).toLocaleString( 'en-IN', moneyFmt ),
816
826
  companyName: invoiceInfo.companyName,
817
827
  companyAddress: invoiceInfo.companyAddress,
818
828
  PlaceOfSupply: invoiceInfo.PlaceOfSupply,
@@ -821,7 +831,8 @@ export async function invoiceDownload( req, res ) {
821
831
  amountwords: AmountinWords,
822
832
  Terms: `Term ${getgroup?.paymentTerm ? getgroup?.paymentTerm : '30'}`,
823
833
  currencyType: invoiceCurrency,
824
- totalAmount: invoiceInfo.totalAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
834
+ currencyWord: wordFor( invoiceInfo.currency ),
835
+ totalAmount: invoiceInfo.totalAmount.toLocaleString( 'en-IN', moneyFmt ),
825
836
  invoiceDate,
826
837
  dueDate,
827
838
  discountPercentage: invoiceInfo.discountPercentage ? invoiceInfo.discountPercentage : 0,
@@ -980,18 +991,21 @@ async function buildInvoicePdfBuffer( invoiceId ) {
980
991
  // field, recorded at creation from the billing group. See invoiceDownload
981
992
  // above for the same pattern.
982
993
  const invoiceCurrency = symbolFor( invoiceInfo.currency );
994
+ // INR shows whole numbers; every other currency keeps 2 decimals.
995
+ const fractionDigits = invoiceInfo.currency === 'inr' ? 0 : 2;
996
+ const moneyFmt = { minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits };
983
997
  invoiceInfo.products.forEach( ( item, index ) => {
984
998
  item.index = index + 1;
985
999
  let [ firstWord, secondWord ] = item.productName.replace( /([a-z])([A-Z])/g, '$1 $2' ).split( ' ' );
986
1000
  firstWord = firstWord.charAt( 0 ).toUpperCase() + firstWord.slice( 1 );
987
1001
  item.productName = firstWord + ' ' + secondWord;
988
- item.price = Math.round( item.price ).toLocaleString( 'en-IN' );
989
- item.amount = item.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
1002
+ item.price = roundAmount( item.price, invoiceInfo.currency ).toLocaleString( 'en-IN', moneyFmt );
1003
+ item.amount = roundAmount( item.amount, invoiceInfo.currency ).toLocaleString( 'en-IN', moneyFmt );
990
1004
  item.currency = invoiceCurrency;
991
1005
  } );
992
1006
 
993
1007
  let invoiceDate = dayjs( invoiceInfo.billingDate ).format( 'DD/MM/YYYY' );
994
- invoiceInfo.totalAmount = Math.round( invoiceInfo.totalAmount );
1008
+ invoiceInfo.totalAmount = roundAmount( invoiceInfo.totalAmount, invoiceInfo.currency );
995
1009
  let AmountinWords = inWords( invoiceInfo.totalAmount );
996
1010
  let getgroup;
997
1011
  if ( invoiceInfo.groupId ) {
@@ -1005,10 +1019,10 @@ async function buildInvoicePdfBuffer( invoiceId ) {
1005
1019
  let invoiceData = {
1006
1020
  ...invoiceInfo._doc,
1007
1021
  clientName: clientDetails.clientName,
1008
- amount: invoiceInfo.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
1022
+ amount: roundAmount( invoiceInfo.amount, invoiceInfo.currency ).toLocaleString( 'en-IN', moneyFmt ),
1009
1023
  extendDays: getgroup?.paymentTerm ? getgroup?.paymentTerm : '30',
1010
1024
  address: clientDetails.billingDetails.billingAddress,
1011
- subtotal: invoiceInfo.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
1025
+ subtotal: roundAmount( invoiceInfo.amount, invoiceInfo.currency ).toLocaleString( 'en-IN', moneyFmt ),
1012
1026
  companyName: invoiceInfo.companyName,
1013
1027
  companyAddress: invoiceInfo.companyAddress,
1014
1028
  PlaceOfSupply: invoiceInfo.PlaceOfSupply,
@@ -1017,7 +1031,8 @@ async function buildInvoicePdfBuffer( invoiceId ) {
1017
1031
  amountwords: AmountinWords,
1018
1032
  Terms: `Term ${getgroup?.paymentTerm ? getgroup?.paymentTerm : '30'}`,
1019
1033
  currencyType: invoiceCurrency,
1020
- totalAmount: invoiceInfo.totalAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
1034
+ currencyWord: wordFor( invoiceInfo.currency ),
1035
+ totalAmount: invoiceInfo.totalAmount.toLocaleString( 'en-IN', moneyFmt ),
1021
1036
  invoiceDate,
1022
1037
  dueDate,
1023
1038
  discountPercentage: invoiceInfo.discountPercentage ? invoiceInfo.discountPercentage : 0,
@@ -1207,20 +1222,25 @@ function inWords( num ) {
1207
1222
  // Resolve which basepricing doc applies to a billing group. When the client
1208
1223
  // has billingGroupWisePricing enabled AND a doc exists for this group, returns
1209
1224
  // that group's doc; otherwise falls back to the brand-level doc (groupId unset).
1210
- // Returns { query, groupId } where query is the mongo filter for the chosen doc
1211
- // and groupId is the group id to use in aggregation lookups (or null for brand).
1225
+ // Returns { query, groupId, priceType } where query is the mongo filter for the
1226
+ // chosen doc, groupId is the id for aggregation lookups (null for brand), and
1227
+ // priceType is the group's own price type when group-wise (else null).
1212
1228
  async function resolveBasePricingScope( group, getClient ) {
1213
1229
  const brandQuery = { clientId: group.clientId, groupId: { $exists: false } };
1214
1230
  if ( getClient?.billingGroupWisePricing && group?._id ) {
1215
1231
  const groupIdStr = String( group._id );
1216
1232
  const groupDoc = await basepricingService.findOne(
1217
- { clientId: group.clientId, groupId: groupIdStr }, { _id: 1 },
1233
+ { clientId: group.clientId, groupId: groupIdStr }, { _id: 1, priceType: 1 },
1218
1234
  );
1219
1235
  if ( groupDoc ) {
1220
- return { query: { clientId: group.clientId, groupId: groupIdStr }, groupId: groupIdStr };
1236
+ return {
1237
+ query: { clientId: group.clientId, groupId: groupIdStr },
1238
+ groupId: groupIdStr,
1239
+ priceType: groupDoc.priceType || null,
1240
+ };
1221
1241
  }
1222
1242
  }
1223
- return { query: brandQuery, groupId: null };
1243
+ return { query: brandQuery, groupId: null, priceType: null };
1224
1244
  }
1225
1245
 
1226
1246
  async function standardPrice( group, getClient, baseDate ) {
@@ -1628,7 +1648,7 @@ async function standardPrice( group, getClient, baseDate ) {
1628
1648
  if ( store.workingDays >= currentMonthDays ) {
1629
1649
  amount = store.price * storeCount;
1630
1650
  } else {
1631
- amount = Math.round( ( store.price / currentMonthDays ) * store.workingDays * storeCount * 100 ) / 100;
1651
+ amount = roundAmount( ( store.price / currentMonthDays ) * store.workingDays * storeCount, group.currency );
1632
1652
  }
1633
1653
 
1634
1654
  let description = store.productName === 'tangoZone' ? 'Product category/section analytics' : 'Customer Footfall Analytics';
@@ -1827,7 +1847,7 @@ async function stepPrice( group, getClient ) {
1827
1847
  if ( store.workingDays >= currentMonthDays ) {
1828
1848
  amount = price * storeCount;
1829
1849
  } else {
1830
- amount = Math.round( ( price / currentMonthDays ) * store.workingDays * storeCount * 100 ) / 100;
1850
+ amount = roundAmount( ( price / currentMonthDays ) * store.workingDays * storeCount, group.currency );
1831
1851
  }
1832
1852
 
1833
1853
  let description = store.productName === 'tangoZone' ? 'Product category/section analytics' : 'Customer Footfall Analytics';
@@ -1903,8 +1923,8 @@ async function stepPrice( group, getClient ) {
1903
1923
  const price = tierPriceForPosition( s.productName, productPosition[s.productName] );
1904
1924
  const fullMonth = s.workingDays >= currentMonthDays;
1905
1925
  const amount = fullMonth ?
1906
- Math.round( units * price * 100 ) / 100 :
1907
- Math.round( ( units * ( price / currentMonthDays ) * s.workingDays ) * 100 ) / 100;
1926
+ roundAmount( units * price, group.currency ) :
1927
+ roundAmount( units * ( price / currentMonthDays ) * s.workingDays, group.currency );
1908
1928
  const period = fullMonth ? 'fullMonth' : 'proRate';
1909
1929
  const key = `${s.productName}_${period}_${price}`;
1910
1930
  if ( !grouped[key] ) {
@@ -1921,7 +1941,8 @@ async function stepPrice( group, getClient ) {
1921
1941
  } else if ( grp.productName === 'tangoZone' ) {
1922
1942
  description = 'Product category/section analytics';
1923
1943
  }
1924
- const amount = Math.round( grp.totalAmount * 100 ) / 100;
1944
+ const amount = roundAmount( grp.totalAmount, group.currency );
1945
+ const zeroPrice = group.currency === 'inr' ? '0' : '0.00';
1925
1946
  return {
1926
1947
  productName: grp.productName,
1927
1948
  period: grp.period,
@@ -1933,7 +1954,7 @@ async function stepPrice( group, getClient ) {
1933
1954
  HsnNumber: '998314',
1934
1955
  amount: amount,
1935
1956
  month: dayjs().format( 'MMM YYYY' ),
1936
- price: grp.unitCount ? ( amount / grp.unitCount ).toFixed( 2 ) : '0.00',
1957
+ price: grp.unitCount ? String( roundAmount( amount / grp.unitCount, group.currency ) ) : zeroPrice,
1937
1958
  };
1938
1959
  } );
1939
1960
 
@@ -2219,9 +2240,11 @@ export async function clientInvoiceList( req, res ) {
2219
2240
  cards.pendingPaymentCount += 1;
2220
2241
  }
2221
2242
  }
2222
- cards.outstandingAmount = Math.round( cards.outstandingAmount * 100 ) / 100;
2223
- cards.overdueAmount = Math.round( cards.overdueAmount * 100 ) / 100;
2224
- cards.pendingPaymentAmount = Math.round( cards.pendingPaymentAmount * 100 ) / 100;
2243
+ // These card totals are normalised to INR (dollar invoices are multiplied
2244
+ // by usdRate above), so they follow the INR rule: whole numbers.
2245
+ cards.outstandingAmount = roundAmount( cards.outstandingAmount, 'inr' );
2246
+ cards.overdueAmount = roundAmount( cards.overdueAmount, 'inr' );
2247
+ cards.pendingPaymentAmount = roundAmount( cards.pendingPaymentAmount, 'inr' );
2225
2248
 
2226
2249
  res.sendSuccess( { count: count.length, data: invoiceList, cards } );
2227
2250
  } catch ( error ) {
@@ -2353,14 +2376,14 @@ export async function applyDiscount( req, res ) {
2353
2376
  try {
2354
2377
  let invoice = await invoiceService.findOne( { invoice: req.body.invoice } );
2355
2378
  if ( invoice ) {
2356
- invoice.discountAmount = ( ( invoice.amount * req.body.discount ) / 100 ).toFixed( 2 );
2357
- invoice.amount = invoice.amount - invoice.discountAmount;
2379
+ invoice.discountAmount = roundAmount( ( invoice.amount * req.body.discount ) / 100, invoice.currency );
2380
+ invoice.amount = roundAmount( invoice.amount - invoice.discountAmount, invoice.currency );
2358
2381
  invoice.discountPercentage = req.body.discount;
2359
2382
  if ( invoice.currency === 'inr' ) {
2360
2383
  if ( invoice.tax.length ) {
2361
2384
  for ( let i = 0; i < invoice.tax.length; i++ ) {
2362
- invoice.tax[i].taxAmount = ( ( invoice.amount * invoice.tax[i].value ) / 100 ).toFixed( 2 );
2363
- invoice.totalAmount = ( Number( invoice.amount ) + Number( invoice.tax[i].taxAmount ) ).toFixed( 2 );
2385
+ invoice.tax[i].taxAmount = String( roundAmount( ( invoice.amount * invoice.tax[i].value ) / 100, invoice.currency ) );
2386
+ invoice.totalAmount = roundAmount( Number( invoice.amount ) + Number( invoice.tax[i].taxAmount ), invoice.currency );
2364
2387
  }
2365
2388
  }
2366
2389
  }
@@ -2490,7 +2513,7 @@ export async function recordPayment( req, res ) {
2490
2513
  }
2491
2514
 
2492
2515
  const previousPaid = Number( invoice.paidAmount ) || 0;
2493
- const newPaid = Math.round( ( previousPaid + amountNum ) * 100 ) / 100;
2516
+ const newPaid = roundAmount( previousPaid + amountNum, invoice.currency );
2494
2517
  const totalAmount = Number( invoice.totalAmount ) || 0;
2495
2518
 
2496
2519
  // Reject overpayment — finance teams want this caught early. They can
@@ -2498,7 +2521,7 @@ export async function recordPayment( req, res ) {
2498
2521
  // invoice total; anything beyond that is a data-entry error.
2499
2522
  if ( totalAmount > 0 && newPaid > totalAmount + 0.01 ) {
2500
2523
  return res.sendError(
2501
- `Payment exceeds outstanding balance. Outstanding: ${( totalAmount - previousPaid ).toFixed( 2 )}`,
2524
+ `Payment exceeds outstanding balance. Outstanding: ${roundAmount( totalAmount - previousPaid, invoice.currency )}`,
2502
2525
  400,
2503
2526
  );
2504
2527
  }
@@ -13,11 +13,13 @@ import * as cameraService from '../services/camera.service.js';
13
13
  import * as billingService from '../services/billing.service.js';
14
14
  import * as paymentAccountService from '../services/paymentAccount.service.js';
15
15
  import * as taggingService from '../services/tagging.service.js';
16
- import { symbolFor } from '../utils/currency.js';
16
+ import { symbolFor, roundAmount, wordFor } from '../utils/currency.js';
17
17
 
18
18
 
19
19
  import dayjs from 'dayjs';
20
- import Handlebars from 'handlebars';
20
+ // Use the shared Handlebars instance that has our custom helpers
21
+ // (ifExists / neq / gtZero) registered, so invoicePdf1.hbs can use them.
22
+ import Handlebars from '../utils/validations/helper/handlebar.helper.js';
21
23
  import fs from 'fs';
22
24
  import path from 'path';
23
25
  // import { JSDOM } from 'jsdom';
@@ -2256,12 +2258,15 @@ export const priceList = async ( req, res ) => {
2256
2258
  } else {
2257
2259
  priceQuery.groupId = { $exists: false };
2258
2260
  }
2259
- let pricingDetails = await basePricingService.findOne( priceQuery, { standard: 1, step: 1, oneTimeFeePerStore: 1 } );
2261
+ let pricingDetails = await basePricingService.findOne( priceQuery, { standard: 1, step: 1, oneTimeFeePerStore: 1, priceType: 1 } );
2260
2262
  if ( !pricingDetails ) {
2261
2263
  return res.sendError( 'no data found', 204 );
2262
2264
  }
2265
+ // The price type for THIS doc: the doc's own priceType wins (group-wise
2266
+ // pricing stores it per group); fall back to the requested priceType.
2267
+ const effectivePriceType = pricingDetails.priceType || req.body.priceType;
2263
2268
  let data = [];
2264
- if ( req.body.priceType == 'standard' ) {
2269
+ if ( effectivePriceType == 'standard' ) {
2265
2270
  data = pricingDetails.standard;
2266
2271
  } else {
2267
2272
  data = pricingDetails.step;
@@ -2301,7 +2306,7 @@ export const priceList = async ( req, res ) => {
2301
2306
  if ( !product.billingType ) {
2302
2307
  product.billingType = 'perStore';
2303
2308
  }
2304
- if ( req.body.priceType == 'standard' ) {
2309
+ if ( effectivePriceType == 'standard' ) {
2305
2310
  product.storeCount = item.storeCount;
2306
2311
  } else {
2307
2312
  product.showImg = false;
@@ -2351,6 +2356,8 @@ export const priceList = async ( req, res ) => {
2351
2356
  let finalValue = parseFloat( discountTotalPrice ) + gstAmount;
2352
2357
  let result = {
2353
2358
  product: data,
2359
+ // The price type actually used for this doc (group-wise type wins).
2360
+ priceType: effectivePriceType,
2354
2361
  oneTimeFeePerStore: pricingDetails.oneTimeFeePerStore != null ? pricingDetails.oneTimeFeePerStore : null,
2355
2362
  totalActualPrice: originalTotalPrice,
2356
2363
  totalNegotiatePrice: discountTotalPrice.toFixed( 2 ),
@@ -2516,16 +2523,23 @@ export const pricingListUpdate = async ( req, res ) => {
2516
2523
  if ( req.body.oneTimeFeePerStore != null && req.body.oneTimeFeePerStore !== '' ) {
2517
2524
  getPriceInfo.oneTimeFeePerStore = Number( req.body.oneTimeFeePerStore ) || 0;
2518
2525
  }
2519
- // Keep the group identity on the doc when saving group-wise pricing.
2526
+ // Keep the group identity + price type on the doc when saving group-wise
2527
+ // pricing, so each group remembers its own standard/step choice.
2520
2528
  if ( req.body.groupId ) {
2521
2529
  getPriceInfo.groupId = req.body.groupId;
2522
2530
  if ( req.body.groupName ) {
2523
2531
  getPriceInfo.groupName = req.body.groupName;
2524
2532
  }
2533
+ getPriceInfo.priceType = req.body.type;
2525
2534
  }
2526
2535
  getPriceInfo.save().then( async () => {
2527
2536
  let clientDetails = await paymentService.findOne( { clientId: req.body.clientId }, { priceType: 1, paymentInvoice: 1, planDetails: 1 } );
2528
- clientDetails.priceType = req.body.type;
2537
+ // For group-wise pricing the client-level priceType must NOT change —
2538
+ // each group keeps its own type on its basepricing doc. Only update the
2539
+ // client's priceType for brand-level (non-group) saves.
2540
+ if ( !req.body.groupId ) {
2541
+ clientDetails.priceType = req.body.type;
2542
+ }
2529
2543
 
2530
2544
  // Update billingType per product in client's planDetails
2531
2545
  if ( req.body.products && req.body.products.length && clientDetails.planDetails?.product?.length ) {
@@ -2690,11 +2704,15 @@ async function updatePricing( req, res, update ) {
2690
2704
  let product = [];
2691
2705
  let clientId = req.body.clientId;
2692
2706
  let paymentInvoice = clientDetails.paymentInvoice;
2693
- if ( !update ) {
2707
+ // Group-wise pricing only creates/updates the GROUP's basepricing doc — it
2708
+ // must NOT re-run the client-level first-time setup (which resets the
2709
+ // client's currencyType, invoice recipients and product trial statuses).
2710
+ // That initialization is only for a brand-level (non-group) first save.
2711
+ if ( !update && !req.body.groupId ) {
2694
2712
  let userDetails = await userService.findOne( { clientId: req.body.clientId, role: 'superadmin' } );
2695
2713
  paymentInvoice.invoiceTo = [ userDetails.email ];
2696
2714
  paymentInvoice.paymentAgreementTo = [ userDetails.email ];
2697
- paymentInvoice.currencyType = userDetails.countryCode == '91' ? 'inr' : 'dollar';
2715
+ // paymentInvoice.currencyType = userDetails.countryCode == '91' ? 'inr' : 'dollar';
2698
2716
  clientDetails.planDetails.product.forEach( ( item ) => {
2699
2717
  product.push( {
2700
2718
  productName: item.productName,
@@ -2706,6 +2724,12 @@ async function updatePricing( req, res, update ) {
2706
2724
  } else {
2707
2725
  product = clientDetails.planDetails.product;
2708
2726
  }
2727
+ // Group-wise pricing: stop here — the group's basepricing doc was already
2728
+ // created/updated above. Do NOT touch the client's priceType, currency,
2729
+ // products or payment status (that's brand-level state only).
2730
+ if ( req.body.groupId ) {
2731
+ return;
2732
+ }
2709
2733
  req.body = {
2710
2734
  'camaraPerSqft': clientDetails.planDetails.storeSize,
2711
2735
  'storesCount': clientDetails.planDetails.totalStores,
@@ -3070,6 +3094,10 @@ export const invoiceDownload = async ( req, res ) => {
3070
3094
  if ( invoiceInfo ) {
3071
3095
  let clientDetails = await paymentService.findOne( { clientId: invoiceInfo.clientId } );
3072
3096
  let amount = 0;
3097
+ // INR shows whole numbers; every other currency keeps 2 decimals.
3098
+ const invoicePdfCurrency = clientDetails?.paymentInvoice?.currencyType;
3099
+ const invoicePdfFractionDigits = invoicePdfCurrency === 'inr' ? 0 : 2;
3100
+ const invoicePdfMoneyFmt = { minimumFractionDigits: invoicePdfFractionDigits, maximumFractionDigits: invoicePdfFractionDigits };
3073
3101
  invoiceInfo.products.forEach( ( item, index ) => {
3074
3102
  let [ firstWord, secondWord ] = item.product.product.replace( /([a-z])([A-Z])/g, '$1 $2' ).split( ' ' );
3075
3103
  firstWord = firstWord.charAt( 0 ).toUpperCase() + firstWord.slice( 1 );
@@ -3085,12 +3113,12 @@ export const invoiceDownload = async ( req, res ) => {
3085
3113
 
3086
3114
  amount = amount + item.price;
3087
3115
 
3088
- item.basePrice = Math.round( item.basePrice ).toLocaleString( 'en-IN' );
3089
- item.price = Math.round( item.price ).toLocaleString( 'en-IN' );
3116
+ item.basePrice = roundAmount( item.basePrice, invoicePdfCurrency ).toLocaleString( 'en-IN', invoicePdfMoneyFmt );
3117
+ item.price = roundAmount( item.price, invoicePdfCurrency ).toLocaleString( 'en-IN', invoicePdfMoneyFmt );
3090
3118
  item.currency = symbolFor( clientDetails?.paymentInvoice?.currencyType );
3091
3119
  } );
3092
3120
  for ( let tax of invoiceInfo.tax ) {
3093
- tax.taxAmount = tax.taxAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
3121
+ tax.taxAmount = roundAmount( tax.taxAmount, invoicePdfCurrency ).toLocaleString( 'en-IN', invoicePdfMoneyFmt );
3094
3122
  }
3095
3123
  let invoiceDate = dayjs( invoiceInfo.createdAt ).format( 'DD/MM/YYYY' );
3096
3124
  let days = clientDetails?.paymentInvoice?.extendPaymentPeriodDays || 10;
@@ -3103,7 +3131,7 @@ export const invoiceDownload = async ( req, res ) => {
3103
3131
  clientName: clientDetails.clientName,
3104
3132
  extendDays: clientDetails.paymentInvoice.extendPaymentPeriodDays,
3105
3133
  address: clientDetails.billingDetails.billingAddress,
3106
- subtotal: amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
3134
+ subtotal: roundAmount( amount, invoicePdfCurrency ).toLocaleString( 'en-IN', invoicePdfMoneyFmt ),
3107
3135
  companyName: invoiceInfo.companyName,
3108
3136
  companyAddress: invoiceInfo.companyAddress,
3109
3137
  PlaceOfSupply: invoiceInfo.PlaceOfSupply,
@@ -3112,7 +3140,8 @@ export const invoiceDownload = async ( req, res ) => {
3112
3140
  amountwords: AmountinWords,
3113
3141
  Terms: `Term ${clientDetails.paymentInvoice.extendPaymentPeriodDays}`,
3114
3142
  currencyType: symbolFor( clientDetails?.paymentInvoice?.currencyType ),
3115
- totalAmount: invoiceInfo.totalAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
3143
+ currencyName: wordFor( invoicePdfCurrency ),
3144
+ totalAmount: roundAmount( invoiceInfo.totalAmount, invoicePdfCurrency ).toLocaleString( 'en-IN', invoicePdfMoneyFmt ),
3116
3145
  invoiceDate,
3117
3146
  dueDate,
3118
3147
  };
@@ -1401,12 +1401,14 @@
1401
1401
  <div class="chennai-tamil-nadu-600006-india">
1402
1402
 
1403
1403
  </div>
1404
- <div class="gstin-33-aagct-3124-r-1-z-2">GSTIN {{GSTNumber}}</div>
1404
+ {{#ifExists GSTNumber}}<div class="gstin-33-aagct-3124-r-1-z-2">GSTIN {{GSTNumber}}</div>{{/ifExists}}
1405
1405
  </div>
1406
1406
  </div>
1407
+ {{#ifExists PlaceOfSupply}}
1407
1408
  <div class="place_of_supply">
1408
1409
  Place Of Supply: {{PlaceOfSupply}}
1409
1410
  </div>
1411
+ {{/ifExists}}
1410
1412
  </div>
1411
1413
  <div class="frame-2698">
1412
1414
  <div class="frame-9155">
@@ -1560,7 +1562,7 @@
1560
1562
  <div class="frame-23972">
1561
1563
  {{#neq discountAmount 0}}
1562
1564
  <div class="frame-2394">
1563
- <div class="text9">Discount ({{discountPercentage}}%)</div>
1565
+ <div class="text9">Discount</div>
1564
1566
  <div class="frame-9157">
1565
1567
  <div class="text10">{{currencyType}} {{discountAmount}}</div>
1566
1568
  </div>
@@ -1575,12 +1577,14 @@
1575
1577
 
1576
1578
  {{#if gstApplicable}}
1577
1579
  {{#each tax }}
1580
+ {{#gtZero taxAmount}}
1578
1581
  <div class="frame-2392">
1579
1582
  <div class="text9">{{type}} ({{value}}%)</div>
1580
1583
  <div class="frame-9157">
1581
1584
  <div class="text10">{{../currencyType}} {{taxAmount}}</div>
1582
1585
  </div>
1583
1586
  </div>
1587
+ {{/gtZero}}
1584
1588
  {{/each }}
1585
1589
  {{/if}}
1586
1590
 
@@ -1605,22 +1609,7 @@
1605
1609
  <div class="text11">Total In Words</div>
1606
1610
  <div class="frame-9157">
1607
1611
  <div class="text13">
1608
- {{#eq billingCurrency 'inr'}}
1609
- Indian Rupee
1610
- {{/eq}}
1611
-
1612
- {{#eq billingCurrency 'dollar'}}
1613
- US Dollar
1614
- {{/eq}}
1615
-
1616
- {{#eq billingCurrency 'singaporedollar'}}
1617
- Singapore Dollar
1618
- {{/eq}}
1619
-
1620
- {{#eq billingCurrency 'euro'}}
1621
- Euro
1622
- {{/eq}}
1623
- {{amountwords}} Only
1612
+ {{currencyWord}} {{amountwords}} Only
1624
1613
  </div>
1625
1614
  </div>
1626
1615
  </div>
@@ -1368,16 +1368,20 @@
1368
1368
  class="billing-address-given-by-the-client-flat-no-012-ground-floor-the-banyan-apartment-jsr-layout-j-p-nagar-9th-phase-alahalli-anjanapura-post-bengaluru-560062-karnataka-india">
1369
1369
  {{companyAddress}}
1370
1370
  </div>
1371
+ {{#ifExists GSTNumber}}
1371
1372
  <div class="frame-562">
1372
1373
  <div class="gstin-33-aafci-2595-g-1-z-9"> GSTIN {{GSTNumber}}</div>
1373
1374
  </div>
1375
+ {{/ifExists}}
1374
1376
  </div>
1375
1377
  </div>
1376
1378
  </div>
1377
1379
  </div>
1380
+ {{#ifExists PlaceOfSupply}}
1378
1381
  <div class="place-of-supply-tamil-nadu-33">
1379
1382
  Place Of Supply: {{PlaceOfSupply}}
1380
1383
  </div>
1384
+ {{/ifExists}}
1381
1385
  </div>
1382
1386
  <div class="invoice-info">
1383
1387
  <div class="card-header">
@@ -1496,12 +1500,14 @@
1496
1500
  </div>
1497
1501
  </div>
1498
1502
  {{#each tax }}
1503
+ {{#gtZero taxAmount}}
1499
1504
  <div class="frame-2392">
1500
1505
  <div class="text9">{{type}} ({{value}}%)</div>
1501
1506
  <div class="frame-9157">
1502
1507
  <div class="text10">{{currency}} {{taxAmount}}</div>
1503
1508
  </div>
1504
1509
  </div>
1510
+ {{/gtZero}}
1505
1511
  {{/each }}
1506
1512
  <!-- <div class="frame-2398">
1507
1513
  <div class="text9">CGST (9%)</div>
@@ -13,3 +13,36 @@ export const CURRENCY_SYMBOLS = {
13
13
  };
14
14
 
15
15
  export const symbolFor = ( currency ) => CURRENCY_SYMBOLS[currency] ?? '$';
16
+
17
+ /**
18
+ * Words form of each currency, used in the invoice "Total In Words" line.
19
+ * Keys match billing.currency enums. Unknown keys fall back to 'US Dollar'
20
+ * to match the pre-existing default.
21
+ */
22
+ export const CURRENCY_WORDS = {
23
+ inr: 'Indian Rupee',
24
+ dollar: 'US Dollar',
25
+ singaporedollar: 'Singapore Dollar',
26
+ euro: 'Euro',
27
+ aed: 'UAE Dirham',
28
+ };
29
+
30
+ export const wordFor = ( currency ) => CURRENCY_WORDS[currency] ?? 'US Dollar';
31
+
32
+ /**
33
+ * Currency-aware monetary rounding.
34
+ * - INR -> rounded to a whole number (no decimals).
35
+ * - every other currency -> kept to exactly 2 decimal places.
36
+ * Returns a Number (not a string) so downstream arithmetic still works.
37
+ * Non-finite input is normalised to 0.
38
+ *
39
+ * @param {number|string} value - the monetary value to round.
40
+ * @param {string} currency - the currency code (e.g. 'inr', 'dollar').
41
+ * @return {number} the rounded amount.
42
+ */
43
+ export const roundAmount = ( value, currency ) => {
44
+ const n = Number( value );
45
+ if ( !isFinite( n ) ) return 0;
46
+ if ( currency === 'inr' ) return Math.round( n );
47
+ return Math.round( n * 100 ) / 100;
48
+ };
@@ -13,5 +13,11 @@ Handlebars.registerHelper( 'gte', function( a, b, options ) {
13
13
  Handlebars.registerHelper( 'ifExists', function( value, options ) {
14
14
  return value ? options.fn( this ) : options.inverse( this );
15
15
  } );
16
+ // Renders the block only when `value` parses to a number greater than zero.
17
+ // Used to hide tax lines (IGST/CGST/SGST) whose amount is 0 / "0" / "0.00".
18
+ Handlebars.registerHelper( 'gtZero', function( value, options ) {
19
+ const n = Number( String( value ).replace( /,/g, '' ) );
20
+ return isFinite( n ) && n > 0 ? options.fn( this ) : options.inverse( this );
21
+ } );
16
22
 
17
23
  export default Handlebars;