tango-app-api-payment-subscription 3.5.7 → 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 +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 +136 -13
- package/src/controllers/estimate.controller.js +203 -67
- package/src/controllers/invoice.controller.js +93 -13
- package/src/controllers/paymentReminderTrigger.controller.js +194 -0
- package/src/hbs/estimateEmail.hbs +78 -0
- package/src/hbs/estimatePdf.hbs +1632 -118
- package/src/hbs/invoicePdf.hbs +1711 -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 +5 -2
- package/src/services/estimate.service.js +4 -0
- 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.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.
|
|
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",
|
|
@@ -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,8 +170,24 @@ 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 ] },
|
|
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
|
+
},
|
|
127
191
|
},
|
|
128
192
|
},
|
|
129
193
|
{
|
|
@@ -135,7 +199,9 @@ export async function brandsBillingList( req, res ) {
|
|
|
135
199
|
totalStores: 1,
|
|
136
200
|
billingStores: 1,
|
|
137
201
|
productsAdded: 1,
|
|
138
|
-
|
|
202
|
+
billingConfigured: 1,
|
|
203
|
+
dueInr: 1,
|
|
204
|
+
dueUsd: 1,
|
|
139
205
|
status: 1,
|
|
140
206
|
paymentStatus: '$planDetails.paymentStatus',
|
|
141
207
|
nextBillingDate: 1,
|
|
@@ -164,6 +230,17 @@ export async function brandsBillingList( req, res ) {
|
|
|
164
230
|
|
|
165
231
|
let allData = await clientService.aggregate( query );
|
|
166
232
|
|
|
233
|
+
// Bill Amount Due in INR: dollar invoice totals converted at today's rate
|
|
234
|
+
// and added to the INR totals, so the column and the ₹-labelled Total Bill
|
|
235
|
+
// Due chip are a single, additive currency. (Previously USD was shown as ₹
|
|
236
|
+
// and the grand total summed INR+USD raw.)
|
|
237
|
+
const billDueRate = await getUsdInrRate();
|
|
238
|
+
for ( const c of allData ) {
|
|
239
|
+
c.billAmountDue = Math.round( ( ( c.dueInr || 0 ) + ( c.dueUsd || 0 ) * billDueRate ) * 100 ) / 100;
|
|
240
|
+
delete c.dueInr;
|
|
241
|
+
delete c.dueUsd;
|
|
242
|
+
}
|
|
243
|
+
|
|
167
244
|
// Lifecycle + payment counts over the FULL client population (no status /
|
|
168
245
|
// paymentStatus filter), so the overview cards stay stable regardless of
|
|
169
246
|
// which lifecycle tab is selected — and so Hold / Suspended / Deactive
|
|
@@ -190,6 +267,7 @@ export async function brandsBillingList( req, res ) {
|
|
|
190
267
|
{ case: { $eq: [ '$$ps', 'free' ] }, then: 'free' },
|
|
191
268
|
{ case: { $and: [ { $eq: [ '$$ps', 'paid' ] }, '$$hasTrialProduct' ] }, then: 'trialPaid' },
|
|
192
269
|
{ case: { $eq: [ '$$ps', 'paid' ] }, then: 'paid' },
|
|
270
|
+
{ case: { $eq: [ '$$ps', 'unbilled' ] }, then: 'unbilled' },
|
|
193
271
|
],
|
|
194
272
|
default: 'other',
|
|
195
273
|
},
|
|
@@ -206,7 +284,7 @@ export async function brandsBillingList( req, res ) {
|
|
|
206
284
|
// regardless of the selected tab, and show even when the Active tab is
|
|
207
285
|
// empty.
|
|
208
286
|
const lifecycle = { active: 0, hold: 0, suspended: 0, deactive: 0 };
|
|
209
|
-
const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
|
|
287
|
+
const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0, unbilled: 0 };
|
|
210
288
|
const paymentByStatus = {};
|
|
211
289
|
let totalBrands = 0;
|
|
212
290
|
matrixAgg.forEach( ( row ) => {
|
|
@@ -221,7 +299,7 @@ export async function brandsBillingList( req, res ) {
|
|
|
221
299
|
payTotals[pay] += n;
|
|
222
300
|
}
|
|
223
301
|
if ( !paymentByStatus[st] ) {
|
|
224
|
-
paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
|
|
302
|
+
paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0, unbilled: 0 };
|
|
225
303
|
}
|
|
226
304
|
if ( paymentByStatus[st][pay] != null ) {
|
|
227
305
|
paymentByStatus[st][pay] += n;
|
|
@@ -238,6 +316,7 @@ export async function brandsBillingList( req, res ) {
|
|
|
238
316
|
paid: payTotals.paid,
|
|
239
317
|
free: payTotals.free,
|
|
240
318
|
trialPaid: payTotals.trialPaid,
|
|
319
|
+
unbilled: payTotals.unbilled,
|
|
241
320
|
paymentByStatus,
|
|
242
321
|
// Money/store totals stay tied to the filtered view so they match the
|
|
243
322
|
// rows on screen.
|
|
@@ -1476,6 +1555,12 @@ export async function billingSummary( req, res ) {
|
|
|
1476
1555
|
installationInr: { $sum: { $cond: [ '$isDollar', 0, '$installation' ] } },
|
|
1477
1556
|
installationUsd: { $sum: { $cond: [ '$isDollar', '$installation', 0 ] } },
|
|
1478
1557
|
companyName: { $last: '$companyName' },
|
|
1558
|
+
// Track the actual invoice currencies so the row currency reflects how
|
|
1559
|
+
// the client is really billed — not the (sometimes stale)
|
|
1560
|
+
// paymentInvoice.currencyType. e.g. Sundora is flagged 'dollar' but
|
|
1561
|
+
// every invoice is INR.
|
|
1562
|
+
dollarInvoices: { $sum: { $cond: [ '$isDollar', 1, 0 ] } },
|
|
1563
|
+
inrInvoices: { $sum: { $cond: [ '$isDollar', 0, 1 ] } },
|
|
1479
1564
|
} },
|
|
1480
1565
|
] );
|
|
1481
1566
|
|
|
@@ -1492,14 +1577,22 @@ export async function billingSummary( req, res ) {
|
|
|
1492
1577
|
// email's local part since the collection carries no display name.
|
|
1493
1578
|
const usdRate = await getUsdInrRate();
|
|
1494
1579
|
|
|
1495
|
-
// Current month's store count comes from dailyPricing
|
|
1496
|
-
//
|
|
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.
|
|
1497
1585
|
const curMonthStart = new Date( now.startOf( 'month' ).toISOString() );
|
|
1498
1586
|
const latestDp = await dailyPriceService.aggregate( [
|
|
1499
1587
|
{ $match: { dateISO: { $gte: curMonthStart } } },
|
|
1500
|
-
{ $
|
|
1501
|
-
{ $
|
|
1502
|
-
{ $group: {
|
|
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 } } },
|
|
1503
1596
|
] );
|
|
1504
1597
|
const curStoresByClient = new Map( latestDp.map( ( d ) => [ String( d._id ), d.stores || 0 ] ) );
|
|
1505
1598
|
|
|
@@ -1556,11 +1649,15 @@ export async function billingSummary( req, res ) {
|
|
|
1556
1649
|
// to (status 'live') — trials are excluded.
|
|
1557
1650
|
liveProductSet: new Set( products.filter( ( p ) => p.status === 'live' )
|
|
1558
1651
|
.map( ( p ) => String( p.productName || '' ).toLowerCase() ) ),
|
|
1652
|
+
// Fallback only — overridden below from actual invoice currencies
|
|
1653
|
+
// when the client has invoices.
|
|
1559
1654
|
currency: c?.paymentInvoice?.currencyType === 'dollar' ? 'dollar' : 'inr',
|
|
1560
1655
|
csm: [ ...( csmByClient.get( key ) || [] ) ].join( ', ' ),
|
|
1561
1656
|
revenueMonths: {},
|
|
1562
1657
|
billedStoresMonths: {},
|
|
1563
1658
|
installationFee: 0,
|
|
1659
|
+
invDollar: 0,
|
|
1660
|
+
invInr: 0,
|
|
1564
1661
|
} );
|
|
1565
1662
|
}
|
|
1566
1663
|
return rows.get( key );
|
|
@@ -1571,11 +1668,37 @@ export async function billingSummary( req, res ) {
|
|
|
1571
1668
|
r.revenueMonths[inv._id.ym] = Math.round( ( ( inv.revenueInr || 0 ) + ( inv.revenueUsd || 0 ) * usdRate ) * 100 ) / 100;
|
|
1572
1669
|
r.billedStoresMonths[inv._id.ym] = inv.stores || 0;
|
|
1573
1670
|
r.installationFee += ( inv.installationInr || 0 ) + ( inv.installationUsd || 0 ) * usdRate;
|
|
1671
|
+
r.invDollar += ( inv.dollarInvoices || 0 );
|
|
1672
|
+
r.invInr += ( inv.inrInvoices || 0 );
|
|
1574
1673
|
if ( inv.companyName ) {
|
|
1575
1674
|
r.registeredEntity = inv.companyName;
|
|
1576
1675
|
}
|
|
1577
1676
|
}
|
|
1578
1677
|
|
|
1678
|
+
// Resolve each row's currency from its actual invoices: 'dollar' only when
|
|
1679
|
+
// the client has dollar invoices and NO inr invoices; otherwise 'inr'. The
|
|
1680
|
+
// paymentInvoice.currencyType fallback set above stands only for clients
|
|
1681
|
+
// with no invoices in the window.
|
|
1682
|
+
for ( const r of rows.values() ) {
|
|
1683
|
+
if ( r.invDollar > 0 || r.invInr > 0 ) {
|
|
1684
|
+
r.currency = ( r.invDollar > 0 && r.invInr === 0 ) ? 'dollar' : 'inr';
|
|
1685
|
+
}
|
|
1686
|
+
delete r.invDollar;
|
|
1687
|
+
delete r.invInr;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// A client may be actively billing yet have no invoice in the 5-month
|
|
1691
|
+
// window (e.g. invoice for the running month not generated yet). Seed a
|
|
1692
|
+
// row for any client that has a current-month store reading in
|
|
1693
|
+
// dailyPricing, so the list reflects every brand under billing — not just
|
|
1694
|
+
// the ones with a recent invoice. (Without this, brands silently vanish
|
|
1695
|
+
// from the summary until their next invoice lands.)
|
|
1696
|
+
for ( const [ clientId ] of curStoresByClient ) {
|
|
1697
|
+
if ( clientById.has( clientId ) ) {
|
|
1698
|
+
rowOf( clientId );
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1579
1702
|
const curKey = months[4].key;
|
|
1580
1703
|
const prevKey = months[3].key;
|
|
1581
1704
|
const data = [ ...rows.values() ].map( ( r ) => {
|