tango-app-api-payment-subscription 3.5.20 → 3.5.22

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.20",
3
+ "version": "3.5.22",
4
4
  "description": "paymentSubscription",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -1944,7 +1944,11 @@ export async function billingSummary( req, res ) {
1944
1944
  csm: [ ...( csmByClient.get( key ) || [] ) ].join( ', ' ),
1945
1945
  revenueMonths: {},
1946
1946
  billedStoresMonths: {},
1947
- installationFee: 0,
1947
+ // Installation fee per month (keyed YYYY-MM). The summary surfaces
1948
+ // ONLY the current month's value — see installationOut below — so the
1949
+ // column stays hidden until the current-month invoice is generated
1950
+ // with an installation fee.
1951
+ installationMonths: {},
1948
1952
  invDollar: 0,
1949
1953
  invInr: 0,
1950
1954
  } );
@@ -1956,7 +1960,8 @@ export async function billingSummary( req, res ) {
1956
1960
  const r = rowOf( inv._id.c );
1957
1961
  r.revenueMonths[inv._id.ym] = Math.round( ( ( inv.revenueInr || 0 ) + ( inv.revenueUsd || 0 ) * usdRate ) * 100 ) / 100;
1958
1962
  r.billedStoresMonths[inv._id.ym] = inv.stores || 0;
1959
- r.installationFee += ( inv.installationInr || 0 ) + ( inv.installationUsd || 0 ) * usdRate;
1963
+ r.installationMonths[inv._id.ym] = ( r.installationMonths[inv._id.ym] || 0 ) +
1964
+ ( inv.installationInr || 0 ) + ( inv.installationUsd || 0 ) * usdRate;
1960
1965
  r.invDollar += ( inv.dollarInvoices || 0 );
1961
1966
  r.invInr += ( inv.inrInvoices || 0 );
1962
1967
  if ( inv.companyName ) {
@@ -2074,7 +2079,11 @@ export async function billingSummary( req, res ) {
2074
2079
  // in native USD for dollar clients.
2075
2080
  revCur = revCur == null ? null : Math.round( revCur );
2076
2081
  const revPrevOut = revPrev == null ? null : Math.round( revPrev );
2077
- const installationOut = r.installationFee ? Math.round( r.installationFee ) : null;
2082
+ // Show the installation fee ONLY for the current month, and only once
2083
+ // that month's invoice (which carries the installationFee line) exists.
2084
+ // Until then it's null so the UI hides the whole column.
2085
+ const curInstallation = r.installationMonths[curKey] || 0;
2086
+ const installationOut = curInstallation ? Math.round( curInstallation ) : null;
2078
2087
  // Registered Entity from the billings collection (distinct group names).
2079
2088
  // First name shown in the column; the full list is sent so the UI can
2080
2089
  // reveal the others on hover. Fall back to the invoice company name when
@@ -7,7 +7,7 @@ import Handlebars from '../utils/validations/helper/handlebar.helper.js';
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
9
  import htmlpdf from 'html-pdf-node';
10
- import { symbolFor } from '../utils/currency.js';
10
+ import { symbolFor, roundAmount } from '../utils/currency.js';
11
11
  import { getInvoiceCcEmails } from './invoice.controller.js';
12
12
 
13
13
  // Build the estimate PDF buffer + the template data + a safe filename. Shared
@@ -45,7 +45,9 @@ async function buildEstimatePdf( estimate ) {
45
45
  companyAddress: e.companyAddress || '',
46
46
  GSTNumber: e.GSTNumber || '',
47
47
  PlaceOfSupply: e.PlaceOfSupply || '',
48
- groupName: e.groupName || '',
48
+ // "Default Group" is the placeholder used when no real billing group was
49
+ // chosen — don't surface it on the estimate (the template hides empty).
50
+ groupName: ( e.groupName && e.groupName !== 'Default Group' ) ? e.groupName : '',
49
51
  period: e.period || '',
50
52
  createdDate: e.createdDate ? dayjs( e.createdDate ).format( 'DD/MM/YYYY' ) : '',
51
53
  validTill: e.validTill ? dayjs( e.validTill ).format( 'DD/MM/YYYY' ) : '',
@@ -211,11 +213,13 @@ export async function createEstimate( req, res ) {
211
213
  { 'clientName': 1, 'paymentInvoice.currencyType': 1 },
212
214
  );
213
215
 
214
- const amount = Math.round( Number( b.amount ) || 0 );
215
- let totalAmount = Math.round( Number( b.totalAmount ) || 0 );
216
+ // INR rounds to whole; other currencies keep 2 decimals.
217
+ const estimateCurrency = b.currency || ( client?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr' );
218
+ const amount = roundAmount( Number( b.amount ) || 0, estimateCurrency );
219
+ let totalAmount = roundAmount( Number( b.totalAmount ) || 0, estimateCurrency );
216
220
  if ( !totalAmount && amount ) {
217
221
  // Default to 18% GST when caller sends only the pre-tax amount.
218
- totalAmount = Math.round( amount * 1.18 );
222
+ totalAmount = roundAmount( amount * 1.18, estimateCurrency );
219
223
  }
220
224
 
221
225
  const { estimate, estimateIndex } = await nextEstimateNumber();
@@ -239,7 +243,7 @@ export async function createEstimate( req, res ) {
239
243
  tax: Array.isArray( b.tax ) ? b.tax : [],
240
244
  amount,
241
245
  totalAmount,
242
- currency: b.currency || ( client?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr' ),
246
+ currency: estimateCurrency,
243
247
  status: b.status === 'sent' ? 'sent' : 'pending',
244
248
  createdDate,
245
249
  validTill,
@@ -273,10 +277,12 @@ export async function updateEstimate( req, res ) {
273
277
  return res.sendError( 'An accepted estimate cannot be edited.', 409 );
274
278
  }
275
279
 
276
- const amount = Math.round( Number( b.amount ) || 0 );
277
- let totalAmount = Math.round( Number( b.totalAmount ) || 0 );
280
+ // INR rounds to whole; other currencies keep 2 decimals.
281
+ const estimateCurrency = b.currency || existing.currency;
282
+ const amount = roundAmount( Number( b.amount ) || 0, estimateCurrency );
283
+ let totalAmount = roundAmount( Number( b.totalAmount ) || 0, estimateCurrency );
278
284
  if ( !totalAmount && amount ) {
279
- totalAmount = Math.round( amount * 1.18 );
285
+ totalAmount = roundAmount( amount * 1.18, estimateCurrency );
280
286
  }
281
287
 
282
288
  // Only the editable fields are updated. The estimate number, index,
@@ -294,7 +300,7 @@ export async function updateEstimate( req, res ) {
294
300
  tax: Array.isArray( b.tax ) ? b.tax : existing.tax,
295
301
  amount,
296
302
  totalAmount,
297
- currency: b.currency || existing.currency,
303
+ currency: estimateCurrency,
298
304
  notes: b.notes ?? existing.notes,
299
305
  updatedBy: req.user?.email || req.user?.userName || '',
300
306
  };
@@ -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}`,
@@ -396,7 +397,7 @@ export async function createInvoice( req, res ) {
396
397
  productName: 'oneTimeFee',
397
398
  period: 'fullmonth',
398
399
  storeCount: newStoreCount,
399
- amount: Math.round( newStoreCount * oneTimeFeePerStore * 100 ) / 100,
400
+ amount: roundAmount( newStoreCount * oneTimeFeePerStore, group.currency ),
400
401
  price: oneTimeFeePerStore,
401
402
  description: `One-Time Fee - ${newStoreCount} stores`,
402
403
  HsnNumber: '998314',
@@ -417,21 +418,21 @@ export async function createInvoice( req, res ) {
417
418
  // existing GST/IGST/CGST/SGST behavior for legacy records that have
418
419
  // no taxCalculationType set yet.
419
420
  if ( group.taxCalculationType === 'international' ) {
420
- totalAmount = Math.round( amount );
421
+ totalAmount = roundAmount( amount, group.currency );
421
422
  } else if ( group.gst && group.gst.slice( 0, 2 ) == '33' ) {
422
423
  let taxAmount = ( amount * 18 ) / 100;
423
- totalAmount = Math.round( amount + taxAmount );
424
+ totalAmount = roundAmount( amount + taxAmount, group.currency );
424
425
  taxList.push(
425
426
  {
426
427
  'currency': '₹',
427
428
  'type': 'CGST',
428
429
  'value': 9,
429
- 'taxAmount': ( ( amount * 9 ) / 100 ).toFixed( 2 ),
430
+ 'taxAmount': String( roundAmount( ( amount * 9 ) / 100, group.currency ) ),
430
431
  }, {
431
432
  'currency': '₹',
432
433
  'type': 'SGST',
433
434
  'value': 9,
434
- 'taxAmount': ( ( amount * 9 ) / 100 ).toFixed( 2 ),
435
+ 'taxAmount': String( roundAmount( ( amount * 9 ) / 100, group.currency ) ),
435
436
  },
436
437
  );
437
438
  } else {
@@ -439,13 +440,13 @@ export async function createInvoice( req, res ) {
439
440
  if ( group.currency === 'inr' ) {
440
441
  taxAmount = ( amount * 18 ) / 100;
441
442
  }
442
- totalAmount = Math.round( amount + taxAmount );
443
+ totalAmount = roundAmount( amount + taxAmount, group.currency );
443
444
  taxList.push(
444
445
  {
445
446
  'currency': '₹',
446
447
  'type': 'IGST',
447
448
  'value': 18,
448
- 'taxAmount': ( taxAmount ).toFixed( 2 ),
449
+ 'taxAmount': String( roundAmount( taxAmount, group.currency ) ),
449
450
  },
450
451
  );
451
452
  }
@@ -528,14 +529,14 @@ export async function createInvoice( req, res ) {
528
529
  invoice: req.body.invoiceId ? req.body.invoiceId : `${invPrefix}${Finacialyear}-${invoiceNo}`,
529
530
  products: products,
530
531
  status: 'pendingCsm',
531
- amount: Math.round( amount ),
532
+ amount: roundAmount( amount, group.currency ),
532
533
  invoiceIndex: req.body.invoiceId ? findInvoice.invoiceIndex : invoiceNo,
533
534
  tax: taxList,
534
535
  companyName: ( group.registeredCompanyName || '' ).toUpperCase(),
535
536
  companyAddress: address,
536
537
  PlaceOfSupply: group.placeOfSupply,
537
538
  GSTNumber: group.gst,
538
- totalAmount: Math.round( totalAmount ),
539
+ totalAmount: roundAmount( totalAmount, group.currency ),
539
540
  clientId: group.clientId,
540
541
  paymentMethod: 'Online',
541
542
  billingDate: new Date( invoicedate ),
@@ -751,8 +752,8 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
751
752
  // store's position within the product.
752
753
  const price = priceForStore( s.productName, productPosition[s.productName] );
753
754
  const runningCost = s.workingdays >= monthDays ?
754
- Math.round( price * units * 100 ) / 100 :
755
- Math.round( ( price / monthDays ) * s.workingdays * units * 100 ) / 100;
755
+ roundAmount( price * units, invoiceInfo.currency ) :
756
+ roundAmount( ( price / monthDays ) * s.workingdays * units, invoiceInfo.currency );
756
757
  return {
757
758
  productName: s.productName ? s.productName.charAt( 0 ).toUpperCase() + s.productName.slice( 1 ) : '',
758
759
  currencyType: invoiceCurrency,
@@ -770,8 +771,9 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
770
771
  };
771
772
  } );
772
773
 
773
- const totalAmount = Math.round( data.reduce( ( a, x ) => a + ( x.runningCost || 0 ), 0 ) * 100 ) / 100;
774
- 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 } );
775
777
  return { data, totalAmount, totalFormatted };
776
778
  }
777
779
 
@@ -786,21 +788,24 @@ export async function invoiceDownload( req, res ) {
786
788
  // client.paymentInvoice or virtualAccount.currency causes historical
787
789
  // invoices to re-render in the wrong currency if those settings change.
788
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 };
789
794
  invoiceInfo.products.forEach( ( item, index ) => {
790
795
  item.index = index + 1;
791
796
  let [ firstWord, secondWord ] = item.productName.replace( /([a-z])([A-Z])/g, '$1 $2' ).split( ' ' );
792
797
  firstWord = firstWord.charAt( 0 ).toUpperCase() + firstWord.slice( 1 );
793
798
  item.productName = firstWord + ' ' + secondWord;
794
- item.price = item.price .toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
795
- 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 );
796
801
  item.currency = invoiceCurrency;
797
802
  } );
798
803
 
799
804
 
800
805
  let invoiceDate = dayjs( invoiceInfo.billingDate ).format( 'DD/MM/YYYY' );
801
806
 
802
- invoiceInfo.totalAmount = Math.round( invoiceInfo.totalAmount );
803
- let AmountinWords = inWords( invoiceInfo.totalAmount );
807
+ invoiceInfo.totalAmount = roundAmount( invoiceInfo.totalAmount, invoiceInfo.currency );
808
+ let AmountinWords = amountToWords( invoiceInfo.totalAmount, invoiceInfo.currency );
804
809
  let getgroup;
805
810
  let days = getgroup?.paymentTerm ? getgroup?.paymentTerm : '30';
806
811
  let dueDate = invoiceInfo?.dueDate ? dayjs( invoiceInfo?.dueDate ).format( 'DD/MM/YYYY' ) : dayjs().add( days, 'days' ).format( 'DD/MM/YYYY' );
@@ -814,10 +819,10 @@ export async function invoiceDownload( req, res ) {
814
819
  invoiceData = {
815
820
  ...invoiceInfo._doc,
816
821
  clientName: clientDetails.clientName,
817
- amount: invoiceInfo.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
822
+ amount: roundAmount( invoiceInfo.amount, invoiceInfo.currency ).toLocaleString( 'en-IN', moneyFmt ),
818
823
  extendDays: getgroup?.paymentTerm ? getgroup?.paymentTerm : '30',
819
824
  address: clientDetails.billingDetails.billingAddress,
820
- subtotal: invoiceInfo.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
825
+ subtotal: roundAmount( invoiceInfo.amount, invoiceInfo.currency ).toLocaleString( 'en-IN', moneyFmt ),
821
826
  companyName: invoiceInfo.companyName,
822
827
  companyAddress: invoiceInfo.companyAddress,
823
828
  PlaceOfSupply: invoiceInfo.PlaceOfSupply,
@@ -826,7 +831,8 @@ export async function invoiceDownload( req, res ) {
826
831
  amountwords: AmountinWords,
827
832
  Terms: `Term ${getgroup?.paymentTerm ? getgroup?.paymentTerm : '30'}`,
828
833
  currencyType: invoiceCurrency,
829
- totalAmount: invoiceInfo.totalAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
834
+ currencyWord: wordFor( invoiceInfo.currency ),
835
+ totalAmount: invoiceInfo.totalAmount.toLocaleString( 'en-IN', moneyFmt ),
830
836
  invoiceDate,
831
837
  dueDate,
832
838
  discountPercentage: invoiceInfo.discountPercentage ? invoiceInfo.discountPercentage : 0,
@@ -985,19 +991,22 @@ async function buildInvoicePdfBuffer( invoiceId ) {
985
991
  // field, recorded at creation from the billing group. See invoiceDownload
986
992
  // above for the same pattern.
987
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 };
988
997
  invoiceInfo.products.forEach( ( item, index ) => {
989
998
  item.index = index + 1;
990
999
  let [ firstWord, secondWord ] = item.productName.replace( /([a-z])([A-Z])/g, '$1 $2' ).split( ' ' );
991
1000
  firstWord = firstWord.charAt( 0 ).toUpperCase() + firstWord.slice( 1 );
992
1001
  item.productName = firstWord + ' ' + secondWord;
993
- item.price = Math.round( item.price ).toLocaleString( 'en-IN' );
994
- 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 );
995
1004
  item.currency = invoiceCurrency;
996
1005
  } );
997
1006
 
998
1007
  let invoiceDate = dayjs( invoiceInfo.billingDate ).format( 'DD/MM/YYYY' );
999
- invoiceInfo.totalAmount = Math.round( invoiceInfo.totalAmount );
1000
- let AmountinWords = inWords( invoiceInfo.totalAmount );
1008
+ invoiceInfo.totalAmount = roundAmount( invoiceInfo.totalAmount, invoiceInfo.currency );
1009
+ let AmountinWords = amountToWords( invoiceInfo.totalAmount, invoiceInfo.currency );
1001
1010
  let getgroup;
1002
1011
  if ( invoiceInfo.groupId ) {
1003
1012
  getgroup = await billingService.findOne( { _id: invoiceInfo.groupId } );
@@ -1010,10 +1019,10 @@ async function buildInvoicePdfBuffer( invoiceId ) {
1010
1019
  let invoiceData = {
1011
1020
  ...invoiceInfo._doc,
1012
1021
  clientName: clientDetails.clientName,
1013
- amount: invoiceInfo.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
1022
+ amount: roundAmount( invoiceInfo.amount, invoiceInfo.currency ).toLocaleString( 'en-IN', moneyFmt ),
1014
1023
  extendDays: getgroup?.paymentTerm ? getgroup?.paymentTerm : '30',
1015
1024
  address: clientDetails.billingDetails.billingAddress,
1016
- subtotal: invoiceInfo.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
1025
+ subtotal: roundAmount( invoiceInfo.amount, invoiceInfo.currency ).toLocaleString( 'en-IN', moneyFmt ),
1017
1026
  companyName: invoiceInfo.companyName,
1018
1027
  companyAddress: invoiceInfo.companyAddress,
1019
1028
  PlaceOfSupply: invoiceInfo.PlaceOfSupply,
@@ -1022,7 +1031,8 @@ async function buildInvoicePdfBuffer( invoiceId ) {
1022
1031
  amountwords: AmountinWords,
1023
1032
  Terms: `Term ${getgroup?.paymentTerm ? getgroup?.paymentTerm : '30'}`,
1024
1033
  currencyType: invoiceCurrency,
1025
- totalAmount: invoiceInfo.totalAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
1034
+ currencyWord: wordFor( invoiceInfo.currency ),
1035
+ totalAmount: invoiceInfo.totalAmount.toLocaleString( 'en-IN', moneyFmt ),
1026
1036
  invoiceDate,
1027
1037
  dueDate,
1028
1038
  discountPercentage: invoiceInfo.discountPercentage ? invoiceInfo.discountPercentage : 0,
@@ -1208,6 +1218,32 @@ function inWords( num ) {
1208
1218
  return str.toLowerCase().split( ' ' ).map( ( word ) => word.charAt( 0 ).toUpperCase() + word.slice( 1 ) ).join( ' ' );
1209
1219
  }
1210
1220
 
1221
+ // Sub-unit (fraction) name per currency for the "Total In Words" cents part.
1222
+ // INR is whole-number only, so it has no fraction.
1223
+ const CURRENCY_FRACTION_WORDS = {
1224
+ dollar: 'Cents',
1225
+ singaporedollar: 'Cents',
1226
+ euro: 'Cents',
1227
+ aed: 'Fils',
1228
+ };
1229
+
1230
+ // Spell out an amount. INR -> whole number only. Other currencies keep 2
1231
+ // decimals, so the cents are spelled out too when non-zero
1232
+ // (e.g. 3191.10 -> "Three Thousand One Hundred And Ninety One and Ten Cents").
1233
+ function amountToWords( value, currency ) {
1234
+ const n = Number( value ) || 0;
1235
+ if ( currency === 'inr' ) {
1236
+ return inWords( Math.round( n ) );
1237
+ }
1238
+ const whole = Math.trunc( n );
1239
+ const cents = Math.round( ( n - whole ) * 100 );
1240
+ const fraction = CURRENCY_FRACTION_WORDS[currency] || 'Cents';
1241
+ if ( cents > 0 ) {
1242
+ return `${inWords( whole )} and ${inWords( cents )} ${fraction}`;
1243
+ }
1244
+ return inWords( whole );
1245
+ }
1246
+
1211
1247
 
1212
1248
  // Resolve which basepricing doc applies to a billing group. When the client
1213
1249
  // has billingGroupWisePricing enabled AND a doc exists for this group, returns
@@ -1638,7 +1674,7 @@ async function standardPrice( group, getClient, baseDate ) {
1638
1674
  if ( store.workingDays >= currentMonthDays ) {
1639
1675
  amount = store.price * storeCount;
1640
1676
  } else {
1641
- amount = Math.round( ( store.price / currentMonthDays ) * store.workingDays * storeCount * 100 ) / 100;
1677
+ amount = roundAmount( ( store.price / currentMonthDays ) * store.workingDays * storeCount, group.currency );
1642
1678
  }
1643
1679
 
1644
1680
  let description = store.productName === 'tangoZone' ? 'Product category/section analytics' : 'Customer Footfall Analytics';
@@ -1837,7 +1873,7 @@ async function stepPrice( group, getClient ) {
1837
1873
  if ( store.workingDays >= currentMonthDays ) {
1838
1874
  amount = price * storeCount;
1839
1875
  } else {
1840
- amount = Math.round( ( price / currentMonthDays ) * store.workingDays * storeCount * 100 ) / 100;
1876
+ amount = roundAmount( ( price / currentMonthDays ) * store.workingDays * storeCount, group.currency );
1841
1877
  }
1842
1878
 
1843
1879
  let description = store.productName === 'tangoZone' ? 'Product category/section analytics' : 'Customer Footfall Analytics';
@@ -1913,8 +1949,8 @@ async function stepPrice( group, getClient ) {
1913
1949
  const price = tierPriceForPosition( s.productName, productPosition[s.productName] );
1914
1950
  const fullMonth = s.workingDays >= currentMonthDays;
1915
1951
  const amount = fullMonth ?
1916
- Math.round( units * price * 100 ) / 100 :
1917
- Math.round( ( units * ( price / currentMonthDays ) * s.workingDays ) * 100 ) / 100;
1952
+ roundAmount( units * price, group.currency ) :
1953
+ roundAmount( units * ( price / currentMonthDays ) * s.workingDays, group.currency );
1918
1954
  const period = fullMonth ? 'fullMonth' : 'proRate';
1919
1955
  const key = `${s.productName}_${period}_${price}`;
1920
1956
  if ( !grouped[key] ) {
@@ -1931,7 +1967,8 @@ async function stepPrice( group, getClient ) {
1931
1967
  } else if ( grp.productName === 'tangoZone' ) {
1932
1968
  description = 'Product category/section analytics';
1933
1969
  }
1934
- const amount = Math.round( grp.totalAmount * 100 ) / 100;
1970
+ const amount = roundAmount( grp.totalAmount, group.currency );
1971
+ const zeroPrice = group.currency === 'inr' ? '0' : '0.00';
1935
1972
  return {
1936
1973
  productName: grp.productName,
1937
1974
  period: grp.period,
@@ -1943,7 +1980,7 @@ async function stepPrice( group, getClient ) {
1943
1980
  HsnNumber: '998314',
1944
1981
  amount: amount,
1945
1982
  month: dayjs().format( 'MMM YYYY' ),
1946
- price: grp.unitCount ? ( amount / grp.unitCount ).toFixed( 2 ) : '0.00',
1983
+ price: grp.unitCount ? String( roundAmount( amount / grp.unitCount, group.currency ) ) : zeroPrice,
1947
1984
  };
1948
1985
  } );
1949
1986
 
@@ -2229,9 +2266,11 @@ export async function clientInvoiceList( req, res ) {
2229
2266
  cards.pendingPaymentCount += 1;
2230
2267
  }
2231
2268
  }
2232
- cards.outstandingAmount = Math.round( cards.outstandingAmount * 100 ) / 100;
2233
- cards.overdueAmount = Math.round( cards.overdueAmount * 100 ) / 100;
2234
- cards.pendingPaymentAmount = Math.round( cards.pendingPaymentAmount * 100 ) / 100;
2269
+ // These card totals are normalised to INR (dollar invoices are multiplied
2270
+ // by usdRate above), so they follow the INR rule: whole numbers.
2271
+ cards.outstandingAmount = roundAmount( cards.outstandingAmount, 'inr' );
2272
+ cards.overdueAmount = roundAmount( cards.overdueAmount, 'inr' );
2273
+ cards.pendingPaymentAmount = roundAmount( cards.pendingPaymentAmount, 'inr' );
2235
2274
 
2236
2275
  res.sendSuccess( { count: count.length, data: invoiceList, cards } );
2237
2276
  } catch ( error ) {
@@ -2363,14 +2402,14 @@ export async function applyDiscount( req, res ) {
2363
2402
  try {
2364
2403
  let invoice = await invoiceService.findOne( { invoice: req.body.invoice } );
2365
2404
  if ( invoice ) {
2366
- invoice.discountAmount = ( ( invoice.amount * req.body.discount ) / 100 ).toFixed( 2 );
2367
- invoice.amount = invoice.amount - invoice.discountAmount;
2405
+ invoice.discountAmount = roundAmount( ( invoice.amount * req.body.discount ) / 100, invoice.currency );
2406
+ invoice.amount = roundAmount( invoice.amount - invoice.discountAmount, invoice.currency );
2368
2407
  invoice.discountPercentage = req.body.discount;
2369
2408
  if ( invoice.currency === 'inr' ) {
2370
2409
  if ( invoice.tax.length ) {
2371
2410
  for ( let i = 0; i < invoice.tax.length; i++ ) {
2372
- invoice.tax[i].taxAmount = ( ( invoice.amount * invoice.tax[i].value ) / 100 ).toFixed( 2 );
2373
- invoice.totalAmount = ( Number( invoice.amount ) + Number( invoice.tax[i].taxAmount ) ).toFixed( 2 );
2411
+ invoice.tax[i].taxAmount = String( roundAmount( ( invoice.amount * invoice.tax[i].value ) / 100, invoice.currency ) );
2412
+ invoice.totalAmount = roundAmount( Number( invoice.amount ) + Number( invoice.tax[i].taxAmount ), invoice.currency );
2374
2413
  }
2375
2414
  }
2376
2415
  }
@@ -2500,7 +2539,7 @@ export async function recordPayment( req, res ) {
2500
2539
  }
2501
2540
 
2502
2541
  const previousPaid = Number( invoice.paidAmount ) || 0;
2503
- const newPaid = Math.round( ( previousPaid + amountNum ) * 100 ) / 100;
2542
+ const newPaid = roundAmount( previousPaid + amountNum, invoice.currency );
2504
2543
  const totalAmount = Number( invoice.totalAmount ) || 0;
2505
2544
 
2506
2545
  // Reject overpayment — finance teams want this caught early. They can
@@ -2508,7 +2547,7 @@ export async function recordPayment( req, res ) {
2508
2547
  // invoice total; anything beyond that is a data-entry error.
2509
2548
  if ( totalAmount > 0 && newPaid > totalAmount + 0.01 ) {
2510
2549
  return res.sendError(
2511
- `Payment exceeds outstanding balance. Outstanding: ${( totalAmount - previousPaid ).toFixed( 2 )}`,
2550
+ `Payment exceeds outstanding balance. Outstanding: ${roundAmount( totalAmount - previousPaid, invoice.currency )}`,
2512
2551
  400,
2513
2552
  );
2514
2553
  }
@@ -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';
@@ -3092,6 +3094,10 @@ export const invoiceDownload = async ( req, res ) => {
3092
3094
  if ( invoiceInfo ) {
3093
3095
  let clientDetails = await paymentService.findOne( { clientId: invoiceInfo.clientId } );
3094
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 };
3095
3101
  invoiceInfo.products.forEach( ( item, index ) => {
3096
3102
  let [ firstWord, secondWord ] = item.product.product.replace( /([a-z])([A-Z])/g, '$1 $2' ).split( ' ' );
3097
3103
  firstWord = firstWord.charAt( 0 ).toUpperCase() + firstWord.slice( 1 );
@@ -3107,25 +3113,25 @@ export const invoiceDownload = async ( req, res ) => {
3107
3113
 
3108
3114
  amount = amount + item.price;
3109
3115
 
3110
- item.basePrice = Math.round( item.basePrice ).toLocaleString( 'en-IN' );
3111
- 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 );
3112
3118
  item.currency = symbolFor( clientDetails?.paymentInvoice?.currencyType );
3113
3119
  } );
3114
3120
  for ( let tax of invoiceInfo.tax ) {
3115
- tax.taxAmount = tax.taxAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
3121
+ tax.taxAmount = roundAmount( tax.taxAmount, invoicePdfCurrency ).toLocaleString( 'en-IN', invoicePdfMoneyFmt );
3116
3122
  }
3117
3123
  let invoiceDate = dayjs( invoiceInfo.createdAt ).format( 'DD/MM/YYYY' );
3118
3124
  let days = clientDetails?.paymentInvoice?.extendPaymentPeriodDays || 10;
3119
3125
  let dueDate = invoiceInfo?.dueDate ? dayjs( invoiceInfo?.dueDate ).format( 'DD/MM/YYYY' ) : dayjs().add( days, 'days' ).format( 'DD/MM/YYYY' );
3120
3126
 
3121
3127
  invoiceInfo.totalAmount = invoiceInfo.totalAmount;
3122
- let AmountinWords = inWords( invoiceInfo.totalAmount );
3128
+ let AmountinWords = amountToWords( invoiceInfo.totalAmount, invoicePdfCurrency );
3123
3129
  invoiceData = {
3124
3130
  ...invoiceInfo._doc,
3125
3131
  clientName: clientDetails.clientName,
3126
3132
  extendDays: clientDetails.paymentInvoice.extendPaymentPeriodDays,
3127
3133
  address: clientDetails.billingDetails.billingAddress,
3128
- subtotal: amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
3134
+ subtotal: roundAmount( amount, invoicePdfCurrency ).toLocaleString( 'en-IN', invoicePdfMoneyFmt ),
3129
3135
  companyName: invoiceInfo.companyName,
3130
3136
  companyAddress: invoiceInfo.companyAddress,
3131
3137
  PlaceOfSupply: invoiceInfo.PlaceOfSupply,
@@ -3134,7 +3140,8 @@ export const invoiceDownload = async ( req, res ) => {
3134
3140
  amountwords: AmountinWords,
3135
3141
  Terms: `Term ${clientDetails.paymentInvoice.extendPaymentPeriodDays}`,
3136
3142
  currencyType: symbolFor( clientDetails?.paymentInvoice?.currencyType ),
3137
- totalAmount: invoiceInfo.totalAmount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ),
3143
+ currencyName: wordFor( invoicePdfCurrency ),
3144
+ totalAmount: roundAmount( invoiceInfo.totalAmount, invoicePdfCurrency ).toLocaleString( 'en-IN', invoicePdfMoneyFmt ),
3138
3145
  invoiceDate,
3139
3146
  dueDate,
3140
3147
  };
@@ -3247,6 +3254,31 @@ function inWords( num ) {
3247
3254
  return str.toLowerCase().split( ' ' ).map( ( word ) => word.charAt( 0 ).toUpperCase() + word.slice( 1 ) ).join( ' ' );
3248
3255
  }
3249
3256
 
3257
+ // Sub-unit (fraction) name per currency for the "Total In Words" cents part.
3258
+ const CURRENCY_FRACTION_WORDS = {
3259
+ dollar: 'Cents',
3260
+ singaporedollar: 'Cents',
3261
+ euro: 'Cents',
3262
+ aed: 'Fils',
3263
+ };
3264
+
3265
+ // Spell out an amount. INR -> whole number only. Other currencies keep 2
3266
+ // decimals, so the cents are spelled out too when non-zero
3267
+ // (e.g. 383.04 -> "Three Hundred And Eighty Three and Four Cents").
3268
+ function amountToWords( value, currency ) {
3269
+ const n = Number( value ) || 0;
3270
+ if ( currency === 'inr' ) {
3271
+ return inWords( Math.round( n ) );
3272
+ }
3273
+ const whole = Math.trunc( n );
3274
+ const cents = Math.round( ( n - whole ) * 100 );
3275
+ const fraction = CURRENCY_FRACTION_WORDS[currency] || 'Cents';
3276
+ if ( cents > 0 ) {
3277
+ return `${inWords( whole )} and ${inWords( cents )} ${fraction}`;
3278
+ }
3279
+ return inWords( whole );
3280
+ }
3281
+
3250
3282
  export const updateInvoiceStatus = async ( req, res ) => {
3251
3283
  try {
3252
3284
  if ( !req.params?.invoiceId ) {
@@ -1423,7 +1423,6 @@
1423
1423
  </div>
1424
1424
  </div>
1425
1425
  <div class="_1234"># {{estimate}}</div>
1426
- <div><span class="pill pill-{{status}}">{{statusLabel}}</span></div>
1427
1426
 
1428
1427
  </div>
1429
1428
 
@@ -881,6 +881,10 @@
881
881
  align-self: stretch;
882
882
  flex-shrink: 0;
883
883
  position: relative;
884
+ /* Keep the whole totals block (Sub Total ... Total In Words) on a
885
+ single page so the amount-in-words can't be split across pages. */
886
+ break-inside: avoid;
887
+ page-break-inside: avoid;
884
888
  }
885
889
 
886
890
  .frame-2394 {
@@ -1011,6 +1015,8 @@
1011
1015
  justify-content: flex-start;
1012
1016
  flex-shrink: 0;
1013
1017
  position: relative;
1018
+ break-inside: avoid;
1019
+ page-break-inside: avoid;
1014
1020
  }
1015
1021
 
1016
1022
  .text13 {
@@ -1401,12 +1407,14 @@
1401
1407
  <div class="chennai-tamil-nadu-600006-india">
1402
1408
 
1403
1409
  </div>
1404
- <div class="gstin-33-aagct-3124-r-1-z-2">GSTIN {{GSTNumber}}</div>
1410
+ {{#ifExists GSTNumber}}<div class="gstin-33-aagct-3124-r-1-z-2">GSTIN {{GSTNumber}}</div>{{/ifExists}}
1405
1411
  </div>
1406
1412
  </div>
1413
+ {{#ifExists PlaceOfSupply}}
1407
1414
  <div class="place_of_supply">
1408
1415
  Place Of Supply: {{PlaceOfSupply}}
1409
1416
  </div>
1417
+ {{/ifExists}}
1410
1418
  </div>
1411
1419
  <div class="frame-2698">
1412
1420
  <div class="frame-9155">
@@ -1560,7 +1568,7 @@
1560
1568
  <div class="frame-23972">
1561
1569
  {{#neq discountAmount 0}}
1562
1570
  <div class="frame-2394">
1563
- <div class="text9">Discount ({{discountPercentage}}%)</div>
1571
+ <div class="text9">Discount</div>
1564
1572
  <div class="frame-9157">
1565
1573
  <div class="text10">{{currencyType}} {{discountAmount}}</div>
1566
1574
  </div>
@@ -1575,12 +1583,14 @@
1575
1583
 
1576
1584
  {{#if gstApplicable}}
1577
1585
  {{#each tax }}
1586
+ {{#gtZero taxAmount}}
1578
1587
  <div class="frame-2392">
1579
1588
  <div class="text9">{{type}} ({{value}}%)</div>
1580
1589
  <div class="frame-9157">
1581
1590
  <div class="text10">{{../currencyType}} {{taxAmount}}</div>
1582
1591
  </div>
1583
1592
  </div>
1593
+ {{/gtZero}}
1584
1594
  {{/each }}
1585
1595
  {{/if}}
1586
1596
 
@@ -1605,22 +1615,7 @@
1605
1615
  <div class="text11">Total In Words</div>
1606
1616
  <div class="frame-9157">
1607
1617
  <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
1618
+ {{currencyWord}} {{amountwords}} Only
1624
1619
  </div>
1625
1620
  </div>
1626
1621
  </div>
@@ -844,6 +844,10 @@
844
844
  align-self: stretch;
845
845
  flex-shrink: 0;
846
846
  position: relative;
847
+ /* Keep the whole totals block (Sub Total ... Total In Words) on a
848
+ single page so the amount-in-words can't be split across pages. */
849
+ break-inside: avoid;
850
+ page-break-inside: avoid;
847
851
  }
848
852
 
849
853
  .frame-2394 {
@@ -974,6 +978,8 @@
974
978
  justify-content: flex-start;
975
979
  flex-shrink: 0;
976
980
  position: relative;
981
+ break-inside: avoid;
982
+ page-break-inside: avoid;
977
983
  }
978
984
 
979
985
  .text13 {
@@ -1368,16 +1374,20 @@
1368
1374
  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
1375
  {{companyAddress}}
1370
1376
  </div>
1377
+ {{#ifExists GSTNumber}}
1371
1378
  <div class="frame-562">
1372
1379
  <div class="gstin-33-aafci-2595-g-1-z-9"> GSTIN {{GSTNumber}}</div>
1373
1380
  </div>
1381
+ {{/ifExists}}
1374
1382
  </div>
1375
1383
  </div>
1376
1384
  </div>
1377
1385
  </div>
1386
+ {{#ifExists PlaceOfSupply}}
1378
1387
  <div class="place-of-supply-tamil-nadu-33">
1379
1388
  Place Of Supply: {{PlaceOfSupply}}
1380
1389
  </div>
1390
+ {{/ifExists}}
1381
1391
  </div>
1382
1392
  <div class="invoice-info">
1383
1393
  <div class="card-header">
@@ -1496,12 +1506,14 @@
1496
1506
  </div>
1497
1507
  </div>
1498
1508
  {{#each tax }}
1509
+ {{#gtZero taxAmount}}
1499
1510
  <div class="frame-2392">
1500
1511
  <div class="text9">{{type}} ({{value}}%)</div>
1501
1512
  <div class="frame-9157">
1502
1513
  <div class="text10">{{currency}} {{taxAmount}}</div>
1503
1514
  </div>
1504
1515
  </div>
1516
+ {{/gtZero}}
1505
1517
  {{/each }}
1506
1518
  <!-- <div class="frame-2398">
1507
1519
  <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;