tango-app-api-payment-subscription 3.5.8 → 3.5.9

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.8",
3
+ "version": "3.5.9",
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.27",
32
+ "tango-api-schema": "^2.6.28",
33
33
  "tango-app-api-middleware": "^3.6.18",
34
34
  "winston": "^3.12.0",
35
35
  "winston-daily-rotate-file": "^5.0.0",
@@ -173,6 +173,21 @@ export async function brandsBillingList( req, res ) {
173
173
  dueInr: { $ifNull: [ '$invoiceData.dueInr', 0 ] },
174
174
  dueUsd: { $ifNull: [ '$invoiceData.dueUsd', 0 ] },
175
175
  nextBillingDate: { $ifNull: [ '$billingData.nextBillingDate', null ] },
176
+ // Whether billing is SET UP — true when a billing group exists (or
177
+ // live products are configured). Drives the Setup-Billing vs View
178
+ // button; must NOT depend on the volatile current-month run count.
179
+ billingConfigured: {
180
+ $or: [
181
+ { $ne: [ '$billingData', null ] },
182
+ { $gt: [ {
183
+ $size: { $filter: {
184
+ input: { $ifNull: [ '$planDetails.product', [] ] },
185
+ as: 'prod',
186
+ cond: { $eq: [ '$$prod.status', 'live' ] },
187
+ } },
188
+ }, 0 ] },
189
+ ],
190
+ },
176
191
  },
177
192
  },
