tango-app-api-payment-subscription 3.5.6 → 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 +261 -15
- package/src/controllers/estimate.controller.js +63 -0
- package/src/controllers/invoice.controller.js +98 -34
- package/src/controllers/paymentReminder.controller.js +81 -0
- 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 +10 -0
- package/src/routes/brandsBilling.routes.js +1 -0
- package/src/routes/invoice.routes.js +2 -1
- package/src/services/paymentReminder.service.js +13 -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,19 +214,106 @@ export async function brandsBillingList( req, res ) {
|
|
|
164
214
|
|
|
165
215
|
let allData = await clientService.aggregate( query );
|
|
166
216
|
|
|
167
|
-
|
|
168
|
-
|
|
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;
|
|
169
226
|
}
|
|
170
227
|
|
|
228
|
+
// Lifecycle + payment counts over the FULL client population (no status /
|
|
229
|
+
// paymentStatus filter), so the overview cards stay stable regardless of
|
|
230
|
+
// which lifecycle tab is selected — and so Hold / Suspended / Deactive
|
|
231
|
+
// show even when the default Active tab has zero rows. trialPaid is the
|
|
232
|
+
// derived "paid plan with at least one product still on trial" bucket.
|
|
233
|
+
// Bucket every client into ONE mutually-exclusive payment bucket so the
|
|
234
|
+
// pills (Paid / Trial / Paid-Trial / Free) sum to their lifecycle tab
|
|
235
|
+
// total. trialPaid = paid plan that still has a product on trial; such a
|
|
236
|
+
// client is trialPaid, NOT also paid.
|
|
237
|
+
const payBucketExpr = {
|
|
238
|
+
$let: {
|
|
239
|
+
vars: {
|
|
240
|
+
ps: '$planDetails.paymentStatus',
|
|
241
|
+
hasTrialProduct: { $gt: [ { $size: { $filter: {
|
|
242
|
+
input: { $ifNull: [ '$planDetails.product', [] ] },
|
|
243
|
+
as: 'p',
|
|
244
|
+
cond: { $eq: [ '$$p.status', 'trial' ] },
|
|
245
|
+
} } }, 0 ] },
|
|
246
|
+
},
|
|
247
|
+
in: {
|
|
248
|
+
$switch: {
|
|
249
|
+
branches: [
|
|
250
|
+
{ case: { $eq: [ '$$ps', 'trial' ] }, then: 'trial' },
|
|
251
|
+
{ case: { $eq: [ '$$ps', 'free' ] }, then: 'free' },
|
|
252
|
+
{ case: { $and: [ { $eq: [ '$$ps', 'paid' ] }, '$$hasTrialProduct' ] }, then: 'trialPaid' },
|
|
253
|
+
{ case: { $eq: [ '$$ps', 'paid' ] }, then: 'paid' },
|
|
254
|
+
{ case: { $eq: [ '$$ps', 'unbilled' ] }, then: 'unbilled' },
|
|
255
|
+
],
|
|
256
|
+
default: 'other',
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
const matrixAgg = await clientService.aggregate( [
|
|
262
|
+
{ $project: { status: 1, payBucket: payBucketExpr } },
|
|
263
|
+
{ $group: { _id: { status: '$status', pay: '$payBucket' }, count: { $sum: 1 } } },
|
|
264
|
+
] );
|
|
265
|
+
|
|
266
|
+
// Lifecycle totals + a status×payment matrix. Counts are over the FULL
|
|
267
|
+
// population (no filter) so the overview cards and pills stay stable
|
|
268
|
+
// regardless of the selected tab, and show even when the Active tab is
|
|
269
|
+
// empty.
|
|
270
|
+
const lifecycle = { active: 0, hold: 0, suspended: 0, deactive: 0 };
|
|
271
|
+
const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0, unbilled: 0 };
|
|
272
|
+
const paymentByStatus = {};
|
|
273
|
+
let totalBrands = 0;
|
|
274
|
+
matrixAgg.forEach( ( row ) => {
|
|
275
|
+
const st = row._id.status || 'active';
|
|
276
|
+
const pay = row._id.pay;
|
|
277
|
+
const n = row.count || 0;
|
|
278
|
+
totalBrands += n;
|
|
279
|
+
if ( lifecycle[st] != null ) {
|
|
280
|
+
lifecycle[st] += n;
|
|
281
|
+
}
|
|
282
|
+
if ( payTotals[pay] != null ) {
|
|
283
|
+
payTotals[pay] += n;
|
|
284
|
+
}
|
|
285
|
+
if ( !paymentByStatus[st] ) {
|
|
286
|
+
paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0, unbilled: 0 };
|
|
287
|
+
}
|
|
288
|
+
if ( paymentByStatus[st][pay] != null ) {
|
|
289
|
+
paymentByStatus[st][pay] += n;
|
|
290
|
+
}
|
|
291
|
+
} );
|
|
292
|
+
|
|
171
293
|
let summary = {
|
|
172
|
-
totalBrands
|
|
173
|
-
active:
|
|
174
|
-
|
|
175
|
-
|
|
294
|
+
totalBrands,
|
|
295
|
+
active: lifecycle.active,
|
|
296
|
+
hold: lifecycle.hold,
|
|
297
|
+
suspended: lifecycle.suspended,
|
|
298
|
+
deactive: lifecycle.deactive,
|
|
299
|
+
trial: payTotals.trial,
|
|
300
|
+
paid: payTotals.paid,
|
|
301
|
+
free: payTotals.free,
|
|
302
|
+
trialPaid: payTotals.trialPaid,
|
|
303
|
+
unbilled: payTotals.unbilled,
|
|
304
|
+
paymentByStatus,
|
|
305
|
+
// Money/store totals stay tied to the filtered view so they match the
|
|
306
|
+
// rows on screen.
|
|
176
307
|
totalBillDue: allData.reduce( ( sum, c ) => sum + ( c.billAmountDue || 0 ), 0 ),
|
|
177
308
|
storesUnderBilling: allData.reduce( ( sum, c ) => sum + ( c.billingStores || 0 ), 0 ),
|
|
178
309
|
};
|
|
179
310
|
|
|
311
|
+
if ( allData.length == 0 ) {
|
|
312
|
+
// Still return the population summary so the overview cards populate even
|
|
313
|
+
// when the current lifecycle tab is empty.
|
|
314
|
+
return res.sendSuccess( { summary, count: 0, data: [] } );
|
|
315
|
+
}
|
|
316
|
+
|
|
180
317
|
if ( req.body.export ) {
|
|
181
318
|
const exportdata = [];
|
|
182
319
|
allData.forEach( ( element ) => {
|
|
@@ -526,6 +663,36 @@ export async function latestDailyPricing( req, res ) {
|
|
|
526
663
|
storeList = stores.slice( skip, skip + Number( req.body.limit ) );
|
|
527
664
|
}
|
|
528
665
|
|
|
666
|
+
// Monthly Billing Summary — one row per month of the brand's invoice
|
|
667
|
+
// history (stores billed + invoice amount), newest first. The UI tags the
|
|
668
|
+
// current/last-generated rows and computes month-over-month deltas.
|
|
669
|
+
// billingDate is a Date on most rows but a string on some legacy ones, so
|
|
670
|
+
// coerce before extracting year/month.
|
|
671
|
+
const monthlyBillingSummary = await invoiceService.aggregate( [
|
|
672
|
+
{ $match: { clientId: req.body.clientId } },
|
|
673
|
+
{ $addFields: { billingDateD: { $cond: [
|
|
674
|
+
{ $eq: [ { $type: '$billingDate' }, 'date' ] },
|
|
675
|
+
'$billingDate',
|
|
676
|
+
{ $toDate: '$billingDate' },
|
|
677
|
+
] } } },
|
|
678
|
+
{ $match: { billingDateD: { $ne: null } } },
|
|
679
|
+
{ $group: {
|
|
680
|
+
_id: { year: { $year: '$billingDateD' }, month: { $month: '$billingDateD' } },
|
|
681
|
+
storesBilled: { $sum: { $ifNull: [ '$stores', 0 ] } },
|
|
682
|
+
invoiceAmount: { $sum: { $ifNull: [ '$totalAmount', 0 ] } },
|
|
683
|
+
currency: { $last: { $ifNull: [ '$currency', 'inr' ] } },
|
|
684
|
+
} },
|
|
685
|
+
{ $sort: { '_id.year': -1, '_id.month': -1 } },
|
|
686
|
+
{ $project: {
|
|
687
|
+
_id: 0,
|
|
688
|
+
year: '$_id.year',
|
|
689
|
+
month: '$_id.month',
|
|
690
|
+
storesBilled: 1,
|
|
691
|
+
invoiceAmount: { $round: [ '$invoiceAmount', 2 ] },
|
|
692
|
+
currency: 1,
|
|
693
|
+
} },
|
|
694
|
+
] );
|
|
695
|
+
|
|
529
696
|
let data = {
|
|
530
697
|
clientId: record.clientId,
|
|
531
698
|
brandName: record.brandName,
|
|
@@ -537,6 +704,7 @@ export async function latestDailyPricing( req, res ) {
|
|
|
537
704
|
proRate: record.proRate,
|
|
538
705
|
count,
|
|
539
706
|
data: storeList,
|
|
707
|
+
monthlyBillingSummary,
|
|
540
708
|
};
|
|
541
709
|
|
|
542
710
|
res.sendSuccess( data );
|
|
@@ -1305,7 +1473,7 @@ export async function bulkUpdateBillingGroups( req, res ) {
|
|
|
1305
1473
|
// with an env override (USD_INR_RATE) and a last-known/static fallback so
|
|
1306
1474
|
// the summary never fails because a rate API is down.
|
|
1307
1475
|
let usdRateCache = { rate: null, at: 0 };
|
|
1308
|
-
async function getUsdInrRate() {
|
|
1476
|
+
export async function getUsdInrRate() {
|
|
1309
1477
|
const override = Number( process.env.USD_INR_RATE );
|
|
1310
1478
|
if ( override > 0 ) {
|
|
1311
1479
|
return override;
|
|
@@ -1371,6 +1539,12 @@ export async function billingSummary( req, res ) {
|
|
|
1371
1539
|
installationInr: { $sum: { $cond: [ '$isDollar', 0, '$installation' ] } },
|
|
1372
1540
|
installationUsd: { $sum: { $cond: [ '$isDollar', '$installation', 0 ] } },
|
|
1373
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 ] } },
|
|
1374
1548
|
} },
|
|
1375
1549
|
] );
|
|
1376
1550
|
|
|
@@ -1451,11 +1625,15 @@ export async function billingSummary( req, res ) {
|
|
|
1451
1625
|
// to (status 'live') — trials are excluded.
|
|
1452
1626
|
liveProductSet: new Set( products.filter( ( p ) => p.status === 'live' )
|
|
1453
1627
|
.map( ( p ) => String( p.productName || '' ).toLowerCase() ) ),
|
|
1628
|
+
// Fallback only — overridden below from actual invoice currencies
|
|
1629
|
+
// when the client has invoices.
|
|
1454
1630
|
currency: c?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr',
|
|
1455
1631
|
csm: [ ...( csmByClient.get( key ) || [] ) ].join( ', ' ),
|
|
1456
1632
|
revenueMonths: {},
|
|
1457
1633
|
billedStoresMonths: {},
|
|
1458
1634
|
installationFee: 0,
|
|
1635
|
+
invDollar: 0,
|
|
1636
|
+
invInr: 0,
|
|
1459
1637
|
} );
|
|
1460
1638
|
}
|
|
1461
1639
|
return rows.get( key );
|
|
@@ -1466,11 +1644,37 @@ export async function billingSummary( req, res ) {
|
|
|
1466
1644
|
r.revenueMonths[inv._id.ym] = Math.round( ( ( inv.revenueInr || 0 ) + ( inv.revenueUsd || 0 ) * usdRate ) * 100 ) / 100;
|
|
1467
1645
|
r.billedStoresMonths[inv._id.ym] = inv.stores || 0;
|
|
1468
1646
|
r.installationFee += ( inv.installationInr || 0 ) + ( inv.installationUsd || 0 ) * usdRate;
|
|
1647
|
+
r.invDollar += ( inv.dollarInvoices || 0 );
|
|
1648
|
+
r.invInr += ( inv.inrInvoices || 0 );
|
|
1469
1649
|
if ( inv.companyName ) {
|
|
1470
1650
|
r.registeredEntity = inv.companyName;
|
|
1471
1651
|
}
|
|
1472
1652
|
}
|
|
1473
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
|
+
|
|
1474
1678
|
const curKey = months[4].key;
|
|
1475
1679
|
const prevKey = months[3].key;
|
|
1476
1680
|
const data = [ ...rows.values() ].map( ( r ) => {
|
|
@@ -1491,7 +1695,12 @@ export async function billingSummary( req, res ) {
|
|
|
1491
1695
|
}
|
|
1492
1696
|
let pricePerStore = null;
|
|
1493
1697
|
const pdoc = pricingByClient.get( r.clientId );
|
|
1494
|
-
|
|
1698
|
+
// Price/Store counts only subscribed products — and never the one-time
|
|
1699
|
+
// installationFee line, which isn't a per-store recurring price.
|
|
1700
|
+
const isLive = ( name ) => {
|
|
1701
|
+
const n = String( name || '' ).toLowerCase();
|
|
1702
|
+
return n !== 'installationfee' && r.liveProductSet.has( n );
|
|
1703
|
+
};
|
|
1495
1704
|
if ( pdoc?.standard?.length ) {
|
|
1496
1705
|
const liveRows = pdoc.standard.filter( ( p ) => isLive( p.productName ) );
|
|
1497
1706
|
pricePerStore = liveRows.length ?
|
|
@@ -1573,7 +1782,44 @@ export async function billingSummary( req, res ) {
|
|
|
1573
1782
|
};
|
|
1574
1783
|
} ).sort( ( a, b ) => ( b.revCur || 0 ) - ( a.revCur || 0 ) );
|
|
1575
1784
|
|
|
1576
|
-
|
|
1785
|
+
// Server-side filters (GET query or POST body). CSM / Product / Variance /
|
|
1786
|
+
// search narrow the per-client rows after they're computed, since those
|
|
1787
|
+
// fields are derived during the merge above.
|
|
1788
|
+
const f = { ...( req.query || {} ), ...( req.body || {} ) };
|
|
1789
|
+
const csm = f.csm && f.csm !== 'All' ? String( f.csm ) : '';
|
|
1790
|
+
const product = f.product && f.product !== 'All' ? String( f.product ) : '';
|
|
1791
|
+
const variance = f.variance && f.variance !== 'All' ? String( f.variance ) : '';
|
|
1792
|
+
const search = f.search ? String( f.search ).toLowerCase().trim() : '';
|
|
1793
|
+
|
|
1794
|
+
let filtered = data;
|
|
1795
|
+
if ( csm ) {
|
|
1796
|
+
filtered = filtered.filter( ( r ) => ( r.csm || '' ).split( ', ' ).includes( csm ) );
|
|
1797
|
+
}
|
|
1798
|
+
if ( product ) {
|
|
1799
|
+
filtered = filtered.filter( ( r ) => ( r.products || [] ).includes( product ) );
|
|
1800
|
+
}
|
|
1801
|
+
if ( variance === 'growth' ) {
|
|
1802
|
+
filtered = filtered.filter( ( r ) => r.variance > 0 );
|
|
1803
|
+
} else if ( variance === 'decline' ) {
|
|
1804
|
+
filtered = filtered.filter( ( r ) => r.variance < 0 );
|
|
1805
|
+
} else if ( variance === 'flat' ) {
|
|
1806
|
+
filtered = filtered.filter( ( r ) => r.variance === 0 );
|
|
1807
|
+
}
|
|
1808
|
+
if ( search ) {
|
|
1809
|
+
filtered = filtered.filter( ( r ) =>
|
|
1810
|
+
( r.clientName || '' ).toLowerCase().includes( search ) ||
|
|
1811
|
+
( r.registeredEntity || '' ).toLowerCase().includes( search ) ||
|
|
1812
|
+
String( r.clientId || '' ).includes( search ),
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// Distinct option lists for the filter popover are derived from the FULL
|
|
1817
|
+
// result set (not the filtered slice) so the dropdowns don't shrink as
|
|
1818
|
+
// filters are applied.
|
|
1819
|
+
const csmOptions = [ ...new Set( data.flatMap( ( r ) => String( r.csm || '' ).split( ', ' ).filter( Boolean ) ) ) ].sort();
|
|
1820
|
+
const productOptions = [ ...new Set( data.flatMap( ( r ) => r.products || [] ) ) ].sort();
|
|
1821
|
+
|
|
1822
|
+
return res.sendSuccess( { months, data: filtered, total: data.length, csmOptions, productOptions, usdRate } );
|
|
1577
1823
|
} catch ( error ) {
|
|
1578
1824
|
logger.error( { error: error, function: 'billingSummary' } );
|
|
1579
1825
|
return res.sendError( error, 500 );
|
|
@@ -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 } );
|