tango-app-api-payment-subscription 3.5.9 → 3.5.11
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 +8 -3
- package/src/controllers/bankTransaction.controller.js +100 -51
- package/src/controllers/billing.controllers.js +23 -0
- package/src/controllers/brandsBilling.controller.js +188 -16
- package/src/controllers/invoice.controller.js +110 -3
- package/src/dtos/validation.dtos.js +9 -0
- package/src/routes/brandsBilling.routes.js +2 -2
- package/src/routes/invoice.routes.js +6 -6
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.11",
|
|
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.29",
|
|
33
33
|
"tango-app-api-middleware": "^3.6.18",
|
|
34
34
|
"winston": "^3.12.0",
|
|
35
35
|
"winston-daily-rotate-file": "^5.0.0",
|
|
@@ -19,7 +19,12 @@ dotenv.config();
|
|
|
19
19
|
|
|
20
20
|
const args = process.argv.slice( 2 );
|
|
21
21
|
const dryRun = args.includes( '--dry-run' );
|
|
22
|
-
|
|
22
|
+
|
|
23
|
+
// Default reminder recipients seeded onto every active client. Any email(s)
|
|
24
|
+
// passed as args are added on top, de-duped.
|
|
25
|
+
const DEFAULT_EMAILS = [ 'sathish@tangotech.co.in', 'ayyanarkalusulingam13@gmail.com' ];
|
|
26
|
+
const argEmails = args.filter( ( a ) => a.includes( '@' ) );
|
|
27
|
+
const REMINDER_EMAILS = [ ...new Set( [ ...DEFAULT_EMAILS, ...argEmails ] ) ];
|
|
23
28
|
|
|
24
29
|
// Mirrors the app's DEFAULTS() in paymentReminder.controller.js.
|
|
25
30
|
const DEFAULT_TEMPLATES = {
|
|
@@ -34,7 +39,7 @@ async function run() {
|
|
|
34
39
|
// Reuse the app's own connection builder (reads mongo_* env config).
|
|
35
40
|
const { uri, options } = getConnection();
|
|
36
41
|
await mongoose.connect( uri, options );
|
|
37
|
-
console.log( `Connected.
|
|
42
|
+
console.log( `Connected. Recipients: ${REMINDER_EMAILS.join( ', ' )}${dryRun ? ' (DRY RUN — no writes)' : ''}\n` );
|
|
38
43
|
|
|
39
44
|
const clients = await model.clientModel.find(
|
|
40
45
|
{ status: 'active' },
|
|
@@ -60,7 +65,7 @@ async function run() {
|
|
|
60
65
|
{
|
|
61
66
|
$set: {
|
|
62
67
|
clientId: c.clientId,
|
|
63
|
-
reminderEmails:
|
|
68
|
+
reminderEmails: REMINDER_EMAILS,
|
|
64
69
|
templates: DEFAULT_TEMPLATES,
|
|
65
70
|
updatedBy: 'seed-script',
|
|
66
71
|
},
|
|
@@ -467,69 +467,118 @@ export async function resolveBankTransaction( req, res ) {
|
|
|
467
467
|
}
|
|
468
468
|
|
|
469
469
|
// ---- reconcile ----
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
470
|
+
// Accept either a single invoiceId (legacy) or a list of invoiceIds. One
|
|
471
|
+
// payment can settle multiple invoices: the amount auto-fills them in the
|
|
472
|
+
// order given, each settled fully until the money runs out.
|
|
473
|
+
const invoiceIds = Array.isArray( req.body?.invoiceIds ) && req.body.invoiceIds.length ?
|
|
474
|
+
req.body.invoiceIds.map( String ) :
|
|
475
|
+
( req.body?.invoiceId ? [ String( req.body.invoiceId ) ] : [] );
|
|
476
|
+
if ( !invoiceIds.length ) {
|
|
477
|
+
return res.sendError( 'invoiceIds is required to reconcile', 400 );
|
|
477
478
|
}
|
|
478
479
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
480
|
+
// EDIT case: this transaction was ALREADY reconciled, so its amount was
|
|
481
|
+
// previously applied to one or more invoices. Reverse each prior allocation
|
|
482
|
+
// first so re-reconciling never double-counts. Prefer the recorded
|
|
483
|
+
// per-invoice allocations; fall back to the single invoiceId for older rows.
|
|
484
|
+
if ( txn.status === 'reconciled' ) {
|
|
485
|
+
const priorAllocations = ( Array.isArray( txn.appliedInvoices ) && txn.appliedInvoices.length ) ?
|
|
486
|
+
txn.appliedInvoices :
|
|
487
|
+
( txn.invoiceId ? [ { invoiceId: txn.invoiceId, amount: txn.amount } ] : [] );
|
|
488
|
+
for ( const alloc of priorAllocations ) {
|
|
489
|
+
const prevInvoice = await invoiceService.findOne( { _id: alloc.invoiceId } );
|
|
490
|
+
if ( !prevInvoice ) {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const revertedPaid = round2( Math.max( 0, ( Number( prevInvoice.paidAmount ) || 0 ) - ( Number( alloc.amount ) || 0 ) ) );
|
|
494
|
+
const prevTotal = Number( prevInvoice.totalAmount ) || 0;
|
|
495
|
+
const revertedStatus = revertedPaid <= AMOUNT_TOLERANCE ? 'unpaid' :
|
|
496
|
+
( round2( prevTotal - revertedPaid ) > AMOUNT_TOLERANCE ? 'partial' : 'paid' );
|
|
497
|
+
await invoiceService.invoiceUpdateOne(
|
|
498
|
+
{ _id: prevInvoice._id },
|
|
499
|
+
{
|
|
500
|
+
$set: { paymentStatus: revertedStatus, paidAmount: revertedPaid },
|
|
501
|
+
$pull: { paymentHistory: { reference: txn.refNo || undefined, method: 'bank-statement' } },
|
|
502
|
+
},
|
|
503
|
+
);
|
|
504
|
+
}
|
|
495
505
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
506
|
+
|
|
507
|
+
// Apply the payment across the selected invoices in order (auto-fill).
|
|
508
|
+
let remaining = round2( txn.amount );
|
|
509
|
+
const appliedInvoices = [];
|
|
510
|
+
const settledLabels = [];
|
|
511
|
+
let firstInvoice = null;
|
|
512
|
+
for ( const id of invoiceIds ) {
|
|
513
|
+
const invoice = await invoiceService.findOne( { _id: id } );
|
|
514
|
+
if ( !invoice ) {
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if ( !firstInvoice ) {
|
|
518
|
+
firstInvoice = invoice;
|
|
519
|
+
}
|
|
520
|
+
const prevPaid = Number( invoice.paidAmount ) || 0;
|
|
521
|
+
const totalAmount = Number( invoice.totalAmount ) || 0;
|
|
522
|
+
const outstanding = round2( Math.max( 0, totalAmount - prevPaid ) );
|
|
523
|
+
const applied = round2( Math.max( 0, Math.min( remaining, outstanding ) ) );
|
|
524
|
+
if ( applied <= 0 && outstanding > 0 ) {
|
|
525
|
+
// No money left for this invoice — record it as selected but unfunded.
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
remaining = round2( remaining - applied );
|
|
529
|
+
const newPaid = round2( prevPaid + applied );
|
|
530
|
+
const fullySettled = round2( totalAmount - newPaid ) <= AMOUNT_TOLERANCE;
|
|
531
|
+
await invoiceService.invoiceUpdateOne(
|
|
532
|
+
{ _id: invoice._id },
|
|
533
|
+
{
|
|
534
|
+
$set: {
|
|
535
|
+
paymentStatus: fullySettled ? 'paid' : 'partial',
|
|
536
|
+
paidAmount: newPaid,
|
|
537
|
+
...( fullySettled ? { paidDate: new Date() } : {} ),
|
|
538
|
+
},
|
|
539
|
+
$push: {
|
|
540
|
+
paymentHistory: {
|
|
541
|
+
amount: applied,
|
|
542
|
+
date: txn.valueDate || new Date(),
|
|
543
|
+
method: 'bank-statement',
|
|
544
|
+
reference: txn.refNo || undefined,
|
|
545
|
+
notes: `Manually reconciled from "${txn.fileName || 'statement'}"`,
|
|
546
|
+
recordedBy: req.user?.email || req.user?.userName || 'bank-reconciliation',
|
|
547
|
+
recordedAt: new Date(),
|
|
548
|
+
},
|
|
515
549
|
},
|
|
516
550
|
},
|
|
517
|
-
|
|
518
|
-
|
|
551
|
+
);
|
|
552
|
+
appliedInvoices.push( { invoiceId: String( invoice._id ), invoice: invoice.invoice, amount: applied } );
|
|
553
|
+
settledLabels.push( `${invoice.invoice} (${fullySettled ? 'Paid' : 'Partial'})` );
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if ( !appliedInvoices.length ) {
|
|
557
|
+
return res.sendError( 'None of the selected invoices could be reconciled.', 400 );
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const pending = round2( remaining );
|
|
561
|
+
let note = appliedInvoices.length > 1 ?
|
|
562
|
+
`Reconciled across ${appliedInvoices.length} invoices: ${settledLabels.join( ', ' )}` :
|
|
563
|
+
`Manually reconciled to ${settledLabels[0]}`;
|
|
564
|
+
if ( pending > AMOUNT_TOLERANCE ) {
|
|
565
|
+
note += ` — ₹${pending} excess to follow up`;
|
|
566
|
+
}
|
|
519
567
|
|
|
520
|
-
// Brand name for the table's Brand Name column.
|
|
521
|
-
const client = await clientService.findOne( { clientId:
|
|
568
|
+
// Brand name for the table's Brand Name column (from the first invoice).
|
|
569
|
+
const client = await clientService.findOne( { clientId: firstInvoice.clientId }, { clientName: 1 } );
|
|
522
570
|
await bankTransactionService.updateOne( { _id: txnId }, { $set: {
|
|
523
571
|
status: 'reconciled',
|
|
524
572
|
contacted: false,
|
|
525
|
-
invoice: invoice.
|
|
526
|
-
invoiceId:
|
|
527
|
-
|
|
528
|
-
|
|
573
|
+
invoice: appliedInvoices.map( ( a ) => a.invoice ).join( ', ' ),
|
|
574
|
+
invoiceId: appliedInvoices[0].invoiceId,
|
|
575
|
+
appliedInvoices,
|
|
576
|
+
identifiedClientId: String( firstInvoice.clientId ?? '' ),
|
|
577
|
+
identifiedClientName: client?.clientName || firstInvoice.companyName || '',
|
|
529
578
|
resultNote: note,
|
|
530
579
|
} } );
|
|
531
580
|
|
|
532
|
-
return res.sendSuccess( { status: 'reconciled', note,
|
|
581
|
+
return res.sendSuccess( { status: 'reconciled', note, invoices: appliedInvoices.map( ( a ) => a.invoice ), pending } );
|
|
533
582
|
} catch ( error ) {
|
|
534
583
|
logger.error( { error: error, function: 'resolveBankTransaction' } );
|
|
535
584
|
return res.sendError( error, 500 );
|
|
@@ -106,6 +106,29 @@ export const subscribedStoreList = async ( req, res ) => {
|
|
|
106
106
|
|
|
107
107
|
);
|
|
108
108
|
|
|
109
|
+
// Selected stores for the group being edited. The backend resolves these
|
|
110
|
+
// itself from the group's `stores` (by groupId) so the FE doesn't have to
|
|
111
|
+
// send/compute them. It can also accept an explicit selectedStoreIds list.
|
|
112
|
+
// Each returned store gets an `isSelected` flag (drives the checkbox), and
|
|
113
|
+
// selected stores are floated to the top across ALL pages (before
|
|
114
|
+
// pagination) so they appear first.
|
|
115
|
+
// The live form selection (selectedStoreIds) is authoritative — it matches
|
|
116
|
+
// exactly what's checked in the UI (including unsaved edits). Only fall back
|
|
117
|
+
// to the saved group's stores when the FE didn't send a selection.
|
|
118
|
+
let selectedStoreIds = Array.isArray( req.body.selectedStoreIds ) ? req.body.selectedStoreIds : [];
|
|
119
|
+
if ( !selectedStoreIds.length && req.body.groupId ) {
|
|
120
|
+
const group = await findOne( { _id: req.body.groupId }, { stores: 1 } );
|
|
121
|
+
if ( group && Array.isArray( group.stores ) ) {
|
|
122
|
+
selectedStoreIds = group.stores;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Always tag isSelected (false when nothing is selected) + sort selected
|
|
126
|
+
// first; storeId secondary keeps the order stable within each partition.
|
|
127
|
+
pipeline.push(
|
|
128
|
+
{ $addFields: { isSelected: { $in: [ '$storeId', selectedStoreIds ] } } },
|
|
129
|
+
{ $sort: { isSelected: -1, storeId: 1 } },
|
|
130
|
+
);
|
|
131
|
+
|
|
109
132
|
const facetStage = {
|
|
110
133
|
$facet: {
|
|
111
134
|
data: [
|
|
@@ -30,11 +30,36 @@ export async function brandsBillingList( req, res ) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
if ( req.body.paymentStatus && req.body.paymentStatus.length > 0 ) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
33
|
+
// Filter by the SAME derived buckets the pills use (see payBucketExpr),
|
|
34
|
+
// so a selected pill returns exactly the rows it counts:
|
|
35
|
+
// trial -> paymentStatus == 'trial'
|
|
36
|
+
// free -> paymentStatus == 'free'
|
|
37
|
+
// paid -> paymentStatus in {paid,unbilled,due} AND no trial product
|
|
38
|
+
// trialPaid -> paymentStatus in {paid,unbilled,due} AND has a trial product
|
|
39
|
+
// 'trialPaid' is a derived bucket — there is no stored 'trialPaid'
|
|
40
|
+
// status — which is why a plain $in match returned no data.
|
|
41
|
+
const buckets = req.body.paymentStatus;
|
|
42
|
+
const paidLike = [ 'paid', 'unbilled', 'due' ];
|
|
43
|
+
const hasTrialProduct = { $gt: [ { $size: { $filter: {
|
|
44
|
+
input: { $ifNull: [ '$planDetails.product', [] ] },
|
|
45
|
+
as: 'p',
|
|
46
|
+
cond: { $eq: [ '$$p.status', 'trial' ] },
|
|
47
|
+
} } }, 0 ] };
|
|
48
|
+
const ors = [];
|
|
49
|
+
for ( const b of buckets ) {
|
|
50
|
+
if ( b === 'trial' || b === 'free' ) {
|
|
51
|
+
ors.push( { $eq: [ '$planDetails.paymentStatus', b ] } );
|
|
52
|
+
} else if ( b === 'paid' ) {
|
|
53
|
+
ors.push( { $and: [ { $in: [ '$planDetails.paymentStatus', paidLike ] }, { $not: hasTrialProduct } ] } );
|
|
54
|
+
} else if ( b === 'trialPaid' ) {
|
|
55
|
+
ors.push( { $and: [ { $in: [ '$planDetails.paymentStatus', paidLike ] }, hasTrialProduct ] } );
|
|
56
|
+
} else {
|
|
57
|
+
ors.push( { $eq: [ '$planDetails.paymentStatus', b ] } );
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if ( ors.length ) {
|
|
61
|
+
query.push( { $match: { $expr: ors.length === 1 ? ors[0] : { $or: ors } } } );
|
|
62
|
+
}
|
|
38
63
|
}
|
|
39
64
|
|
|
40
65
|
query.push(
|
|
@@ -203,7 +228,21 @@ export async function brandsBillingList( req, res ) {
|
|
|
203
228
|
dueInr: 1,
|
|
204
229
|
dueUsd: 1,
|
|
205
230
|
status: 1,
|
|
206
|
-
|
|
231
|
+
// Display mapping for the Payment Status column: an 'unbilled' or
|
|
232
|
+
// 'due' plan is shown as 'paid'; any other status is shown as-is.
|
|
233
|
+
paymentStatus: {
|
|
234
|
+
$cond: {
|
|
235
|
+
if: { $eq: [ '$planDetails.paymentStatus', 'unbilled' ] },
|
|
236
|
+
then: 'paid',
|
|
237
|
+
else: {
|
|
238
|
+
$cond: {
|
|
239
|
+
if: { $eq: [ '$planDetails.paymentStatus', 'due' ] },
|
|
240
|
+
then: 'paid',
|
|
241
|
+
else: '$planDetails.paymentStatus',
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
},
|
|
207
246
|
nextBillingDate: 1,
|
|
208
247
|
currencyType: '$paymentInvoice.currencyType',
|
|
209
248
|
},
|
|
@@ -265,9 +304,11 @@ export async function brandsBillingList( req, res ) {
|
|
|
265
304
|
branches: [
|
|
266
305
|
{ case: { $eq: [ '$$ps', 'trial' ] }, then: 'trial' },
|
|
267
306
|
{ case: { $eq: [ '$$ps', 'free' ] }, then: 'free' },
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
307
|
+
// 'unbilled' and 'due' are treated as PAID (same mapping as the
|
|
308
|
+
// Payment Status column). A paid/unbilled/due plan that still has
|
|
309
|
+
// a product on trial is bucketed as trialPaid.
|
|
310
|
+
{ case: { $and: [ { $in: [ '$$ps', [ 'paid', 'unbilled', 'due' ] ] }, '$$hasTrialProduct' ] }, then: 'trialPaid' },
|
|
311
|
+
{ case: { $in: [ '$$ps', [ 'paid', 'unbilled', 'due' ] ] }, then: 'paid' },
|
|
271
312
|
],
|
|
272
313
|
default: 'other',
|
|
273
314
|
},
|
|
@@ -284,7 +325,7 @@ export async function brandsBillingList( req, res ) {
|
|
|
284
325
|
// regardless of the selected tab, and show even when the Active tab is
|
|
285
326
|
// empty.
|
|
286
327
|
const lifecycle = { active: 0, hold: 0, suspended: 0, deactive: 0 };
|
|
287
|
-
const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0
|
|
328
|
+
const payTotals = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
|
|
288
329
|
const paymentByStatus = {};
|
|
289
330
|
let totalBrands = 0;
|
|
290
331
|
matrixAgg.forEach( ( row ) => {
|
|
@@ -299,7 +340,7 @@ export async function brandsBillingList( req, res ) {
|
|
|
299
340
|
payTotals[pay] += n;
|
|
300
341
|
}
|
|
301
342
|
if ( !paymentByStatus[st] ) {
|
|
302
|
-
paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0
|
|
343
|
+
paymentByStatus[st] = { trial: 0, paid: 0, free: 0, trialPaid: 0 };
|
|
303
344
|
}
|
|
304
345
|
if ( paymentByStatus[st][pay] != null ) {
|
|
305
346
|
paymentByStatus[st][pay] += n;
|
|
@@ -316,7 +357,6 @@ export async function brandsBillingList( req, res ) {
|
|
|
316
357
|
paid: payTotals.paid,
|
|
317
358
|
free: payTotals.free,
|
|
318
359
|
trialPaid: payTotals.trialPaid,
|
|
319
|
-
unbilled: payTotals.unbilled,
|
|
320
360
|
paymentByStatus,
|
|
321
361
|
// Money/store totals stay tied to the filtered view so they match the
|
|
322
362
|
// rows on screen.
|
|
@@ -386,6 +426,13 @@ export async function brandInvoiceList( req, res ) {
|
|
|
386
426
|
},
|
|
387
427
|
} ];
|
|
388
428
|
|
|
429
|
+
// Client users only ever see APPROVED invoices — the approval pipeline
|
|
430
|
+
// (pendingCsm/pendingFinance/pendingApproval/pending) is internal to tango
|
|
431
|
+
// and must not be exposed to clients, even in the raw list response.
|
|
432
|
+
if ( req.user?.userType === 'client' ) {
|
|
433
|
+
query.push( { $match: { status: 'approved' } } );
|
|
434
|
+
}
|
|
435
|
+
|
|
389
436
|
// If the user picked an explicit Month or Year, ignore durationFilter so the
|
|
390
437
|
// two ranges don't conflict (e.g. "current month" + April would always 0).
|
|
391
438
|
const hasMonthYear = ( req.body.monthFilter && String( req.body.monthFilter ) !== '' ) || ( req.body.yearFilter && String( req.body.yearFilter ) !== '' );
|
|
@@ -610,8 +657,14 @@ export async function latestDailyPricing( req, res ) {
|
|
|
610
657
|
let stores = record.stores || [];
|
|
611
658
|
let count = stores.length;
|
|
612
659
|
|
|
613
|
-
|
|
614
|
-
|
|
660
|
+
// statusFilter may be an array (multi-select) or a legacy string. An empty
|
|
661
|
+
// value / empty array means "all statuses".
|
|
662
|
+
const statusFilterList = Array.isArray( req.body.statusFilter ) ?
|
|
663
|
+
req.body.statusFilter.filter( Boolean ) :
|
|
664
|
+
( req.body.statusFilter ? [ req.body.statusFilter ] : [] );
|
|
665
|
+
if ( statusFilterList.length ) {
|
|
666
|
+
const allowed = new Set( statusFilterList );
|
|
667
|
+
stores = stores.filter( ( s ) => allowed.has( s.status ) );
|
|
615
668
|
}
|
|
616
669
|
|
|
617
670
|
if ( req.body.searchValue && req.body.searchValue !== '' ) {
|
|
@@ -631,6 +684,76 @@ export async function latestDailyPricing( req, res ) {
|
|
|
631
684
|
} );
|
|
632
685
|
}
|
|
633
686
|
|
|
687
|
+
// Per-product price (basepricing) + month length, used to prorate the
|
|
688
|
+
// invoice amount: (price / daysInMonth) * workingDays. Computed once here so
|
|
689
|
+
// BOTH the export and the on-screen table use the same numbers.
|
|
690
|
+
const priceByProduct = {};
|
|
691
|
+
let bpCurrency = 'inr';
|
|
692
|
+
try {
|
|
693
|
+
const bp = await basePriceService.findOne(
|
|
694
|
+
{ clientId: req.body.clientId },
|
|
695
|
+
{ standard: 1, currency: 1 },
|
|
696
|
+
);
|
|
697
|
+
bpCurrency = bp?.currency || 'inr';
|
|
698
|
+
for ( const p of ( bp?.standard || [] ) ) {
|
|
699
|
+
const price = Number( p.negotiatePrice ) || Number( p.basePrice ) || 0;
|
|
700
|
+
if ( p.productName ) {
|
|
701
|
+
priceByProduct[p.productName] = price;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
} catch ( bpErr ) {
|
|
705
|
+
logger.error( { error: bpErr, function: 'latestDailyPricing.basePrice', clientId: req.body.clientId } );
|
|
706
|
+
}
|
|
707
|
+
// Billing type per product (perStore / perZone / perCamera) from the client
|
|
708
|
+
// plan — drives whether the amount multiplies by camera/zone count.
|
|
709
|
+
const billingTypeByProduct = {};
|
|
710
|
+
try {
|
|
711
|
+
const planClient = await clientService.findOne(
|
|
712
|
+
{ clientId: req.body.clientId },
|
|
713
|
+
{ 'planDetails.product.productName': 1, 'planDetails.product.billingType': 1 },
|
|
714
|
+
);
|
|
715
|
+
for ( const p of ( planClient?.planDetails?.product || [] ) ) {
|
|
716
|
+
if ( p.productName ) {
|
|
717
|
+
billingTypeByProduct[p.productName] = p.billingType || 'perStore';
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
} catch ( btErr ) {
|
|
721
|
+
logger.error( { error: btErr, function: 'latestDailyPricing.billingType', clientId: req.body.clientId } );
|
|
722
|
+
}
|
|
723
|
+
const daysInMonth = dayjs( record.dateISO ).daysInMonth();
|
|
724
|
+
const prettyProduct = ( name ) => String( name || '' )
|
|
725
|
+
.replace( /^tango/i, '' )
|
|
726
|
+
.replace( /([a-z])([A-Z])/g, '$1 $2' )
|
|
727
|
+
.replace( /^./, ( ch ) => ch.toUpperCase() );
|
|
728
|
+
// Units to bill: perZone/perCamera multiply by the store's zone/camera
|
|
729
|
+
// count (same rule as invoice generation + the annexure); else per-store=1.
|
|
730
|
+
const productUnits = ( store, product ) => {
|
|
731
|
+
const billingType = billingTypeByProduct[product.productName] || 'perStore';
|
|
732
|
+
if ( product.productName === 'tangoZone' ) {
|
|
733
|
+
if ( billingType === 'perZone' && ( store.zoneCount || 0 ) > 0 ) {
|
|
734
|
+
return store.zoneCount;
|
|
735
|
+
}
|
|
736
|
+
if ( billingType === 'perCamera' && ( store.zoneCameraCount || 0 ) > 0 ) {
|
|
737
|
+
return store.zoneCameraCount;
|
|
738
|
+
}
|
|
739
|
+
} else if ( product.productName === 'tangoTraffic' ) {
|
|
740
|
+
if ( billingType === 'perCamera' && ( store.trafficCameraCount || 0 ) > 0 ) {
|
|
741
|
+
return store.trafficCameraCount;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return 1;
|
|
745
|
+
};
|
|
746
|
+
const productInvoiceAmount = ( store, product ) => {
|
|
747
|
+
const price = priceByProduct[product.productName] || 0;
|
|
748
|
+
const workingDays = Number( product.workingdays ) || 0;
|
|
749
|
+
if ( price <= 0 || workingDays <= 0 ) {
|
|
750
|
+
return 0;
|
|
751
|
+
}
|
|
752
|
+
const units = productUnits( store, product );
|
|
753
|
+
const days = Math.min( workingDays, daysInMonth );
|
|
754
|
+
return Math.round( ( ( price / daysInMonth ) * days * units ) * 100 ) / 100;
|
|
755
|
+
};
|
|
756
|
+
|
|
634
757
|
if ( req.body.export ) {
|
|
635
758
|
const exportdata = [];
|
|
636
759
|
stores.forEach( ( store ) => {
|
|
@@ -651,6 +774,7 @@ export async function latestDailyPricing( req, res ) {
|
|
|
651
774
|
'Zone Camera Count': isZone ? ( store.zoneCameraCount || 0 ) : 0,
|
|
652
775
|
'Zone Count': isZone ? ( store.zoneCount || 0 ) : 0,
|
|
653
776
|
'Working Days': product.workingdays || 0,
|
|
777
|
+
'Invoice Amount': productInvoiceAmount( store, product ),
|
|
654
778
|
'Status': store.status || '',
|
|
655
779
|
} );
|
|
656
780
|
} );
|
|
@@ -665,6 +789,7 @@ export async function latestDailyPricing( req, res ) {
|
|
|
665
789
|
'Zone Camera Count': 0,
|
|
666
790
|
'Zone Count': 0,
|
|
667
791
|
'Working Days': 0,
|
|
792
|
+
'Invoice Amount': 0,
|
|
668
793
|
'Status': store.status || '',
|
|
669
794
|
} );
|
|
670
795
|
}
|
|
@@ -679,6 +804,34 @@ export async function latestDailyPricing( req, res ) {
|
|
|
679
804
|
storeList = stores.slice( skip, skip + Number( req.body.limit ) );
|
|
680
805
|
}
|
|
681
806
|
|
|
807
|
+
// Invoice Amount per store: prorate each product by the days it ran this
|
|
808
|
+
// month — (price / daysInMonth) * workingDays — summed across the store's
|
|
809
|
+
// products, plus a per-product breakdown for the UI. Uses the shared
|
|
810
|
+
// priceByProduct / daysInMonth / productInvoiceAmount computed above.
|
|
811
|
+
storeList = storeList.map( ( store ) => {
|
|
812
|
+
let invoiceAmount = 0;
|
|
813
|
+
const invoiceBreakdown = [];
|
|
814
|
+
for ( const product of ( store.products || [] ) ) {
|
|
815
|
+
const amount = productInvoiceAmount( store, product );
|
|
816
|
+
invoiceAmount += amount;
|
|
817
|
+
// One breakdown row per product on the store (so the UI can show
|
|
818
|
+
// Traffic: ₹x, Zone: ₹y …).
|
|
819
|
+
invoiceBreakdown.push( {
|
|
820
|
+
productName: product.productName,
|
|
821
|
+
label: prettyProduct( product.productName ),
|
|
822
|
+
price: priceByProduct[product.productName] || 0,
|
|
823
|
+
workingDays: Number( product.workingdays ) || 0,
|
|
824
|
+
amount,
|
|
825
|
+
} );
|
|
826
|
+
}
|
|
827
|
+
return {
|
|
828
|
+
...( store._doc || store ),
|
|
829
|
+
invoiceAmount: Math.round( invoiceAmount * 100 ) / 100,
|
|
830
|
+
invoiceBreakdown,
|
|
831
|
+
invoiceCurrency: bpCurrency,
|
|
832
|
+
};
|
|
833
|
+
} );
|
|
834
|
+
|
|
682
835
|
// Monthly Billing Summary — one row per month of the brand's invoice
|
|
683
836
|
// history (stores billed + invoice amount), newest first. The UI tags the
|
|
684
837
|
// current/last-generated rows and computes month-over-month deltas.
|
|
@@ -1573,6 +1726,18 @@ export async function billingSummary( req, res ) {
|
|
|
1573
1726
|
} );
|
|
1574
1727
|
const clientById = new Map( clients.map( ( c ) => [ String( c.clientId ), c ] ) );
|
|
1575
1728
|
|
|
1729
|
+
// Registered Entity comes from the billings collection (each billing group's
|
|
1730
|
+
// registeredCompanyName). A client can have multiple groups/names, so
|
|
1731
|
+
// collect the distinct list per client — the UI shows the first and reveals
|
|
1732
|
+
// the rest on hover.
|
|
1733
|
+
const billingNameAgg = await billingService.aggregatebilling( [
|
|
1734
|
+
{ $match: { registeredCompanyName: { $exists: true, $nin: [ '', null ] } } },
|
|
1735
|
+
{ $group: { _id: '$clientId', names: { $addToSet: '$registeredCompanyName' } } },
|
|
1736
|
+
] );
|
|
1737
|
+
const regNamesByClient = new Map(
|
|
1738
|
+
billingNameAgg.map( ( b ) => [ String( b._id ), ( b.names || [] ).filter( Boolean ) ] ),
|
|
1739
|
+
);
|
|
1740
|
+
|
|
1576
1741
|
// Per-client CSM (userAssignedStore, tangoUserType 'csm'); display the
|
|
1577
1742
|
// email's local part since the collection carries no display name.
|
|
1578
1743
|
const usdRate = await getUsdInrRate();
|
|
@@ -1786,10 +1951,17 @@ export async function billingSummary( req, res ) {
|
|
|
1786
1951
|
revCur = revCur == null ? null : Math.round( revCur );
|
|
1787
1952
|
const revPrevOut = revPrev == null ? null : Math.round( revPrev );
|
|
1788
1953
|
const installationOut = r.installationFee ? Math.round( r.installationFee ) : null;
|
|
1954
|
+
// Registered Entity from the billings collection (distinct group names).
|
|
1955
|
+
// First name shown in the column; the full list is sent so the UI can
|
|
1956
|
+
// reveal the others on hover. Fall back to the invoice company name when
|
|
1957
|
+
// a client has no billing-group registered name.
|
|
1958
|
+
const regNames = regNamesByClient.get( r.clientId ) || [];
|
|
1959
|
+
const registeredEntity = regNames[0] || r.registeredEntity || '';
|
|
1789
1960
|
return {
|
|
1790
1961
|
clientId: r.clientId,
|
|
1791
|
-
clientName: r.clientName ||
|
|
1792
|
-
registeredEntity
|
|
1962
|
+
clientName: r.clientName || registeredEntity || r.clientId,
|
|
1963
|
+
registeredEntity,
|
|
1964
|
+
registeredEntities: regNames.length ? regNames : ( r.registeredEntity ? [ r.registeredEntity ] : [] ),
|
|
1793
1965
|
status: r.status,
|
|
1794
1966
|
products: r.products,
|
|
1795
1967
|
csm: r.csm,
|
|
@@ -17,6 +17,7 @@ import { symbolFor } from '../utils/currency.js';
|
|
|
17
17
|
import { invoiceStatusEnum } from '../dtos/validation.dtos.js';
|
|
18
18
|
import { findOneApplicationDefault } from '../services/applicationDefault.service.js';
|
|
19
19
|
import * as assignedStoreService from '../services/assignedStore.service.js';
|
|
20
|
+
import * as bankTransactionService from '../services/bankTransaction.service.js';
|
|
20
21
|
import { getUsdInrRate } from './brandsBilling.controller.js';
|
|
21
22
|
|
|
22
23
|
// Pulls CSM + Finance head emails (stored under applicationDefault
|
|
@@ -136,6 +137,35 @@ export async function createInvoice( req, res ) {
|
|
|
136
137
|
invoiceNo = invoiceNo.toString().padStart( 5, '0' );
|
|
137
138
|
}
|
|
138
139
|
const baseDate = req.body.billingDate ? dayjs( req.body.billingDate ) : dayjs();
|
|
140
|
+
let customProducts = Array.isArray( req.body.products ) ? req.body.products : [];
|
|
141
|
+
|
|
142
|
+
// Advance billing: monthly = 1, half-yearly = 6, yearly = 12. For a
|
|
143
|
+
// multi-month advance invoice, each month becomes its own line item —
|
|
144
|
+
// every posted product is repeated once per month, labelled with that
|
|
145
|
+
// month, so one invoice bills the whole period upfront (same logic as
|
|
146
|
+
// the auto-generated advance invoices).
|
|
147
|
+
const customAdvanceMonths = req.body.advanceInvoice ?
|
|
148
|
+
( { quarterly: 3, halfyearly: 6, yearly: 12 }[req.body.advancePeriod] || 1 ) : 1;
|
|
149
|
+
if ( customAdvanceMonths > 1 ) {
|
|
150
|
+
const expanded = [];
|
|
151
|
+
for ( let m = 0; m < customAdvanceMonths; m++ ) {
|
|
152
|
+
const monthLabel = baseDate.add( m, 'month' ).format( 'MMM YYYY' );
|
|
153
|
+
for ( const p of customProducts ) {
|
|
154
|
+
expanded.push( { ...p, month: monthLabel } );
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
customProducts = expanded;
|
|
158
|
+
}
|
|
159
|
+
// Recompute totals from the (possibly expanded) line items so a
|
|
160
|
+
// multi-month advance invoice bills the full period.
|
|
161
|
+
const customAmount = customAdvanceMonths > 1 ?
|
|
162
|
+
Math.round( customProducts.reduce( ( s, p ) => s + ( Number( p.amount ) || 0 ), 0 ) ) :
|
|
163
|
+
Math.round( Number( req.body.amount ) || 0 );
|
|
164
|
+
const customTotal = customAdvanceMonths > 1 ?
|
|
165
|
+
Math.round( customProducts.reduce( ( s, p ) => s + ( Number( p.amount ) || 0 ), 0 ) +
|
|
166
|
+
( Array.isArray( req.body.tax ) ? req.body.tax.reduce( ( s, t ) => s + ( Number( t.taxAmount ) || 0 ) * customAdvanceMonths, 0 ) : 0 ) ) :
|
|
167
|
+
Math.round( Number( req.body.totalAmount ) || 0 );
|
|
168
|
+
|
|
139
169
|
const data = {
|
|
140
170
|
invoice: `INV-${Finacialyear}-${invoiceNo}`,
|
|
141
171
|
invoiceIndex: invoiceNo,
|
|
@@ -147,10 +177,10 @@ export async function createInvoice( req, res ) {
|
|
|
147
177
|
companyAddress: req.body.companyAddress || '',
|
|
148
178
|
GSTNumber: req.body.GSTNumber || '',
|
|
149
179
|
PlaceOfSupply: req.body.PlaceOfSupply || '',
|
|
150
|
-
products:
|
|
180
|
+
products: customProducts,
|
|
151
181
|
tax: Array.isArray( req.body.tax ) ? req.body.tax : [],
|
|
152
|
-
amount:
|
|
153
|
-
totalAmount:
|
|
182
|
+
amount: customAmount,
|
|
183
|
+
totalAmount: customTotal,
|
|
154
184
|
stores: Number( req.body.stores ) || 0,
|
|
155
185
|
currency: req.body.currency || 'inr',
|
|
156
186
|
billingDate: baseDate.toDate(),
|
|
@@ -159,6 +189,9 @@ export async function createInvoice( req, res ) {
|
|
|
159
189
|
paymentMethod: 'Online',
|
|
160
190
|
status: 'pendingCsm',
|
|
161
191
|
paymentStatus: 'unpaid',
|
|
192
|
+
advanceInvoice: req.body.advanceInvoice || false,
|
|
193
|
+
advancePeriod: req.body.advanceInvoice ? ( req.body.advancePeriod || 'monthly' ) : undefined,
|
|
194
|
+
advanceMonths: customAdvanceMonths,
|
|
162
195
|
};
|
|
163
196
|
const created = await invoiceService.create( data );
|
|
164
197
|
const logObj = {
|
|
@@ -207,6 +240,32 @@ export async function createInvoice( req, res ) {
|
|
|
207
240
|
} else {
|
|
208
241
|
products = await stepPrice( group, getClient );
|
|
209
242
|
}
|
|
243
|
+
|
|
244
|
+
// Billing horizon in months. Advance and normal cycle are independent —
|
|
245
|
+
// only one drives the span per generation:
|
|
246
|
+
// advanceInvoice ON -> advancePeriod (advance future billing)
|
|
247
|
+
// advanceInvoice OFF -> paymentCycle (normal billing cycle)
|
|
248
|
+
// For a multi-month span, each month becomes its own line item: every
|
|
249
|
+
// product is repeated once per month, labelled with that month, at the
|
|
250
|
+
// normal monthly amount. One invoice then bills the whole period.
|
|
251
|
+
// (advancePeriod uses 'halfyearly'; paymentCycle uses 'quarter'/'halfYearly'
|
|
252
|
+
// — map both spellings.)
|
|
253
|
+
const advancePeriodMonths = { quarterly: 3, halfyearly: 6, yearly: 12 };
|
|
254
|
+
const paymentCycleMonths = { quarter: 3, quarterly: 3, halfYearly: 6, halfyearly: 6, yearly: 12 };
|
|
255
|
+
const advanceMonths = group.advanceInvoice ?
|
|
256
|
+
( advancePeriodMonths[group.advancePeriod] || 1 ) :
|
|
257
|
+
( paymentCycleMonths[group.paymentCycle] || 1 );
|
|
258
|
+
if ( advanceMonths > 1 ) {
|
|
259
|
+
const expanded = [];
|
|
260
|
+
for ( let m = 0; m < advanceMonths; m++ ) {
|
|
261
|
+
const monthLabel = baseDate.add( m, 'month' ).format( 'MMM YYYY' );
|
|
262
|
+
for ( const p of products ) {
|
|
263
|
+
expanded.push( { ...p, month: monthLabel } );
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
products = expanded;
|
|
267
|
+
}
|
|
268
|
+
|
|
210
269
|
let amount = products.reduce( ( sum, product ) => sum + product.amount, 0 );
|
|
211
270
|
let taxList = [];
|
|
212
271
|
let totalAmount = 0;
|
|
@@ -305,6 +364,8 @@ export async function createInvoice( req, res ) {
|
|
|
305
364
|
monthOfbilling: baseDate.format( 'MM' ),
|
|
306
365
|
dueDate: dueDate,
|
|
307
366
|
advanceInvoice: group.advanceInvoice || false,
|
|
367
|
+
advancePeriod: group.advanceInvoice ? ( group.advancePeriod || 'monthly' ) : undefined,
|
|
368
|
+
advanceMonths: advanceMonths,
|
|
308
369
|
};
|
|
309
370
|
|
|
310
371
|
if ( req.body.invoiceId ) {
|
|
@@ -2211,6 +2272,18 @@ export async function migrateInvoice( req, res ) {
|
|
|
2211
2272
|
export async function PaymentStatusChange( req, res ) {
|
|
2212
2273
|
try {
|
|
2213
2274
|
let invoice = await invoiceService.findOne( { invoice: req.body.invoiceId } );
|
|
2275
|
+
if ( !invoice ) {
|
|
2276
|
+
return res.sendError( 'Invoice not found', 404 );
|
|
2277
|
+
}
|
|
2278
|
+
// Payment can only be recorded on an APPROVED invoice — block while it's
|
|
2279
|
+
// still in the approval pipeline.
|
|
2280
|
+
if ( invoice.status !== 'approved' ) {
|
|
2281
|
+
return res.sendError( 'Payment status can be changed only after the invoice is approved.', 409 );
|
|
2282
|
+
}
|
|
2283
|
+
// A paid invoice is locked — its payment status cannot be changed.
|
|
2284
|
+
if ( invoice.paymentStatus === 'paid' ) {
|
|
2285
|
+
return res.sendError( 'This invoice is already paid; its payment status cannot be changed.', 409 );
|
|
2286
|
+
}
|
|
2214
2287
|
let updateInvoice = await invoiceService.updateOne( { invoice: req.body.invoiceId }, { paymentStatus: req.body.status } );
|
|
2215
2288
|
let logObj = {
|
|
2216
2289
|
userName: req.user?.userName,
|
|
@@ -2255,6 +2328,14 @@ export async function recordPayment( req, res ) {
|
|
|
2255
2328
|
if ( !invoice ) {
|
|
2256
2329
|
return res.sendError( 'Invoice not found', 404 );
|
|
2257
2330
|
}
|
|
2331
|
+
// Payment can only be recorded on an APPROVED invoice.
|
|
2332
|
+
if ( invoice.status !== 'approved' ) {
|
|
2333
|
+
return res.sendError( 'Payment can be recorded only after the invoice is approved.', 409 );
|
|
2334
|
+
}
|
|
2335
|
+
// A paid invoice is locked — no further payment / status change.
|
|
2336
|
+
if ( invoice.paymentStatus === 'paid' ) {
|
|
2337
|
+
return res.sendError( 'This invoice is already paid; its payment status cannot be changed.', 409 );
|
|
2338
|
+
}
|
|
2258
2339
|
|
|
2259
2340
|
const previousPaid = Number( invoice.paidAmount ) || 0;
|
|
2260
2341
|
const newPaid = Math.round( ( previousPaid + amountNum ) * 100 ) / 100;
|
|
@@ -2305,6 +2386,32 @@ export async function recordPayment( req, res ) {
|
|
|
2305
2386
|
const result = await invoiceService.invoiceUpdateOne( { invoice: invoiceId }, update );
|
|
2306
2387
|
logger.info?.( { function: 'recordPayment', invoiceId, matched: result?.matchedCount, modified: result?.modifiedCount } );
|
|
2307
2388
|
|
|
2389
|
+
// Mirror the manual payment into the Transactions section as a reconciled
|
|
2390
|
+
// row with source 'manual', so manually-recorded payments are visible
|
|
2391
|
+
// alongside bank/VA/gateway transactions.
|
|
2392
|
+
try {
|
|
2393
|
+
const clientForName = await clientService.findOne( { clientId: invoice.clientId }, { clientName: 1 } );
|
|
2394
|
+
await bankTransactionService.insertMany( [ {
|
|
2395
|
+
valueDate: historyEntry.date,
|
|
2396
|
+
narration: notes || `Manual payment recorded for ${invoiceId}`,
|
|
2397
|
+
refNo: reference || '',
|
|
2398
|
+
amount: amountNum,
|
|
2399
|
+
payer: clientForName?.clientName || invoice.companyName || '',
|
|
2400
|
+
source: 'manual',
|
|
2401
|
+
status: 'reconciled',
|
|
2402
|
+
identifiedClientId: String( invoice.clientId ?? '' ),
|
|
2403
|
+
identifiedClientName: clientForName?.clientName || invoice.companyName || '',
|
|
2404
|
+
invoice: invoice.invoice,
|
|
2405
|
+
invoiceId: String( invoice._id ),
|
|
2406
|
+
appliedInvoices: [ { invoiceId: String( invoice._id ), invoice: invoice.invoice, amount: amountNum } ],
|
|
2407
|
+
resultNote: `Manually recorded - ${derivedStatus === 'paid' ? 'invoice marked Paid' : 'partial payment'}`,
|
|
2408
|
+
fileName: 'manual-entry',
|
|
2409
|
+
} ] );
|
|
2410
|
+
} catch ( txnErr ) {
|
|
2411
|
+
// Non-fatal: the payment is already recorded; the mirror row is best-effort.
|
|
2412
|
+
logger.error( { error: txnErr, function: 'recordPayment.mirrorTransaction', invoiceId } );
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2308
2415
|
try {
|
|
2309
2416
|
const logObj = {
|
|
2310
2417
|
userName: req.user?.userName,
|
|
@@ -286,6 +286,12 @@ export const subscribedStoreListBody = joi.object( {
|
|
|
286
286
|
state: joi.array().optional(),
|
|
287
287
|
city: joi.array().optional(),
|
|
288
288
|
getFilters: joi.boolean().optional(),
|
|
289
|
+
// Storeids selected in the group being edited — backend floats them to the
|
|
290
|
+
// top (selected-first ordering across pages).
|
|
291
|
+
selectedStoreIds: joi.array().items( joi.string() ).optional(),
|
|
292
|
+
// Group being edited — backend resolves its selected stores and tags each
|
|
293
|
+
// returned store with isSelected (drives checkbox + selected-first order).
|
|
294
|
+
groupId: joi.string().optional().allow( '' ),
|
|
289
295
|
} );
|
|
290
296
|
|
|
291
297
|
export const subscribedStoreListSchema = {
|
|
@@ -329,6 +335,7 @@ export const createBillingGroupBody = joi.object(
|
|
|
329
335
|
attachAnnexure: joi.boolean().optional(),
|
|
330
336
|
isPrimary: joi.boolean().optional(),
|
|
331
337
|
advanceInvoice: joi.boolean().optional(),
|
|
338
|
+
advancePeriod: joi.string().valid( 'monthly', 'quarterly', 'halfyearly', 'yearly' ).optional().allow( '' ),
|
|
332
339
|
products: joi.array().items( joi.object( {
|
|
333
340
|
productName: joi.string().required(),
|
|
334
341
|
billingMethod: joi.string().valid( 'eachStore', 'overallStore' ).required(),
|
|
@@ -368,6 +375,7 @@ export const updateBillingGroupBody = joi.object(
|
|
|
368
375
|
attachAnnexure: joi.boolean().optional(),
|
|
369
376
|
isPrimary: joi.boolean().optional(),
|
|
370
377
|
advanceInvoice: joi.boolean().optional(),
|
|
378
|
+
advancePeriod: joi.string().valid( 'monthly', 'quarterly', 'halfyearly', 'yearly' ).optional().allow( '' ),
|
|
371
379
|
products: joi.array().items( joi.object( {
|
|
372
380
|
_id: joi.any().optional(),
|
|
373
381
|
productName: joi.string().required(),
|
|
@@ -401,6 +409,7 @@ export const bulkUpdateBillingGroupRowSchema = joi.object( {
|
|
|
401
409
|
isInstallationOneTime: joi.boolean().optional(),
|
|
402
410
|
attachAnnexure: joi.boolean().optional(),
|
|
403
411
|
advanceInvoice: joi.boolean().optional(),
|
|
412
|
+
advancePeriod: joi.string().valid( 'monthly', 'quarterly', 'halfyearly', 'yearly' ).optional().allow( '' ),
|
|
404
413
|
storeId: joi.string().allow( '' ).optional(),
|
|
405
414
|
} );
|
|
406
415
|
|
|
@@ -6,12 +6,12 @@ import { isAllowedSessionHandler, accessVerification } from 'tango-app-api-middl
|
|
|
6
6
|
export const brandsBillingRouter = express.Router();
|
|
7
7
|
|
|
8
8
|
brandsBillingRouter.post( '/brandsBillingList', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), brandsBillingList );
|
|
9
|
-
brandsBillingRouter.post( '/brandInvoiceList', isAllowedSessionHandler, accessVerification( { userType: [ 'tango'
|
|
9
|
+
brandsBillingRouter.post( '/brandInvoiceList', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ], access: [ ] } ), brandInvoiceList );
|
|
10
10
|
brandsBillingRouter.post( '/latestDailyPricing', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), latestDailyPricing );
|
|
11
11
|
brandsBillingRouter.post( '/brandBillingGroups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), brandBillingGroups );
|
|
12
12
|
brandsBillingRouter.put( '/updateDailyPricingWorkingDays', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), updateDailyPricingWorkingDays );
|
|
13
13
|
brandsBillingRouter.put( '/updateDailyPricingStoreField', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), updateDailyPricingStoreField );
|
|
14
|
-
brandsBillingRouter.post( '/getClientBillingInfo', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), getClientBillingInfo );
|
|
14
|
+
brandsBillingRouter.post( '/getClientBillingInfo', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), getClientBillingInfo );
|
|
15
15
|
brandsBillingRouter.get( '/bulk-download-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), bulkDownloadBillingGroups );
|
|
16
16
|
brandsBillingRouter.post( '/bulk-update-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), bulkUpdateBillingGroups );
|
|
17
17
|
brandsBillingRouter.post( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), billingSummary );
|
|
@@ -21,11 +21,11 @@ function superadminBypass( accessConfig ) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
invoiceRouter.post( '/createInvoice', isAllowedSessionHandler, createInvoice );
|
|
25
|
-
invoiceRouter.post( '/regerateInvoice', isAllowedSessionHandler, createInvoice );
|
|
24
|
+
invoiceRouter.post( '/createInvoice', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), createInvoice );
|
|
25
|
+
invoiceRouter.post( '/regerateInvoice', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), createInvoice );
|
|
26
26
|
invoiceRouter.post( '/invoiceDownload/bulk', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), invoiceDownloadBulk );
|
|
27
27
|
invoiceRouter.post( '/invoiceDownload/:invoiceId', isAllowedSessionHandler, invoiceDownload );
|
|
28
|
-
invoiceRouter.post( '/clientInvoiceList', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), clientInvoiceList );
|
|
28
|
+
invoiceRouter.post( '/clientInvoiceList', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), clientInvoiceList );
|
|
29
29
|
invoiceRouter.post( '/creditTransactionlist', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), creditTransactionlist );
|
|
30
30
|
invoiceRouter.post( '/pendingInvoices', isAllowedSessionHandler, pendingInvoices );
|
|
31
31
|
invoiceRouter.post( '/applyDiscount', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), applyDiscount );
|
|
@@ -33,9 +33,9 @@ invoiceRouter.post( '/migrateInvoice', migrateInvoice );
|
|
|
33
33
|
invoiceRouter.post( '/PaymentStatusChange', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), PaymentStatusChange );
|
|
34
34
|
invoiceRouter.post( '/recordPayment', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), recordPayment );
|
|
35
35
|
invoiceRouter.post( '/checkPaymentStatus', checkPaymentStatus );
|
|
36
|
-
invoiceRouter.get( '/getInvoice/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango'
|
|
37
|
-
invoiceRouter.get( '/invoiceAnnexure/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), invoiceAnnexure );
|
|
38
|
-
invoiceRouter.get( '/invoiceBankDetails/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), invoiceBankDetails );
|
|
36
|
+
invoiceRouter.get( '/getInvoice/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango', 'client' ], access: [ ] } ), getInvoice );
|
|
37
|
+
invoiceRouter.get( '/invoiceAnnexure/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango', 'client' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), invoiceAnnexure );
|
|
38
|
+
invoiceRouter.get( '/invoiceBankDetails/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango', 'client' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), invoiceBankDetails );
|
|
39
39
|
invoiceRouter.put( '/updateInvoice', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), updateInvoice );
|
|
40
40
|
invoiceRouter.get( '/getClientBasePricing/:clientId', isAllowedSessionHandler, getClientBasePricing );
|
|
41
41
|
invoiceRouter.delete( '/deleteInvoice/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), deleteInvoice );
|