178
193
  {
@@ -184,6 +199,7 @@ export async function brandsBillingList( req, res ) {
184
199
  totalStores: 1,
185
200
  billingStores: 1,
186
201
  productsAdded: 1,
202
+ billingConfigured: 1,
187
203
  dueInr: 1,
188
204
  dueUsd: 1,
189
205
  status: 1,
@@ -1561,14 +1577,22 @@ export async function billingSummary( req, res ) {
1561
1577
  // email's local part since the collection carries no display name.
1562
1578
  const usdRate = await getUsdInrRate();
1563
1579
 
1564
- // Current month's store count comes from dailyPricing (latest reading
1565
- // this month) invoices for the running month usually don't exist yet.
1580
+ // Current month's store count comes from dailyPricing counted the same
1581
+ // way as Brands & Billing's "Billing Stores": distinct ACTIVE stores that
1582
+ // RAN on more than one day in the month (a single-day appearance is
1583
+ // transient and isn't billed). Invoices for the running month usually don't
1584
+ // exist yet, so this is the current-month source.
1566
1585
  const curMonthStart = new Date( now.startOf( 'month' ).toISOString() );
1567
1586
  const latestDp = await dailyPriceService.aggregate( [
1568
1587
  { $match: { dateISO: { $gte: curMonthStart } } },
1569
- { $project: { clientId: 1, activeStores: 1, dateISO: 1 } },
1570
- { $sort: { dateISO: 1 } },
1571
- { $group: { _id: '$clientId', stores: { $last: '$activeStores' } } },
1588
+ { $unwind: { path: '$stores', preserveNullAndEmptyArrays: false } },
1589
+ { $match: { 'stores.status': 'active' } },
1590
+ { $group: {
1591
+ _id: { clientId: '$clientId', storeId: '$stores.storeId' },
1592
+ days: { $addToSet: { $dateToString: { format: '%Y-%m-%d', date: '$dateISO' } } },
1593
+ } },
1594
+ { $match: { $expr: { $gt: [ { $size: '$days' }, 1 ] } } },
1595
+ { $group: { _id: '$_id.clientId', stores: { $sum: 1 } } },
1572
1596
  ] );
1573
1597
  const curStoresByClient = new Map( latestDp.map( ( d ) => [ String( d._id ), d.stores || 0 ] ) );
1574
1598
 
@@ -1,12 +1,77 @@
1
1
  import * as estimateService from '../services/estimate.service.js';
2
2
  import * as clientService from '../services/clientPayment.services.js';
3
+ import * as billingService from '../services/billing.service.js';
3
4
  import dayjs from 'dayjs';
4
- import { logger, download } from 'tango-app-api-middleware';
5
+ import { logger, download, sendEmailWithSES } from 'tango-app-api-middleware';
5
6
  import Handlebars from '../utils/validations/helper/handlebar.helper.js';
6
7
  import fs from 'fs';
7
8
  import path from 'path';
8
9
  import htmlpdf from 'html-pdf-node';
9
10
  import { symbolFor } from '../utils/currency.js';
11
+ import { getInvoiceCcEmails } from './invoice.controller.js';
12
+
13
+ // Build the estimate PDF buffer + the template data + a safe filename. Shared
14
+ // by the download and send flows so both render the identical document.
15
+ async function buildEstimatePdf( estimate ) {
16
+ const e = estimate._doc || estimate;
17
+ const currencyType = symbolFor( e.currency );
18
+ const fmt = ( n ) => Number( n || 0 ).toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
19
+
20
+ const products = ( e.products || [] ).map( ( p, i ) => {
21
+ let name = String( p.productName || '' ).replace( /([a-z])([A-Z])/g, '$1 $2' );
22
+ name = name.charAt( 0 ).toUpperCase() + name.slice( 1 );
23
+ return {
24
+ index: i + 1,
25
+ productName: name,
26
+ description: p.description || '',
27
+ hsn: p.hsn || p.hsnCode || '998314',
28
+ storeCount: p.storeCount || e.stores || '',
29
+ price: fmt( p.price ),
30
+ amount: fmt( p.amount ),
31
+ };
32
+ } );
33
+ const tax = ( e.tax || [] ).map( ( t ) => ( {
34
+ type: t.type || t.taxName || t.name || 'GST',
35
+ value: t.value ?? t.taxPercentage ?? t.percentage ?? '',
36
+ taxAmount: fmt( t.taxAmount ),
37
+ } ) );
38
+
39
+ const statusLabelMap = { pending: 'Pending', sent: 'Sent', accepted: 'Accepted', declined: 'Declined', expired: 'Expired' };
40
+ const data = {
41
+ estimate: e.estimate,
42
+ status: e.status,
43
+ statusLabel: statusLabelMap[e.status] || e.status,
44
+ companyName: e.companyName || '',
45
+ companyAddress: e.companyAddress || '',
46
+ GSTNumber: e.GSTNumber || '',
47
+ PlaceOfSupply: e.PlaceOfSupply || '',
48
+ groupName: e.groupName || '',
49
+ period: e.period || '',
50
+ createdDate: e.createdDate ? dayjs( e.createdDate ).format( 'DD/MM/YYYY' ) : '',
51
+ validTill: e.validTill ? dayjs( e.validTill ).format( 'DD/MM/YYYY' ) : '',
52
+ currencyType,
53
+ amount: fmt( e.amount ),
54
+ totalAmount: fmt( e.totalAmount ),
55
+ products,
56
+ tax,
57
+ notes: e.notes || '',
58
+ logo: `${JSON.parse( process.env.URL ).apiDomain}/logo.png`,
59
+ };
60
+
61
+ const templateHtml = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/estimatePdf.hbs', 'utf8' );
62
+ const html = Handlebars.compile( templateHtml )( data );
63
+ const options = {
64
+ executablePath: '/usr/bin/chromium',
65
+ args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--no-zygote', '--single-process' ],
66
+ format: 'A4',
67
+ margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
68
+ printBackground: true,
69
+ preferCSSPageSize: true,
70
+ };
71
+ const pdfBuffer = await htmlpdf.generatePdf( { content: html }, options );
72
+ const filename = ( e.estimate + '-' + ( e.companyName || 'estimate' ) + '.pdf' ).split( '/' ).join( '_' ).split( '"' ).join( '' ).trim();
73
+ return { pdfBuffer, filename, data };
74
+ }
10
75
 
11
76
  // ---------------------------------------------------------------------------
12
77
  // Estimates (quotations). A lightweight pre-invoice document with its own
@@ -43,7 +108,7 @@ async function nextEstimateNumber() {
43
108
  // flips to 'expired' so the list reflects reality without a cron.
44
109
  async function expireOverdue( clientId ) {
45
110
  await estimateService.updateOne(
46
- { clientId, status: { $in: [ 'draft', 'sent' ] }, validTill: { $lt: new Date() } },
111
+ { clientId, status: { $in: [ 'pending', 'sent' ] }, validTill: { $lt: new Date() } },
47
112
  { $set: { status: 'expired' } },
48
113
  );
49
114
  }
@@ -119,7 +184,7 @@ export async function estimateList( req, res ) {
119
184
  { $match: { clientId } },
120
185
  { $group: { _id: '$status', count: { $sum: 1 } } },
121
186
  ] );
122
- const counts = { draft: 0, sent: 0, accepted: 0, declined: 0, expired: 0, total: 0 };
187
+ const counts = { pending: 0, sent: 0, accepted: 0, declined: 0, expired: 0, total: 0 };
123
188
  statusAgg.forEach( ( s ) => {
124
189
  if ( counts[s._id] != null ) {
125
190
  counts[s._id] = s.count;
@@ -175,7 +240,7 @@ export async function createEstimate( req, res ) {
175
240
  amount,
176
241
  totalAmount,
177
242
  currency: b.currency || ( client?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr' ),
178
- status: b.status === 'sent' ? 'sent' : 'draft',
243
+ status: b.status === 'sent' ? 'sent' : 'pending',
179
244
  createdDate,
180
245
  validTill,
181
246
  createdBy: req.user?.email || req.user?.userName || '',
@@ -240,7 +305,7 @@ export async function updateEstimate( req, res ) {
240
305
  update.period = b.period;
241
306
  }
242
307
  // Allow a status nudge (e.g. draft → sent) on save, but never to accepted.
243
- if ( b.status && [ 'draft', 'sent' ].includes( b.status ) ) {
308
+ if ( b.status && [ 'pending', 'sent' ].includes( b.status ) ) {
244
309
  update.status = b.status;
245
310
  }
246
311
 
@@ -270,7 +335,7 @@ export async function getEstimate( req, res ) {
270
335
  export async function estimateStatusUpdate( req, res ) {
271
336
  try {
272
337
  const { estimateId, status } = req.body || {};
273
- const allowed = [ 'draft', 'sent', 'accepted', 'declined', 'expired' ];
338
+ const allowed = [ 'pending', 'sent', 'accepted', 'declined', 'expired' ];
274
339
  if ( !estimateId || !allowed.includes( status ) ) {
275
340
  return res.sendError( 'estimateId and a valid status are required', 400 );
276
341
  }
@@ -293,66 +358,7 @@ export async function downloadEstimate( req, res ) {
293
358
  if ( !estimate ) {
294
359
  return res.sendError( 'Estimate not found', 404 );
295
360
  }
296
- const e = estimate._doc || estimate;
297
- const currencyType = symbolFor( e.currency );
298
- const fmt = ( n ) => Number( n || 0 ).toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
299
-
300
- const products = ( e.products || [] ).map( ( p, i ) => {
301
- let name = String( p.productName || '' ).replace( /([a-z])([A-Z])/g, '$1 $2' );
302
- name = name.charAt( 0 ).toUpperCase() + name.slice( 1 );
303
- return {
304
- index: i + 1,
305
- productName: name,
306
- description: p.description || '',
307
- hsn: p.hsn || p.hsnCode || '998314',
308
- storeCount: p.storeCount || e.stores || '',
309
- price: fmt( p.price ),
310
- amount: fmt( p.amount ),
311
- };
312
- } );
313
- const tax = ( e.tax || [] ).map( ( t ) => ( {
314
- type: t.type || t.taxName || t.name || 'GST',
315
- value: t.value ?? t.taxPercentage ?? t.percentage ?? '',
316
- taxAmount: fmt( t.taxAmount ),
317
- } ) );
318
-
319
- const statusLabelMap = { draft: 'Draft', sent: 'Sent', accepted: 'Accepted', declined: 'Declined', expired: 'Expired' };
320
- const data = {
321
- estimate: e.estimate,
322
- status: e.status,
323
- statusLabel: statusLabelMap[e.status] || e.status,
324
- companyName: e.companyName || '',
325
- companyAddress: e.companyAddress || '',
326
- GSTNumber: e.GSTNumber || '',
327
- PlaceOfSupply: e.PlaceOfSupply || '',
328
- groupName: e.groupName || '',
329
- period: e.period || '',
330
- createdDate: e.createdDate ? dayjs( e.createdDate ).format( 'DD/MM/YYYY' ) : '',
331
- validTill: e.validTill ? dayjs( e.validTill ).format( 'DD/MM/YYYY' ) : '',
332
- currencyType,
333
- amount: fmt( e.amount ),
334
- totalAmount: fmt( e.totalAmount ),
335
- products,
336
- tax,
337
- notes: e.notes || '',
338
- logo: `${JSON.parse( process.env.URL ).apiDomain}/logo.png`,
339
- };
340
-
341
- const templateHtml = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/estimatePdf.hbs', 'utf8' );
342
- const template = Handlebars.compile( templateHtml );
343
- const html = template( data );
344
- const file = { content: html };
345
- const options = {
346
- executablePath: '/usr/bin/chromium',
347
- args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--no-zygote', '--single-process' ],
348
- format: 'A4',
349
- margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
350
- printBackground: true,
351
- preferCSSPageSize: true,
352
- };
353
- const pdfBuffer = await htmlpdf.generatePdf( file, options );
354
-
355
- const filename = ( e.estimate + '-' + ( e.companyName || 'estimate' ) + '.pdf' ).split( '/' ).join( '_' ).split( '"' ).join( '' ).trim();
361
+ const { pdfBuffer, filename } = await buildEstimatePdf( estimate );
356
362
  res.set( 'Content-Type', 'application/pdf' );
357
363
  res.set( 'Content-Disposition', `attachment; filename="${filename}"` );
358
364
  return res.send( pdfBuffer );
@@ -362,6 +368,71 @@ export async function downloadEstimate( req, res ) {
362
368
  }
363
369
  }
364
370
 
371
+ // Approve & send an estimate: render the PDF, email it (with the PDF attached)
372
+ // to the billing group's recipients — same recipient logic as invoice send —
373
+ // then flip the status to 'sent'. Triggered by the Approve action's confirm.
374
+ export async function sendEstimate( req, res ) {
375
+ try {
376
+ const estimateId = req.body?.estimateId || req.params?.estimateId;
377
+ if ( !estimateId ) {
378
+ return res.sendError( 'estimateId is required', 400 );
379
+ }
380
+ const estimate = await estimateService.findOne( { _id: estimateId } );
381
+ if ( !estimate ) {
382
+ return res.sendError( 'Estimate not found', 404 );
383
+ }
384
+ const e = estimate._doc || estimate;
385
+ if ( e.status === 'accepted' ) {
386
+ return res.sendError( 'An accepted estimate cannot be re-sent.', 409 );
387
+ }
388
+
389
+ // Recipients: billing group's generateInvoiceTo (TO) + invoice heads /
390
+ // assigned CSMs (CC) — identical to the invoice send flow.
391
+ let toEmails = [];
392
+ if ( e.groupId ) {
393
+ const group = await billingService.findOne( { _id: e.groupId } );
394
+ toEmails = ( group?.generateInvoiceTo || [] ).map( ( x ) => String( x || '' ).trim() ).filter( Boolean );
395
+ }
396
+ toEmails = [ ...new Set( toEmails ) ];
397
+ if ( !toEmails.length ) {
398
+ return res.sendError( 'To Email not Found — configure recipients on the billing group.', 400 );
399
+ }
400
+ const ccRaw = await getInvoiceCcEmails( e.clientId );
401
+ const toSet = new Set( toEmails.map( ( x ) => x.toLowerCase() ) );
402
+ const ccEmails = [ ...new Set( ( ccRaw || [] ).map( ( x ) => String( x || '' ).trim() ).filter( Boolean ) ) ]
403
+ .filter( ( x ) => !toSet.has( x.toLowerCase() ) );
404
+
405
+ const { pdfBuffer, filename, data } = await buildEstimatePdf( estimate );
406
+
407
+ const client = await clientService.findOne( { clientId: e.clientId }, { clientName: 1 } );
408
+ const emailData = {
409
+ ...data,
410
+ clientName: client?.clientName || e.companyName || 'Customer',
411
+ // The email is sent FROM Tango, so the sign-off is the sender — not the
412
+ // client's registered company name carried on the estimate.
413
+ companyName: 'Team Tango',
414
+ };
415
+ const emailHtml = Handlebars.compile(
416
+ fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/estimateEmail.hbs', 'utf8' ),
417
+ )( emailData );
418
+
419
+ const SES = JSON.parse( process.env.SES );
420
+ const fromEmail = SES.accountsEmail || SES.adminEmail;
421
+ const subject = `Estimate ${e.estimate} - Tango/${client?.clientName || e.companyName || ''}`;
422
+ const attachment = { filename, content: pdfBuffer, contentType: 'application/pdf' };
423
+
424
+ await sendEmailWithSES( toEmails, subject, emailHtml, attachment, fromEmail, ccEmails.length ? ccEmails : undefined );
425
+
426
+ // Mark sent only after a successful send.
427
+ await estimateService.updateOne( { _id: estimateId }, { $set: { status: 'sent' } } );
428
+ logger.info?.( { function: 'sendEstimate', estimateId, to: toEmails, cc: ccEmails } );
429
+ return res.sendSuccess( { estimateId, status: 'sent', sentTo: toEmails } );
430
+ } catch ( error ) {
431
+ logger.error( { error: error, function: 'sendEstimate' } );
432
+ return res.sendError( error, 500 );
433
+ }
434
+ }
435
+
365
436
  export async function deleteEstimate( req, res ) {
366
437
  try {
367
438
  const estimate = await estimateService.findOne( { _id: req.params.estimateId } );
@@ -371,8 +442,10 @@ export async function deleteEstimate( req, res ) {
371
442
  if ( estimate.status === 'accepted' ) {
372
443
  return res.sendError( 'An accepted estimate cannot be deleted.', 409 );
373
444
  }
374
- await estimateService.updateOne( { _id: req.params.estimateId }, { $set: { status: 'declined' } } );
375
- return res.sendSuccess( 'Estimate removed' );
445
+ // Hard delete remove the document from the collection (not a soft
446
+ // status change).
447
+ await estimateService.deleteOne( { _id: req.params.estimateId } );
448
+ return res.sendSuccess( 'Estimate deleted' );
376
449
  } catch ( error ) {
377
450
  logger.error( { error: error, function: 'deleteEstimate' } );
378
451
  return res.sendError( error, 500 );
@@ -24,7 +24,7 @@ import { getUsdInrRate } from './brandsBilling.controller.js';
24
24
  // userAssignedStore (clientId + tangoUserType:'csm'). Returns a flat de-duped
25
25
  // array suitable for the SES CC field. Returns [] on any failure — invoice
26
26
  // email send should never fail because of missing settings or DB hiccups.
27
- async function getInvoiceCcEmails( clientId ) {
27
+ export async function getInvoiceCcEmails( clientId ) {
28
28
  const collected = [];
29
29
 
30
30
  // 1. Configured heads (global, applies to every invoice).
@@ -273,9 +273,15 @@ export async function createInvoice( req, res ) {
273
273
 
274
274
  ] );
275
275
 
276
- let invoicedate = req.body.invoiceId ? dayjs( findInvoice.billingDate ).format( 'YYYY-MM-DD' ) : baseDate.format( 'YYYY-MM-DD' );
276
+ // billingDate is the actual invoice (creation) date even for advance
277
+ // invoices. The advance MONTH is reflected only in the product lines /
278
+ // monthOfbilling (driven by baseDate), not in the billing date itself.
279
+ // (Regenerate keeps the original invoice's billingDate.)
280
+ const creationDate = dayjs();
281
+ let invoicedate = req.body.invoiceId ? dayjs( findInvoice.billingDate ).format( 'YYYY-MM-DD' ) : creationDate.format( 'YYYY-MM-DD' );
277
282
  let daysExtend = group?.paymentTerm ? group?.paymentTerm : 30;
278
- let dueDate = baseDate.add( daysExtend, 'days' );
283
+ // Due date is relative to the billing (creation) date.
284
+ let dueDate = creationDate.add( daysExtend, 'days' );
279
285
  console.log( 'group.currencygroup.currency', group.currency );
280
286
  let data = {
281
287
  groupName: group.groupName,
@@ -842,6 +848,24 @@ export async function invoiceDownloadBulk( req, res ) {
842
848
  }
843
849
  }
844
850
 
851
+ // Bank/beneficiary details for the invoice preview. Fixed beneficiary account
852
+ // — hardcoded to match the PDF exactly (NOT read from the DB).
853
+ export async function invoiceBankDetails( req, res ) {
854
+ try {
855
+ return res.sendSuccess( {
856
+ beneficiaryName: 'Tango IT Solutions India Private Limited',
857
+ accountNumber: '50200027441433',
858
+ ifsc: 'HDFC0000386',
859
+ swift: 'HDFCINBBCHE',
860
+ branch: 'Santhome, Chennai',
861
+ paymentType: 'Online',
862
+ } );
863
+ } catch ( error ) {
864
+ logger.error( { error: error, function: 'invoiceBankDetails', invoiceId: req.params.invoiceId } );
865
+ return res.sendError( error, 500 );
866
+ }
867
+ }
868
+
845
869
  export async function invoiceAnnexure( req, res ) {
846
870
  try {
847
871
  const invoiceInfo = await invoiceService.findOne( { _id: req.params.invoiceId } );
@@ -0,0 +1,78 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Estimate</title>
7
+ </head>
8
+ <body style="margin:0;padding:0;background-color:#dbe5ea;font-family:'Inter',Arial,sans-serif;">
9
+ <table border="0" cellpadding="0" cellspacing="0" width="100%" style="padding:0 10px;">
10
+ <tr>
11
+ <td style="padding:32px 10px 0 10px;">
12
+ <table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width:680px;margin:0 auto;" align="center">
13
+ <tr>
14
+ <td style="background-color:#ffffff;padding:24px 24px 0 18px;">
15
+ <img src="{{logo}}" width="200" height="100" alt="Tango Eye" style="vertical-align:middle;border:0;height:auto;">
16
+ </td>
17
+ </tr>
18
+ <tr>
19
+ <td style="background-color:#ffffff;padding:0 24px;">
20
+ <p style="border-top:1px solid #CBD5E1;margin:0;"></p>
21
+ </td>
22
+ </tr>
23
+ <tr>
24
+ <td style="background-color:#ffffff;padding:18px 24px 6px 30px;">
25
+ <span style="font-weight:700;color:#121A26;font-size:22px;line-height:32px;">Your estimate {{estimate}}</span>
26
+ </td>
27
+ </tr>
28
+ <tr>
29
+ <td style="background-color:#ffffff;padding:14px 24px 4px 30px;font-size:16px;line-height:150%;color:#384860;">
30
+ <p style="margin:0;">Hi {{clientName}},</p>
31
+ </td>
32
+ </tr>
33
+ <tr>
34
+ <td style="background-color:#ffffff;padding:10px 24px 6px 30px;font-size:16px;line-height:150%;color:#384860;">
35
+ <p style="margin:0;">Please find attached the estimate <strong>{{estimate}}</strong>{{#if period}} for <strong>{{period}}</strong>{{/if}}. A summary is shown below; the attached PDF has the full breakdown.</p>
36
+ </td>
37
+ </tr>
38
+
39
+ <tr>
40
+ <td style="background-color:#ffffff;padding:14px 24px 6px 30px;">
41
+ <table border="0" cellpadding="0" cellspacing="0" width="100%" style="border:1px solid #E2E8F0;border-radius:8px;border-collapse:separate;overflow:hidden;">
42
+ <tr style="background-color:#F1F5F9;">
43
+ <td style="padding:10px 14px;font-size:13px;color:#64748B;font-weight:600;">Estimate Number</td>
44
+ <td style="padding:10px 14px;font-size:13px;color:#121A26;font-weight:700;text-align:right;">{{estimate}}</td>
45
+ </tr>
46
+ <tr>
47
+ <td style="padding:10px 14px;font-size:13px;color:#64748B;font-weight:600;border-top:1px solid #E2E8F0;">Total Amount</td>
48
+ <td style="padding:10px 14px;font-size:13px;color:#121A26;font-weight:700;text-align:right;border-top:1px solid #E2E8F0;">{{currencyType}} {{totalAmount}}</td>
49
+ </tr>
50
+ <tr>
51
+ <td style="padding:10px 14px;font-size:13px;color:#64748B;font-weight:600;border-top:1px solid #E2E8F0;">Valid Till</td>
52
+ <td style="padding:10px 14px;font-size:13px;color:#121A26;font-weight:700;text-align:right;border-top:1px solid #E2E8F0;">{{validTill}}</td>
53
+ </tr>
54
+ </table>
55
+ </td>
56
+ </tr>
57
+
58
+ <tr>
59
+ <td style="background-color:#ffffff;padding:14px 24px 6px 30px;font-size:16px;line-height:150%;color:#384860;">
60
+ <p style="margin:0;">This is an estimate, not a tax invoice. Prices are subject to the terms agreed in the final subscription. If you'd like to proceed or have any questions, please reply to this email.</p>
61
+ </td>
62
+ </tr>
63
+ <tr>
64
+ <td style="background-color:#ffffff;padding:14px 24px 24px 30px;font-size:16px;line-height:150%;color:#384860;">
65
+ <p style="margin:0;">Best regards,<br>{{companyName}}</p>
66
+ </td>
67
+ </tr>
68
+ <tr>
69
+ <td style="background-color:#ffffff;padding:10px 24px 18px 30px;font-size:12px;color:#202B3C;line-height:150%;">
70
+ <p style="margin:0;">© Tango Eye. All rights reserved.</p>
71
+ </td>
72
+ </tr>
73
+ </table>
74
+ </td>
75
+ </tr>
76
+ </table>
77
+ </body>
78
+ </html>