tango-app-api-payment-subscription 3.5.10 → 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 +1 -1
- package/scripts/seed-payment-reminders.js +8 -3
- package/src/controllers/billing.controllers.js +23 -0
- package/src/controllers/brandsBilling.controller.js +107 -0
- package/src/dtos/validation.dtos.js +6 -0
- package/src/routes/brandsBilling.routes.js +2 -2
- package/src/routes/invoice.routes.js +6 -6
package/package.json
CHANGED
|
@@ -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
|
},
|
|
@@ -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: [
|
|
@@ -426,6 +426,13 @@ export async function brandInvoiceList( req, res ) {
|
|
|
426
426
|
},
|
|
427
427
|
} ];
|
|
428
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
|
+
|
|
429
436
|
// If the user picked an explicit Month or Year, ignore durationFilter so the
|
|
430
437
|
// two ranges don't conflict (e.g. "current month" + April would always 0).
|
|
431
438
|
const hasMonthYear = ( req.body.monthFilter && String( req.body.monthFilter ) !== '' ) || ( req.body.yearFilter && String( req.body.yearFilter ) !== '' );
|
|
@@ -677,6 +684,76 @@ export async function latestDailyPricing( req, res ) {
|
|
|
677
684
|
} );
|
|
678
685
|
}
|
|
679
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
|
+
|
|
680
757
|
if ( req.body.export ) {
|
|
681
758
|
const exportdata = [];
|
|
682
759
|
stores.forEach( ( store ) => {
|
|
@@ -697,6 +774,7 @@ export async function latestDailyPricing( req, res ) {
|
|
|
697
774
|
'Zone Camera Count': isZone ? ( store.zoneCameraCount || 0 ) : 0,
|
|
698
775
|
'Zone Count': isZone ? ( store.zoneCount || 0 ) : 0,
|
|
699
776
|
'Working Days': product.workingdays || 0,
|
|
777
|
+
'Invoice Amount': productInvoiceAmount( store, product ),
|
|
700
778
|
'Status': store.status || '',
|
|
701
779
|
} );
|
|
702
780
|
} );
|
|
@@ -711,6 +789,7 @@ export async function latestDailyPricing( req, res ) {
|
|
|
711
789
|
'Zone Camera Count': 0,
|
|
712
790
|
'Zone Count': 0,
|
|
713
791
|
'Working Days': 0,
|
|
792
|
+
'Invoice Amount': 0,
|
|
714
793
|
'Status': store.status || '',
|
|
715
794
|
} );
|
|
716
795
|
}
|
|
@@ -725,6 +804,34 @@ export async function latestDailyPricing( req, res ) {
|
|
|
725
804
|
storeList = stores.slice( skip, skip + Number( req.body.limit ) );
|
|
726
805
|
}
|
|
727
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
|
+
|
|
728
835
|
// Monthly Billing Summary — one row per month of the brand's invoice
|
|
729
836
|
// history (stores billed + invoice amount), newest first. The UI tags the
|
|
730
837
|
// current/last-generated rows and computes month-over-month deltas.
|
|
@@ -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 = {
|
|
@@ -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 );
|