tango-app-api-payment-subscription 3.5.7 → 3.5.8
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 +2 -2
- package/scripts/seed-payment-reminders.js +82 -0
- package/scripts/send-reminder-test-emails.js +70 -0
- package/src/controllers/brandsBilling.controller.js +107 -8
- package/src/controllers/estimate.controller.js +63 -0
- package/src/controllers/invoice.controller.js +66 -10
- package/src/controllers/paymentReminderTrigger.controller.js +194 -0
- package/src/hbs/invoicePdf.hbs +1779 -1779
- package/src/hbs/partials/invoiceSummaryTable.hbs +33 -0
- package/src/hbs/reminderBeforeDue.hbs +62 -0
- package/src/hbs/reminderDeactivated.hbs +62 -0
- package/src/hbs/reminderOnDue.hbs +62 -0
- package/src/hbs/reminderOnHold.hbs +62 -0
- package/src/hbs/reminderSuspended.hbs +62 -0
- package/src/routes/billing.routes.js +5 -0
- package/src/routes/invoice.routes.js +2 -1
- package/src/services/paymentReminder.service.js +4 -0
- package/src/utils/currency.js +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tango-app-api-payment-subscription",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.8",
|
|
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.
|
|
32
|
+
"tango-api-schema": "^2.6.27",
|
|
33
33
|
"tango-app-api-middleware": "^3.6.18",
|
|
34
34
|
"winston": "^3.12.0",
|
|
35
35
|
"winston-daily-rotate-file": "^5.0.0",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// One-shot seeder: creates a paymentreminders config for every ACTIVE client
|
|
2
|
+
// (clients.status === 'active'), with a single static recipient email and the
|
|
3
|
+
// app's default template toggles. Idempotent — re-running upserts the same
|
|
4
|
+
// docs, so existing configs are overwritten with the static email + defaults.
|
|
5
|
+
//
|
|
6
|
+
// Usage (from the API project root):
|
|
7
|
+
// node scripts/seed-payment-reminders.js
|
|
8
|
+
// node scripts/seed-payment-reminders.js someone@else.com # override email
|
|
9
|
+
// node scripts/seed-payment-reminders.js --dry-run # report only
|
|
10
|
+
//
|
|
11
|
+
// Reads MONGO connection + env from .env (same as the app).
|
|
12
|
+
|
|
13
|
+
import dotenv from 'dotenv';
|
|
14
|
+
import mongoose from 'mongoose';
|
|
15
|
+
import model from 'tango-api-schema';
|
|
16
|
+
import { getConnection } from '../config/database/database.js';
|
|
17
|
+
|
|
18
|
+
dotenv.config();
|
|
19
|
+
|
|
20
|
+
const args = process.argv.slice( 2 );
|
|
21
|
+
const dryRun = args.includes( '--dry-run' );
|
|
22
|
+
const STATIC_EMAIL = args.find( ( a ) => a.includes( '@' ) ) || 'ayyanarkalusulingam13@gmail.com';
|
|
23
|
+
|
|
24
|
+
// Mirrors the app's DEFAULTS() in paymentReminder.controller.js.
|
|
25
|
+
const DEFAULT_TEMPLATES = {
|
|
26
|
+
preDue: { enabled: true, daysBefore: 3 },
|
|
27
|
+
onDue: { enabled: true },
|
|
28
|
+
onHold: { enabled: true },
|
|
29
|
+
suspend: { enabled: true },
|
|
30
|
+
deactivated: { enabled: false },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
async function run() {
|
|
34
|
+
// Reuse the app's own connection builder (reads mongo_* env config).
|
|
35
|
+
const { uri, options } = getConnection();
|
|
36
|
+
await mongoose.connect( uri, options );
|
|
37
|
+
console.log( `Connected. Static recipient: ${STATIC_EMAIL}${dryRun ? ' (DRY RUN — no writes)' : ''}\n` );
|
|
38
|
+
|
|
39
|
+
const clients = await model.clientModel.find(
|
|
40
|
+
{ status: 'active' },
|
|
41
|
+
{ clientId: 1, clientName: 1 },
|
|
42
|
+
).lean();
|
|
43
|
+
console.log( `Active clients found: ${clients.length}\n` );
|
|
44
|
+
|
|
45
|
+
const summary = { total: clients.length, upserted: 0, skipped: 0 };
|
|
46
|
+
|
|
47
|
+
for ( const c of clients ) {
|
|
48
|
+
if ( !c.clientId ) {
|
|
49
|
+
summary.skipped++;
|
|
50
|
+
console.log( ` - skip (no clientId): ${c.clientName || c._id}` );
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if ( dryRun ) {
|
|
54
|
+
summary.upserted++;
|
|
55
|
+
console.log( ` • would upsert: ${c.clientName || c.clientId}` );
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
await model.paymentReminderModel.updateOne(
|
|
59
|
+
{ clientId: c.clientId },
|
|
60
|
+
{
|
|
61
|
+
$set: {
|
|
62
|
+
clientId: c.clientId,
|
|
63
|
+
reminderEmails: [ STATIC_EMAIL ],
|
|
64
|
+
templates: DEFAULT_TEMPLATES,
|
|
65
|
+
updatedBy: 'seed-script',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{ upsert: true },
|
|
69
|
+
);
|
|
70
|
+
summary.upserted++;
|
|
71
|
+
console.log( ` ✓ ${c.clientName || c.clientId}` );
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log( `\nDone. ${summary.upserted} upserted, ${summary.skipped} skipped (of ${summary.total} active).` );
|
|
75
|
+
await mongoose.disconnect();
|
|
76
|
+
process.exit( 0 );
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
run().catch( ( err ) => {
|
|
80
|
+
console.error( 'Failed:', err?.message || err );
|
|
81
|
+
process.exit( 1 );
|
|
82
|
+
} );
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// One-shot tester: renders all 5 payment-reminder templates with sample
|
|
2
|
+
// invoice data and emails each one to a single recipient, so you can see
|
|
3
|
+
// exactly what every reminder stage looks like. Uses the real SES setup
|
|
4
|
+
// (reads AWS_CONFIG / SES from .env, same as the app).
|
|
5
|
+
//
|
|
6
|
+
// Usage (from the API project root):
|
|
7
|
+
// node scripts/send-reminder-test-emails.js
|
|
8
|
+
// node scripts/send-reminder-test-emails.js someone@else.com
|
|
9
|
+
//
|
|
10
|
+
// The recipient defaults to the address below; pass an arg to override.
|
|
11
|
+
|
|
12
|
+
import dotenv from 'dotenv';
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
import Handlebars from '../src/utils/validations/helper/handlebar.helper.js';
|
|
17
|
+
import { sendEmailWithSES } from 'tango-app-api-middleware';
|
|
18
|
+
|
|
19
|
+
dotenv.config();
|
|
20
|
+
|
|
21
|
+
const TO = process.argv[2] || 'ayyanarkalusulingam13@gmail.com';
|
|
22
|
+
|
|
23
|
+
const __dirname = path.dirname( fileURLToPath( import.meta.url ) );
|
|
24
|
+
const HBS_DIR = path.resolve( __dirname, '../src/hbs' );
|
|
25
|
+
Handlebars.registerPartial(
|
|
26
|
+
'invoiceSummaryTable',
|
|
27
|
+
fs.readFileSync( `${HBS_DIR}/partials/invoiceSummaryTable.hbs`, 'utf8' ),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// Sample data — a representative client with two unpaid invoices.
|
|
31
|
+
const data = {
|
|
32
|
+
clientName: 'Cashify',
|
|
33
|
+
companyName: 'Team Tango',
|
|
34
|
+
dueDate: '17 Jun 2026',
|
|
35
|
+
totalDue: '₹ 2,70,106.00',
|
|
36
|
+
logo: `${JSON.parse( process.env.URL ).apiDomain}/logo.png`,
|
|
37
|
+
invoices: [
|
|
38
|
+
{ invoiceNumber: 'INV-26-27-00210', invoiceDate: '02 Jun 2026', dueDate: '17 Jun 2026', amountDue: '₹ 1,42,947.00' },
|
|
39
|
+
{ invoiceNumber: 'INV-26-27-00205', invoiceDate: '02 May 2026', dueDate: '17 May 2026', amountDue: '₹ 1,27,159.00' },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const STAGES = [
|
|
44
|
+
{ file: 'reminderBeforeDue.hbs', subject: `[TEST] Payment reminder — due on ${data.dueDate}` },
|
|
45
|
+
{ file: 'reminderOnDue.hbs', subject: `[TEST] Payment due today — ${data.totalDue} outstanding` },
|
|
46
|
+
{ file: 'reminderOnHold.hbs', subject: '[TEST] Action needed: payment overdue — your account is on hold' },
|
|
47
|
+
{ file: 'reminderSuspended.hbs', subject: '[TEST] Important: account suspended — payment 30+ days overdue' },
|
|
48
|
+
{ file: 'reminderDeactivated.hbs', subject: '[TEST] Final notice: account deactivated — payment 60+ days overdue' },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
async function run() {
|
|
52
|
+
const SES = JSON.parse( process.env.SES );
|
|
53
|
+
const fromEmail = SES.accountsEmail || SES.adminEmail;
|
|
54
|
+
console.log( `Sending ${STAGES.length} reminder emails to ${TO} (from ${fromEmail})\n` );
|
|
55
|
+
|
|
56
|
+
for ( const stage of STAGES ) {
|
|
57
|
+
const tpl = Handlebars.compile( fs.readFileSync( `${HBS_DIR}/${stage.file}`, 'utf8' ) );
|
|
58
|
+
const html = tpl( data );
|
|
59
|
+
try {
|
|
60
|
+
await sendEmailWithSES( TO, stage.subject, html, '', fromEmail );
|
|
61
|
+
console.log( ` ✓ sent: ${stage.file}` );
|
|
62
|
+
} catch ( err ) {
|
|
63
|
+
console.error( ` ✗ failed: ${stage.file} —`, err?.message || err );
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
console.log( '\nDone.' );
|
|
67
|
+
process.exit( 0 );
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
run();
|
|
@@ -15,6 +15,12 @@ export async function brandsBillingList( req, res ) {
|
|
|
15
15
|
try {
|
|
16
16
|
let query = [];
|
|
17
17
|
|
|
18
|
+
// Billing-store count is taken from dailyPricing for the current billing
|
|
19
|
+
// month: a store counts only if it actually RAN on more than one day in
|
|
20
|
+
// the month (a store that appears on a single day is transient — e.g. a
|
|
21
|
+
// late onboard or a one-off reading — and isn't billed for the period).
|
|
22
|
+
const billingMonthStart = new Date( dayjs().startOf( 'month' ).toISOString() );
|
|
23
|
+
|
|
18
24
|
if ( req.body.status && req.body.status.length > 0 ) {
|
|
19
25
|
query.push( {
|
|
20
26
|
$match: {
|
|
@@ -38,9 +44,15 @@ export async function brandsBillingList( req, res ) {
|
|
|
38
44
|
let: { clientId: '$clientId' },
|
|
39
45
|
pipeline: [
|
|
40
46
|
{
|
|
47
|
+
// Count only ACTIVE stores — the 'stores' collection also holds
|
|
48
|
+
// deactive/suspended docs which inflated the No. of Stores
|
|
49
|
+
// column and made it inconsistent with Billing Stores.
|
|
41
50
|
$match: {
|
|
42
51
|
$expr: {
|
|
43
|
-
$
|
|
52
|
+
$and: [
|
|
53
|
+
{ $eq: [ '$clientId', '$$clientId' ] },
|
|
54
|
+
{ $eq: [ '$status', 'active' ] },
|
|
55
|
+
],
|
|
44
56
|
},
|
|
45
57
|
},
|
|
46
58
|
},
|
|
@@ -66,7 +78,6 @@ export async function brandsBillingList( req, res ) {
|
|
|
66
78
|
{
|
|
67
79
|
$group: {
|
|
68
80
|
_id: null,
|
|
69
|
-
billingStores: { $sum: { $size: { $ifNull: [ '$stores', [] ] } } },
|
|
70
81
|
products: { $addToSet: '$products' },
|
|
71
82
|
nextBillingDate: { $min: '$nextBillingDate' },
|
|
72
83
|
},
|
|
@@ -75,6 +86,35 @@ export async function brandsBillingList( req, res ) {
|
|
|
75
86
|
as: 'billingData',
|
|
76
87
|
},
|
|
77
88
|
},
|
|
89
|
+
{
|
|
90
|
+
// Billing stores from dailyPricing: distinct active stores that ran
|
|
91
|
+
// on MORE THAN ONE day in the current billing month. We unwind the
|
|
92
|
+
// per-day store rows, keep active ones, collect the distinct dates
|
|
93
|
+
// each store was active, then count only the stores seen on >1 day.
|
|
94
|
+
$lookup: {
|
|
95
|
+
from: 'dailypricings',
|
|
96
|
+
let: { clientId: '$clientId' },
|
|
97
|
+
pipeline: [
|
|
98
|
+
{
|
|
99
|
+
$match: {
|
|
100
|
+
$expr: { $eq: [ '$clientId', '$$clientId' ] },
|
|
101
|
+
dateISO: { $gte: billingMonthStart },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{ $unwind: { path: '$stores', preserveNullAndEmptyArrays: false } },
|
|
105
|
+
{ $match: { 'stores.status': 'active' } },
|
|
106
|
+
{
|
|
107
|
+
$group: {
|
|
108
|
+
_id: '$stores.storeId',
|
|
109
|
+
days: { $addToSet: { $dateToString: { format: '%Y-%m-%d', date: '$dateISO' } } },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{ $match: { $expr: { $gt: [ { $size: '$days' }, 1 ] } } },
|
|
113
|
+
{ $count: 'billingStores' },
|
|
114
|
+
],
|
|
115
|
+
as: 'dailyBillingData',
|
|
116
|
+
},
|
|
117
|
+
},
|
|
78
118
|
{
|
|
79
119
|
$lookup: {
|
|
80
120
|
from: 'invoices',
|
|
@@ -91,9 +131,14 @@ export async function brandsBillingList( req, res ) {
|
|
|
91
131
|
},
|
|
92
132
|
},
|
|
93
133
|
{
|
|
134
|
+
// Split unpaid totals by currency so dollar invoices can be
|
|
135
|
+
// converted to INR after aggregation (the $lookup can't call
|
|
136
|
+
// getUsdInrRate). Invoice totalAmount is stored in NATIVE
|
|
137
|
+
// currency, so summing blindly mislabelled USD as INR.
|
|
94
138
|
$group: {
|
|
95
139
|
_id: null,
|
|
96
|
-
|
|
140
|
+
dueInr: { $sum: { $cond: [ { $eq: [ '$currency', 'dollar' ] }, 0, '$totalAmount' ] } },
|
|
141
|
+
dueUsd: { $sum: { $cond: [ { $eq: [ '$currency', 'dollar' ] }, '$totalAmount', 0 ] } },
|
|
97
142
|
},
|
|
98
143
|
},
|
|
99
144
|
],
|
|
@@ -106,13 +151,16 @@ export async function brandsBillingList( req, res ) {
|
|
|
106
151
|
{
|
|
107
152
|
$unwind: { path: '$billingData', preserveNullAndEmptyArrays: true },
|
|
108
153
|
},
|
|
154
|
+
{
|
|
155
|
+
$unwind: { path: '$dailyBillingData', preserveNullAndEmptyArrays: true },
|
|
156
|
+
},
|
|
109
157
|
{
|
|
110
158
|
$unwind: { path: '$invoiceData', preserveNullAndEmptyArrays: true },
|
|
111
159
|
},
|
|
112
160
|
{
|
|
113
161
|
$addFields: {
|
|
114
162
|
totalStores: { $ifNull: [ '$storeData.totalStores', 0 ] },
|
|
115
|
-
billingStores: { $ifNull: [ '$
|
|
163
|
+
billingStores: { $ifNull: [ '$dailyBillingData.billingStores', 0 ] },
|
|
116
164
|
productsAdded: {
|
|
117
165
|
$size: {
|
|
118
166
|
$filter: {
|
|
@@ -122,7 +170,8 @@ export async function brandsBillingList( req, res ) {
|
|
|
122
170
|
},
|
|
123
171
|
},
|
|
124
172
|
},
|
|
125
|
-
|
|
173
|
+
dueInr: { $ifNull: [ '$invoiceData.dueInr', 0 ] },
|
|
174
|
+
dueUsd: { $ifNull: [ '$invoiceData.dueUsd', 0 ] },
|
|
126
175
|
nextBillingDate: { $ifNull: [ '$billingData.nextBillingDate', null ] },
|
|
127
176
|
},
|
|
128
177
|
},
|
|
@@ -135,7 +184,8 @@ export async function brandsBillingList( req, res ) {
|
|
|
135
184
|
totalStores: 1,
|
|
136
185
|
billingStores: 1,
|
|
137
186
|
productsAdded: 1,
|
|
138
|
-
|
|
187
|
+
dueInr: 1,
|
|
188
|
+
dueUsd: 1,
|
|
139
189
|
status: 1,
|
|
140
190
|
paymentStatus: '$planDetails.paymentStatus',
|
|
141
191
|
nextBillingDate: 1,
|
|
@@ -164,6 +214,17 @@ export async function brandsBillingList( req, res ) {
|
|
|
164
214
|
|
|
165
215
|
let allData = await clientService.aggregate( query );
|
|
166
216
|
|
|
217
|
+
// Bill Amount Due in INR: dollar invoice totals converted at today's rate
|
|
218
|
+
// and added to the INR totals, so the column and the ₹-labelled Total Bill
|
|
219
|
+
// Due chip are a single, additive currency. (Previously USD was shown as ₹
|
|
220
|
+
// and the grand total summed INR+USD raw.)
|
|
221
|
+
const billDueRate = await getUsdInrRate();
|
|
222
|
+
for ( const c of allData ) {
|
|
223
|
+
c.billAmountDue = Math.round( ( ( c.dueInr || 0 ) + ( c.dueUsd || 0 ) * billDueRate ) * 100 ) / 100;
|
|
224
|
+
delete c.dueInr;
|
|
225
|
+
delete c.dueUsd;
|
|
226
|
+
}
|
|
227
|
+
|
|
167
228
|
// Lifecycle + payment counts over the FULL client population (no status /
|
|
168
229
|
// paymentStatus filter), so the overview cards stay stable regardless of
|
|
169
230
|
// which lifecycle tab is selected — and so Hold / Suspended / Deactive
|
|
@@ -190,6 +251,7 @@ export async function brandsBillingList( req, res ) {
|
|
|
190
251
|
{ case: { $eq: [ '$$ps', 'free' ] }, then: 'free' },
|
|
191
252
|
{ case: { $and: [ { $eq: [ '$$ps', 'paid' ] }, '$$hasTrialProduct' ] }, then: 'trialPaid' },
|
|
192
253
|
{ case: { $eq: [ '$$ps', 'paid' ] }, then: 'paid' },
|
|
254
|
+
{ case: { $eq: [ '$$ps', 'unbilled' ] }, then: 'unbilled' },
|
|
193
255
|
],
|
|
194
256
|
default: 'other',
|
|
195
257
|
},
|
|
@@ -206,7 +268,7 @@ export async function brandsBillingList( req, res ) {
|
|
|
206
268
|
// regardless of the selected tab, and show even when the Active tab is
|
|
207
269
|
// empty.
|
|
208
270
|
const lifecycle = { active: 0, hold: 0, suspended: 0, deactive: 0 };
|
|
209
|
-
const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
|
|
271
|
+
const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0, unbilled: 0 };
|
|
210
272
|
const paymentByStatus = {};
|
|
211
273
|
let totalBrands = 0;
|
|
212
274
|
matrixAgg.forEach( ( row ) => {
|
|
@@ -221,7 +283,7 @@ export async function brandsBillingList( req, res ) {
|
|
|
221
283
|
payTotals[pay] += n;
|
|
222
284
|
}
|
|
223
285
|
if ( !paymentByStatus[st] ) {
|
|
224
|
-
paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
|
|
286
|
+
paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0, unbilled: 0 };
|
|
225
287
|
}
|
|
226
288
|
if ( paymentByStatus[st][pay] != null ) {
|
|
227
289
|
paymentByStatus[st][pay] += n;
|
|
@@ -238,6 +300,7 @@ export async function brandsBillingList( req, res ) {
|
|
|
238
300
|
paid: payTotals.paid,
|
|
239
301
|
free: payTotals.free,
|
|
240
302
|
trialPaid: payTotals.trialPaid,
|
|
303
|
+
unbilled: payTotals.unbilled,
|
|
241
304
|
paymentByStatus,
|
|
242
305
|
// Money/store totals stay tied to the filtered view so they match the
|
|
243
306
|
// rows on screen.
|
|
@@ -1476,6 +1539,12 @@ export async function billingSummary( req, res ) {
|
|
|
1476
1539
|
installationInr: { $sum: { $cond: [ '$isDollar', 0, '$installation' ] } },
|
|
1477
1540
|
installationUsd: { $sum: { $cond: [ '$isDollar', '$installation', 0 ] } },
|
|
1478
1541
|
companyName: { $last: '$companyName' },
|
|
1542
|
+
// Track the actual invoice currencies so the row currency reflects how
|
|
1543
|
+
// the client is really billed — not the (sometimes stale)
|
|
1544
|
+
// paymentInvoice.currencyType. e.g. Sundora is flagged 'dollar' but
|
|
1545
|
+
// every invoice is INR.
|
|
1546
|
+
dollarInvoices: { $sum: { $cond: [ '$isDollar', 1, 0 ] } },
|
|
1547
|
+
inrInvoices: { $sum: { $cond: [ '$isDollar', 0, 1 ] } },
|
|
1479
1548
|
} },
|
|
1480
1549
|
] );
|
|
1481
1550
|
|
|
@@ -1556,11 +1625,15 @@ export async function billingSummary( req, res ) {
|
|
|
1556
1625
|
// to (status 'live') — trials are excluded.
|
|
1557
1626
|
liveProductSet: new Set( products.filter( ( p ) => p.status === 'live' )
|
|
1558
1627
|
.map( ( p ) => String( p.productName || '' ).toLowerCase() ) ),
|
|
1628
|
+
// Fallback only — overridden below from actual invoice currencies
|
|
1629
|
+
// when the client has invoices.
|
|
1559
1630
|
currency: c?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr',
|
|
1560
1631
|
csm: [ ...( csmByClient.get( key ) || [] ) ].join( ', ' ),
|
|
1561
1632
|
revenueMonths: {},
|
|
1562
1633
|
billedStoresMonths: {},
|
|
1563
1634
|
installationFee: 0,
|
|
1635
|
+
invDollar: 0,
|
|
1636
|
+
invInr: 0,
|
|
1564
1637
|
} );
|
|
1565
1638
|
}
|
|
1566
1639
|
return rows.get( key );
|
|
@@ -1571,11 +1644,37 @@ export async function billingSummary( req, res ) {
|
|
|
1571
1644
|
r.revenueMonths[inv._id.ym] = Math.round( ( ( inv.revenueInr || 0 ) + ( inv.revenueUsd || 0 ) * usdRate ) * 100 ) / 100;
|
|
1572
1645
|
r.billedStoresMonths[inv._id.ym] = inv.stores || 0;
|
|
1573
1646
|
r.installationFee += ( inv.installationInr || 0 ) + ( inv.installationUsd || 0 ) * usdRate;
|
|
1647
|
+
r.invDollar += ( inv.dollarInvoices || 0 );
|
|
1648
|
+
r.invInr += ( inv.inrInvoices || 0 );
|
|
1574
1649
|
if ( inv.companyName ) {
|
|
1575
1650
|
r.registeredEntity = inv.companyName;
|
|
1576
1651
|
}
|
|
1577
1652
|
}
|
|
1578
1653
|
|
|
1654
|
+
// Resolve each row's currency from its actual invoices: 'dollar' only when
|
|
1655
|
+
// the client has dollar invoices and NO inr invoices; otherwise 'inr'. The
|
|
1656
|
+
// paymentInvoice.currencyType fallback set above stands only for clients
|
|
1657
|
+
// with no invoices in the window.
|
|
1658
|
+
for ( const r of rows.values() ) {
|
|
1659
|
+
if ( r.invDollar > 0 || r.invInr > 0 ) {
|
|
1660
|
+
r.currency = ( r.invDollar > 0 && r.invInr === 0 ) ? 'dollar' : 'inr';
|
|
1661
|
+
}
|
|
1662
|
+
delete r.invDollar;
|
|
1663
|
+
delete r.invInr;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// A client may be actively billing yet have no invoice in the 5-month
|
|
1667
|
+
// window (e.g. invoice for the running month not generated yet). Seed a
|
|
1668
|
+
// row for any client that has a current-month store reading in
|
|
1669
|
+
// dailyPricing, so the list reflects every brand under billing — not just
|
|
1670
|
+
// the ones with a recent invoice. (Without this, brands silently vanish
|
|
1671
|
+
// from the summary until their next invoice lands.)
|
|
1672
|
+
for ( const [ clientId ] of curStoresByClient ) {
|
|
1673
|
+
if ( clientById.has( clientId ) ) {
|
|
1674
|
+
rowOf( clientId );
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1579
1678
|
const curKey = months[4].key;
|
|
1580
1679
|
const prevKey = months[3].key;
|
|
1581
1680
|
const data = [ ...rows.values() ].map( ( r ) => {
|
|
@@ -191,6 +191,69 @@ export async function createEstimate( req, res ) {
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
export async function updateEstimate( req, res ) {
|
|
195
|
+
try {
|
|
196
|
+
const b = req.body || {};
|
|
197
|
+
const estimateId = b._id || b.estimateId || req.params.estimateId;
|
|
198
|
+
if ( !estimateId ) {
|
|
199
|
+
return res.sendError( 'estimateId is required', 400 );
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const existing = await estimateService.findOne( { _id: estimateId } );
|
|
203
|
+
if ( !existing ) {
|
|
204
|
+
return res.sendError( 'Estimate not found', 404 );
|
|
205
|
+
}
|
|
206
|
+
// Accepted estimates are locked — mirrors the delete guard.
|
|
207
|
+
if ( ( existing._doc || existing ).status === 'accepted' ) {
|
|
208
|
+
return res.sendError( 'An accepted estimate cannot be edited.', 409 );
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const amount = Math.round( Number( b.amount ) || 0 );
|
|
212
|
+
let totalAmount = Math.round( Number( b.totalAmount ) || 0 );
|
|
213
|
+
if ( !totalAmount && amount ) {
|
|
214
|
+
totalAmount = Math.round( amount * 1.18 );
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Only the editable fields are updated. The estimate number, index,
|
|
218
|
+
// clientId and createdDate are immutable so the document keeps its
|
|
219
|
+
// identity and financial-year sequence.
|
|
220
|
+
const update = {
|
|
221
|
+
companyName: b.companyName ?? existing.companyName,
|
|
222
|
+
companyAddress: b.companyAddress ?? existing.companyAddress,
|
|
223
|
+
PlaceOfSupply: b.PlaceOfSupply ?? existing.PlaceOfSupply,
|
|
224
|
+
GSTNumber: b.GSTNumber ?? existing.GSTNumber,
|
|
225
|
+
groupId: b.groupId || existing.groupId,
|
|
226
|
+
groupName: b.groupName || existing.groupName,
|
|
227
|
+
stores: Number( b.stores ) || 0,
|
|
228
|
+
products: Array.isArray( b.products ) ? b.products : existing.products,
|
|
229
|
+
tax: Array.isArray( b.tax ) ? b.tax : existing.tax,
|
|
230
|
+
amount,
|
|
231
|
+
totalAmount,
|
|
232
|
+
currency: b.currency || existing.currency,
|
|
233
|
+
notes: b.notes ?? existing.notes,
|
|
234
|
+
updatedBy: req.user?.email || req.user?.userName || '',
|
|
235
|
+
};
|
|
236
|
+
if ( b.validTill ) {
|
|
237
|
+
update.validTill = new Date( b.validTill );
|
|
238
|
+
}
|
|
239
|
+
if ( b.period ) {
|
|
240
|
+
update.period = b.period;
|
|
241
|
+
}
|
|
242
|
+
// Allow a status nudge (e.g. draft → sent) on save, but never to accepted.
|
|
243
|
+
if ( b.status && [ 'draft', 'sent' ].includes( b.status ) ) {
|
|
244
|
+
update.status = b.status;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
await estimateService.updateOne( { _id: estimateId }, { $set: update } );
|
|
248
|
+
const saved = await estimateService.findOne( { _id: estimateId } );
|
|
249
|
+
logger.info?.( { function: 'updateEstimate', estimateId } );
|
|
250
|
+
return res.sendSuccess( saved );
|
|
251
|
+
} catch ( error ) {
|
|
252
|
+
logger.error( { error: error, function: 'updateEstimate' } );
|
|
253
|
+
return res.sendError( error, 500 );
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
194
257
|
export async function getEstimate( req, res ) {
|
|
195
258
|
try {
|
|
196
259
|
const estimate = await estimateService.findOne( { _id: req.params.estimateId } );
|
|
@@ -142,7 +142,8 @@ export async function createInvoice( req, res ) {
|
|
|
142
142
|
clientId: req.body.clientId,
|
|
143
143
|
groupId: req.body.groupId || undefined,
|
|
144
144
|
groupName: req.body.groupName || '',
|
|
145
|
-
|
|
145
|
+
// Company (registered) name is always stored uppercase on invoices.
|
|
146
|
+
companyName: ( req.body.companyName || '' ).toUpperCase(),
|
|
146
147
|
companyAddress: req.body.companyAddress || '',
|
|
147
148
|
GSTNumber: req.body.GSTNumber || '',
|
|
148
149
|
PlaceOfSupply: req.body.PlaceOfSupply || '',
|
|
@@ -285,7 +286,7 @@ export async function createInvoice( req, res ) {
|
|
|
285
286
|
amount: Math.round( amount ),
|
|
286
287
|
invoiceIndex: req.body.invoiceId ? findInvoice.invoiceIndex : invoiceNo,
|
|
287
288
|
tax: taxList,
|
|
288
|
-
companyName: group.registeredCompanyName,
|
|
289
|
+
companyName: ( group.registeredCompanyName || '' ).toUpperCase(),
|
|
289
290
|
companyAddress: address,
|
|
290
291
|
PlaceOfSupply: group.placeOfSupply,
|
|
291
292
|
GSTNumber: group.gst,
|
|
@@ -372,6 +373,10 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
372
373
|
const billingMonthEnd = new Date( billingMonth.endOf( 'month' ).toISOString() );
|
|
373
374
|
const monthDays = billingMonth.daysInMonth();
|
|
374
375
|
const invoiceCurrency = symbolFor( invoiceInfo.currency );
|
|
376
|
+
// basepricing negotiatePrice is stored in INR. For non-INR invoices the
|
|
377
|
+
// annexure must convert it to the invoice currency, otherwise the INR number
|
|
378
|
+
// is shown verbatim under a $ symbol (e.g. ₹1650 rendered as "$1,650").
|
|
379
|
+
const annexFx = invoiceInfo.currency === 'dollar' ? ( await getUsdInrRate() ) : 1;
|
|
375
380
|
|
|
376
381
|
const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1 } );
|
|
377
382
|
const billingTypeMap = {};
|
|
@@ -432,7 +437,9 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
432
437
|
units = s.trafficCameraCount;
|
|
433
438
|
}
|
|
434
439
|
}
|
|
435
|
-
|
|
440
|
+
// Convert the INR negotiatePrice into the invoice currency (annexFx = 1 for
|
|
441
|
+
// INR invoices, = USD→INR rate for dollar invoices, so divide).
|
|
442
|
+
const price = ( Number( s.standard?.negotiatePrice ) || 0 ) / annexFx;
|
|
436
443
|
const runningCost = s.workingdays >= monthDays ?
|
|
437
444
|
Math.round( price * units * 100 ) / 100 :
|
|
438
445
|
Math.round( ( price / monthDays ) * s.workingdays * units * 100 ) / 100;
|
|
@@ -526,6 +533,10 @@ export async function invoiceDownload( req, res ) {
|
|
|
526
533
|
billingCurrency: virtualAccount?.currency,
|
|
527
534
|
virtualaccountNumber: virtualAccount ? virtualAccount?.accountNumber : '',
|
|
528
535
|
virtualifsc: virtualAccount ? virtualAccount?.ifsc : '',
|
|
536
|
+
// GST applies only to domestic (INR) invoices. Gate the tax block on the
|
|
537
|
+
// invoice's OWN currency — not the payment-account currency, which is
|
|
538
|
+
// null/non-inr for many INR invoices and was dropping the GST rows.
|
|
539
|
+
gstApplicable: invoiceInfo.currency === 'inr',
|
|
529
540
|
};
|
|
530
541
|
|
|
531
542
|
if ( invoiceData?.tax?.length ) {
|
|
@@ -590,9 +601,20 @@ export async function invoiceDownload( req, res ) {
|
|
|
590
601
|
// Load configured CSM + Finance heads PLUS the per-client CSMs from
|
|
591
602
|
// userAssignedStore as CC recipients on the invoice mail.
|
|
592
603
|
const ccEmails = await getInvoiceCcEmails( invoiceInfo.clientId );
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
604
|
+
// De-duplicate recipients so nobody gets the invoice 2-3 times:
|
|
605
|
+
// unique TO list, and drop from CC anyone already in TO (overlap
|
|
606
|
+
// between generateInvoiceTo, invoice heads and assigned CSMs was the
|
|
607
|
+
// cause of duplicate mails).
|
|
608
|
+
const toEmails = [ ...new Set(
|
|
609
|
+
( getgroup.generateInvoiceTo || [] ).map( ( e ) => String( e || '' ).trim() ).filter( Boolean ),
|
|
610
|
+
) ];
|
|
611
|
+
const toSet = new Set( toEmails.map( ( e ) => e.toLowerCase() ) );
|
|
612
|
+
const dedupedCc = [ ...new Set(
|
|
613
|
+
( ccEmails || [] ).map( ( e ) => String( e || '' ).trim() ).filter( Boolean ),
|
|
614
|
+
) ].filter( ( e ) => !toSet.has( e.toLowerCase() ) );
|
|
615
|
+
console.log( fromEmail, toEmails, dedupedCc, attachments );
|
|
616
|
+
|
|
617
|
+
const result = await sendEmailWithSES( toEmails, mailSubject, mailbody, attachments, fromEmail, dedupedCc.length ? dedupedCc : undefined );
|
|
596
618
|
console.log( result );
|
|
597
619
|
let logObj = {
|
|
598
620
|
userName: req.user?.userName,
|
|
@@ -707,6 +729,8 @@ async function buildInvoicePdfBuffer( invoiceId ) {
|
|
|
707
729
|
billingCurrency: virtualAccount?.currency,
|
|
708
730
|
virtualaccountNumber: virtualAccount ? virtualAccount?.accountNumber : '',
|
|
709
731
|
virtualifsc: virtualAccount ? virtualAccount?.ifsc : '',
|
|
732
|
+
// GST applies only to domestic (INR) invoices; gate on the invoice currency.
|
|
733
|
+
gstApplicable: invoiceInfo.currency === 'inr',
|
|
710
734
|
};
|
|
711
735
|
|
|
712
736
|
if ( invoiceData?.tax?.length ) {
|
|
@@ -1290,8 +1314,12 @@ async function standardPrice( group, getClient, baseDate ) {
|
|
|
1290
1314
|
return product;
|
|
1291
1315
|
} );
|
|
1292
1316
|
|
|
1293
|
-
// Combine overallStore and eachStore products
|
|
1294
|
-
|
|
1317
|
+
// Combine overallStore and eachStore products. Sort by product name so the
|
|
1318
|
+
// persisted order is deterministic — MongoDB $group output order isn't
|
|
1319
|
+
// guaranteed, which made products "interchange" between the Plans view, the
|
|
1320
|
+
// stored invoice and regenerated invoices / PDF.
|
|
1321
|
+
return [ ...products, ...eachStoreProducts ]
|
|
1322
|
+
.sort( ( a, b ) => String( a.productName || '' ).localeCompare( String( b.productName || '' ) ) );
|
|
1295
1323
|
}
|
|
1296
1324
|
|
|
1297
1325
|
|
|
@@ -1425,7 +1453,10 @@ async function stepPrice( group, getClient ) {
|
|
|
1425
1453
|
},
|
|
1426
1454
|
},
|
|
1427
1455
|
{
|
|
1456
|
+
// productName first so order is deterministic across views/PDF, then
|
|
1457
|
+
// workingdays so step rows stay grouped consistently.
|
|
1428
1458
|
$sort: {
|
|
1459
|
+
productName: 1,
|
|
1429
1460
|
workingdays: -1,
|
|
1430
1461
|
},
|
|
1431
1462
|
},
|
|
@@ -1854,6 +1885,24 @@ export async function clientInvoiceList( req, res ) {
|
|
|
1854
1885
|
}
|
|
1855
1886
|
|
|
1856
1887
|
if ( req.body.export ) {
|
|
1888
|
+
// Due Status — mirrors the UI cell (getDueStatus) exactly so the column
|
|
1889
|
+
// matches what reviewers see on screen. Paid / no-due-date show a dash;
|
|
1890
|
+
// otherwise it's overdue / due today / due in N days from TODAY at
|
|
1891
|
+
// day-granularity (time-of-day ignored on both ends).
|
|
1892
|
+
const today = dayjs().startOf( 'day' );
|
|
1893
|
+
const dueStatusOf = ( inv ) => {
|
|
1894
|
+
if ( inv.paymentStatus === 'paid' || !inv.dueDate ) {
|
|
1895
|
+
return '—';
|
|
1896
|
+
}
|
|
1897
|
+
const days = dayjs( inv.dueDate ).startOf( 'day' ).diff( today, 'day' );
|
|
1898
|
+
if ( days < 0 ) {
|
|
1899
|
+
return `Overdue by ${-days} day${days === -1 ? '' : 's'}`;
|
|
1900
|
+
}
|
|
1901
|
+
if ( days === 0 ) {
|
|
1902
|
+
return 'Due today';
|
|
1903
|
+
}
|
|
1904
|
+
return `Due in ${days} day${days === 1 ? '' : 's'}`;
|
|
1905
|
+
};
|
|
1857
1906
|
const exportdata = [];
|
|
1858
1907
|
count.forEach( ( element ) => {
|
|
1859
1908
|
exportdata.push( {
|
|
@@ -1862,9 +1911,13 @@ export async function clientInvoiceList( req, res ) {
|
|
|
1862
1911
|
'Invoice #': element.invoice,
|
|
1863
1912
|
'Billing date': dayjs( element.billingDate ).format( 'DD MMM, YYYY' ),
|
|
1864
1913
|
'Due Date': element.dueDate ? dayjs( element.dueDate ).format( 'DD MMM, YYYY' ) : '',
|
|
1914
|
+
'Due Status': dueStatusOf( element ),
|
|
1865
1915
|
'Group Name': element.groupName,
|
|
1866
1916
|
'Amount Excl. GST': element.amount,
|
|
1867
|
-
|
|
1917
|
+
// GST only applies to domestic (INR) invoices. International invoices
|
|
1918
|
+
// (dollar / euro / etc.) are billed without GST — show a dash to
|
|
1919
|
+
// match the on-screen column rather than a misleading 0.
|
|
1920
|
+
'GST Amount': element.currency === 'inr' ? element.gstAmount : '—',
|
|
1868
1921
|
'Amount Incl. GST': element.totalAmount,
|
|
1869
1922
|
'Stores': element.stores,
|
|
1870
1923
|
'Payment Status': element.paymentStatus,
|
|
@@ -2468,7 +2521,10 @@ async function transitionInvoiceStatus( req, res, fromStatus, toStatus ) {
|
|
|
2468
2521
|
return res.sendError( 'Invoice not found', 404 );
|
|
2469
2522
|
}
|
|
2470
2523
|
|
|
2471
|
-
|
|
2524
|
+
// Legacy 'pending' invoices are equivalent to the first CSM stage, so the
|
|
2525
|
+
// CSM transition accepts either 'pendingCsm' or 'pending' as the source.
|
|
2526
|
+
const acceptedFrom = fromStatus === 'pendingCsm' ? [ 'pendingCsm', 'pending' ] : [ fromStatus ];
|
|
2527
|
+
if ( !acceptedFrom.includes( invoice.status ) ) {
|
|
2472
2528
|
return res.sendError(
|
|
2473
2529
|
`Invoice is currently at status '${invoice.status}', not '${fromStatus}'. Another user may have advanced it.`,
|
|
2474
2530
|
409,
|