tango-app-api-payment-subscription 3.5.3 → 3.5.5
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/migrate-billing-prorata-pricing.js +66 -0
- package/src/controllers/billing.controllers.js +142 -1
- package/src/controllers/brandsBilling.controller.js +1 -0
- package/src/controllers/invoice.controller.js +295 -31
- package/src/controllers/paymentSubscription.controllers.js +20 -7
- package/src/dtos/validation.dtos.js +2 -0
- package/src/routes/billing.routes.js +2 -1
- package/src/routes/invoice.routes.js +2 -1
- package/src/services/paymentAccount.service.js +5 -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.5",
|
|
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.
|
|
32
|
+
"tango-api-schema": "^2.6.9",
|
|
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,66 @@
|
|
|
1
|
+
// One-shot migration: replaces the legacy billing-group proRata values
|
|
2
|
+
// 'before15' → 'flat' and 'after15' → 'prorate'. Idempotent — safe to
|
|
3
|
+
// re-run; rows already on the new values are skipped.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// MONGO_URI="mongodb://..." node scripts/migrate-billing-prorata-pricing.js
|
|
7
|
+
//
|
|
8
|
+
// Sequencing: MUST run BEFORE deploying the tightened schema enum
|
|
9
|
+
// ['prorate','flat'] in tango-api-schema, otherwise updateBillingGroup
|
|
10
|
+
// calls on unmigrated rows will fail Mongoose ValidationError.
|
|
11
|
+
|
|
12
|
+
import mongoose from 'mongoose';
|
|
13
|
+
import 'dotenv/config';
|
|
14
|
+
|
|
15
|
+
const NEW_VALUES = [ 'prorate', 'flat' ];
|
|
16
|
+
|
|
17
|
+
async function run() {
|
|
18
|
+
const uri = process.env.MONGO_URI;
|
|
19
|
+
if ( !uri ) {
|
|
20
|
+
console.error( 'MONGO_URI env var is required' );
|
|
21
|
+
process.exit( 1 );
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
await mongoose.connect( uri );
|
|
25
|
+
// strict:false so we don't pin to a specific tango-api-schema version.
|
|
26
|
+
const Billing = mongoose.model(
|
|
27
|
+
'_migrateBilling',
|
|
28
|
+
new mongoose.Schema( {}, { strict: false } ),
|
|
29
|
+
'billings',
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// before15 (legacy "full-month for stores active most of the month") → flat
|
|
33
|
+
const flatResult = await Billing.updateMany(
|
|
34
|
+
{ proRata: 'before15' },
|
|
35
|
+
{ $set: { proRata: 'flat' } },
|
|
36
|
+
);
|
|
37
|
+
console.log( `Migrated ${flatResult.modifiedCount} billing group(s) from 'before15' → 'flat'` );
|
|
38
|
+
|
|
39
|
+
// after15 (legacy "always actual working days") → prorate
|
|
40
|
+
const prorateResult = await Billing.updateMany(
|
|
41
|
+
{ proRata: 'after15' },
|
|
42
|
+
{ $set: { proRata: 'prorate' } },
|
|
43
|
+
);
|
|
44
|
+
console.log( `Migrated ${prorateResult.modifiedCount} billing group(s) from 'after15' → 'prorate'` );
|
|
45
|
+
|
|
46
|
+
// Surface any rows whose proRata is something unexpected (manual review).
|
|
47
|
+
const stray = await Billing.find(
|
|
48
|
+
{ proRata: { $exists: true, $nin: [ ...NEW_VALUES, null, '' ] } },
|
|
49
|
+
{ _id: 1, clientId: 1, groupName: 1, proRata: 1 },
|
|
50
|
+
).limit( 50 ).lean();
|
|
51
|
+
|
|
52
|
+
if ( stray.length ) {
|
|
53
|
+
console.warn( `Found ${stray.length} row(s) with unexpected proRata values:` );
|
|
54
|
+
stray.forEach( ( s ) => console.warn( ' -', s._id.toString(), s.clientId, s.groupName, '=>', s.proRata ) );
|
|
55
|
+
console.warn( 'Review these manually before tightening the schema enum.' );
|
|
56
|
+
} else {
|
|
57
|
+
console.log( 'No stray proRata values found. Safe to tighten the schema.' );
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await mongoose.disconnect();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
run().catch( ( err ) => {
|
|
64
|
+
console.error( err );
|
|
65
|
+
process.exit( 1 );
|
|
66
|
+
} );
|
|
@@ -4,6 +4,7 @@ import * as invoice from '../services/invoice.service.js';
|
|
|
4
4
|
import { aggregatebilling, countDocuments, create, deleteOne, find, findOne, updateMany, updateOne } from '../services/billing.service.js';
|
|
5
5
|
import * as invoiceService from '../services/invoice.service.js';
|
|
6
6
|
import mongoose from 'mongoose';
|
|
7
|
+
import axios from 'axios';
|
|
7
8
|
import dayjs from 'dayjs';
|
|
8
9
|
import { leadGet } from '../services/lead.service.js';
|
|
9
10
|
import { findOneClient } from '../services/clientPayment.services.js';
|
|
@@ -577,7 +578,6 @@ export const getInvoices = async ( req, res ) => {
|
|
|
577
578
|
filterStartDate = new Date( dayjs().subtract( 3, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
|
|
578
579
|
filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
|
|
579
580
|
}
|
|
580
|
-
|
|
581
581
|
if ( req.body?.filter && !req.body?.searchValue ) {
|
|
582
582
|
matchStage.$match['$and'] = [
|
|
583
583
|
{ billingDate: { $gte: filterStartDate } },
|
|
@@ -868,4 +868,145 @@ export async function getClientProducts( req, res ) {
|
|
|
868
868
|
}
|
|
869
869
|
}
|
|
870
870
|
|
|
871
|
+
// GSTIN address lookup via Sheet GST Check
|
|
872
|
+
// (https://sheet.gstincheck.co.in). Returns the registered company name
|
|
873
|
+
// + principal place of business address so the UI can auto-fill the
|
|
874
|
+
// billing-group form.
|
|
875
|
+
//
|
|
876
|
+
// Requires GST_LOOKUP_API_KEY in the API environment. The key is embedded
|
|
877
|
+
// in the provider's URL path (their auth scheme); we never log it.
|
|
878
|
+
//
|
|
879
|
+
// Caller validates GSTIN format (15 chars, alphanumeric). Server-side we
|
|
880
|
+
// re-check before forwarding to the provider.
|
|
881
|
+
export async function gstinLookup( req, res ) {
|
|
882
|
+
try {
|
|
883
|
+
const gstin = String( req.params.gstin || '' ).trim().toUpperCase();
|
|
884
|
+
if ( !/^[0-9A-Z]{15}$/.test( gstin ) ) {
|
|
885
|
+
return res.sendError( 'Invalid GSTIN format', 400 );
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// GSTIN state-code prefix → State + Place of Supply (matches the
|
|
889
|
+
// table the frontend already maintains).
|
|
890
|
+
const stateMap = {
|
|
891
|
+
'01': 'Jammu and Kashmir', '02': 'Himachal Pradesh', '03': 'Punjab',
|
|
892
|
+
'04': 'Chandigarh', '05': 'Uttarakhand', '06': 'Haryana', '07': 'Delhi',
|
|
893
|
+
'08': 'Rajasthan', '09': 'Uttar Pradesh', '10': 'Bihar', '11': 'Sikkim',
|
|
894
|
+
'12': 'Arunachal Pradesh', '13': 'Nagaland', '14': 'Manipur',
|
|
895
|
+
'15': 'Mizoram', '16': 'Tripura', '17': 'Meghalaya', '18': 'Assam',
|
|
896
|
+
'19': 'West Bengal', '20': 'Jharkhand', '21': 'Odisha',
|
|
897
|
+
'22': 'Chhattisgarh', '23': 'Madhya Pradesh', '24': 'Gujarat',
|
|
898
|
+
'25': 'Daman and Diu', '27': 'Maharashtra', '29': 'Karnataka',
|
|
899
|
+
'30': 'Goa', '32': 'Kerala', '33': 'Tamil Nadu', '34': 'Puducherry',
|
|
900
|
+
'36': 'Telangana', '37': 'Andhra Pradesh',
|
|
901
|
+
};
|
|
902
|
+
const code = gstin.substring( 0, 2 );
|
|
903
|
+
const stateName = stateMap[code];
|
|
904
|
+
if ( !stateName ) {
|
|
905
|
+
return res.sendError( 'Unknown state code in GSTIN', 404 );
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Real lookup via Sheet GST Check (https://gstincheck.co.in/).
|
|
909
|
+
// Endpoint: GET https://sheet.gstincheck.co.in/check/{API_KEY}/{GSTIN}
|
|
910
|
+
// Auth: API key embedded in the URL path (no header). Set
|
|
911
|
+
// GST_LOOKUP_API_KEY in the API's environment.
|
|
912
|
+
const apiKey = process.env.GST_LOOKUP_API_KEY;
|
|
913
|
+
console.log( '🚀 ~ gstinLookup ~ apiKey:', apiKey );
|
|
914
|
+
if ( !apiKey ) {
|
|
915
|
+
// Fail explicitly rather than silently fall back — that way a missing
|
|
916
|
+
// key shows up immediately instead of mock data leaking into prod.
|
|
917
|
+
logger.error( { function: 'gstinLookup', message: 'GST_LOOKUP_API_KEY not set' } );
|
|
918
|
+
return res.sendError( 'GST lookup is not configured on the server', 500 );
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
let providerResponse;
|
|
922
|
+
try {
|
|
923
|
+
providerResponse = await axios.get(
|
|
924
|
+
`https://sheet.gstincheck.co.in/check/${encodeURIComponent( apiKey )}/${encodeURIComponent( gstin )}`,
|
|
925
|
+
{ timeout: 8000 },
|
|
926
|
+
);
|
|
927
|
+
} catch ( axiosErr ) {
|
|
928
|
+
// Distinguish provider-down vs other errors so the client can show a
|
|
929
|
+
// useful message. 4xx from the provider is treated as a real error;
|
|
930
|
+
// network errors fall through to a generic 502.
|
|
931
|
+
const status = axiosErr?.response?.status;
|
|
932
|
+
logger.error( { error: axiosErr?.message, status, function: 'gstinLookup.provider', gstin } );
|
|
933
|
+
if ( status === 404 ) {
|
|
934
|
+
return res.sendError( 'GSTIN not found', 404 );
|
|
935
|
+
}
|
|
936
|
+
return res.sendError( 'GST lookup service unavailable', 502 );
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const body = providerResponse?.data || {};
|
|
940
|
+
if ( !body.flag || !body.data ) {
|
|
941
|
+
// Provider reached but couldn't resolve this GSTIN.
|
|
942
|
+
return res.sendError( 'GSTIN not found', 404 );
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Map provider's response shape into the contract the frontend expects.
|
|
946
|
+
// Provider uses Indian-government short names (lgnm, tradeNam, pradr,
|
|
947
|
+
// sts, etc.); we normalize to our flat structure.
|
|
948
|
+
const src = body.data;
|
|
949
|
+
// TEMP: full raw response logged so we can confirm field names per
|
|
950
|
+
// GSTIN — remove once the mapping is verified across a few real cases.
|
|
951
|
+
logger.error( { function: 'gstinLookup.raw', gstin, providerData: src } );
|
|
952
|
+
|
|
953
|
+
const principal = src.pradr || src.pradr1 || {};
|
|
954
|
+
// The provider has used both `addr` (newer) and a flatter form
|
|
955
|
+
// (older). Coalesce — `addr` if present, otherwise the principal
|
|
956
|
+
// itself contains the address fields.
|
|
957
|
+
const addr = principal.addr || principal || {};
|
|
958
|
+
|
|
959
|
+
// If the provider returned a fully-formed display address string, use
|
|
960
|
+
// it directly for addressLineOne — that's the canonical comma-joined
|
|
961
|
+
// address GSTN itself displays. Otherwise build it from the
|
|
962
|
+
// individual fields. This avoids re-joining in a different order than
|
|
963
|
+
// the registry intended.
|
|
964
|
+
const fullAddr = ( principal.adr || principal.address || src.adr || '' ).trim();
|
|
965
|
+
|
|
966
|
+
let addressLineOne;
|
|
967
|
+
let addressLineTwo;
|
|
968
|
+
if ( fullAddr ) {
|
|
969
|
+
// The registry format is `bno, bnm, flno, st, loc, lndmrk, city,
|
|
970
|
+
// dst, stcd, pncd`. Split on commas, take the first half for line 1
|
|
971
|
+
// and the rest (minus city/state/pincode tail which goes to the
|
|
972
|
+
// separate fields) for line 2. Heuristic: split into two halves.
|
|
973
|
+
const parts = fullAddr.split( ',' ).map( ( s ) => s.trim() ).filter( Boolean );
|
|
974
|
+
const half = Math.ceil( parts.length / 2 );
|
|
975
|
+
addressLineOne = parts.slice( 0, half ).join( ', ' );
|
|
976
|
+
addressLineTwo = parts.slice( half ).join( ', ' );
|
|
977
|
+
} else {
|
|
978
|
+
const line1Parts = [ addr.bno, addr.bnm, addr.flno, addr.st ].filter( ( p ) => p && String( p ).trim() );
|
|
979
|
+
const line2Parts = [ addr.loc, addr.lndmrk, addr.dst ].filter( ( p ) => p && String( p ).trim() );
|
|
980
|
+
addressLineOne = line1Parts.join( ', ' );
|
|
981
|
+
addressLineTwo = line2Parts.join( ', ' );
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// City: prefer explicit `city` / `dst`. Some records put the city
|
|
985
|
+
// name in `loc` and the colony / sub-area in `dst` — prefer city, but
|
|
986
|
+
// fall back to dst then loc when missing.
|
|
987
|
+
const cityValue = addr.city || addr.dst || addr.loc || '';
|
|
988
|
+
|
|
989
|
+
const data = {
|
|
990
|
+
gstin: src.gstin || gstin,
|
|
991
|
+
legalName: src.lgnm || src.legalName || '',
|
|
992
|
+
tradeName: src.tradeNam || src.tradeName || '',
|
|
993
|
+
addressLineOne,
|
|
994
|
+
addressLineTwo,
|
|
995
|
+
city: cityValue,
|
|
996
|
+
// Prefer provider's state name; fall back to our state-code map so a
|
|
997
|
+
// missing field doesn't blank out the Place of Supply.
|
|
998
|
+
state: addr.stcd || addr.state || stateName,
|
|
999
|
+
country: 'India',
|
|
1000
|
+
pinCode: addr.pncd ? String( addr.pncd ) : ( addr.pincode ? String( addr.pincode ) : '' ),
|
|
1001
|
+
placeOfSupply: `${addr.stcd || addr.state || stateName} (${code})`,
|
|
1002
|
+
status: src.sts || '',
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
return res.sendSuccess( data );
|
|
1006
|
+
} catch ( error ) {
|
|
1007
|
+
logger.error( { error: error, function: 'gstinLookup' } );
|
|
1008
|
+
return res.sendError( error, 500 );
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
871
1012
|
|
|
@@ -113,6 +113,72 @@ export async function createInvoice( req, res ) {
|
|
|
113
113
|
invoiceGroupList.push( invoiceGroup );
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// Custom-create path: the Create Invoice modal posts a fully-composed
|
|
117
|
+
// invoice (companyName, products, tax, totals — all entered by the user).
|
|
118
|
+
// The legacy aggregation paths above don't handle this shape and would
|
|
119
|
+
// silently no-op (empty invoiceGroupList → loop skipped → 2xx with nothing
|
|
120
|
+
// written). When the caller sets customInvoice=true we insert exactly
|
|
121
|
+
// what they sent, with a server-generated invoice number.
|
|
122
|
+
if ( req.body.customInvoice === true ) {
|
|
123
|
+
if ( !req.body.clientId ) {
|
|
124
|
+
return res.sendError( 'clientId is required for customInvoice', 400 );
|
|
125
|
+
}
|
|
126
|
+
const Finacialyear = getCurrentFinancialYear();
|
|
127
|
+
const previousinvoice = await invoiceService.findandsort(
|
|
128
|
+
{ invoice: { $regex: `^INV-${Finacialyear}-` } },
|
|
129
|
+
{},
|
|
130
|
+
{ invoiceIndex: -1 },
|
|
131
|
+
);
|
|
132
|
+
let invoiceNo = '00001';
|
|
133
|
+
if ( previousinvoice && previousinvoice.length > 0 ) {
|
|
134
|
+
invoiceNo = Number( previousinvoice[0].invoiceIndex ) + 1;
|
|
135
|
+
invoiceNo = invoiceNo.toString().padStart( 5, '0' );
|
|
136
|
+
}
|
|
137
|
+
const baseDate = req.body.billingDate ? dayjs( req.body.billingDate ) : dayjs();
|
|
138
|
+
const data = {
|
|
139
|
+
invoice: `INV-${Finacialyear}-${invoiceNo}`,
|
|
140
|
+
invoiceIndex: invoiceNo,
|
|
141
|
+
clientId: req.body.clientId,
|
|
142
|
+
groupId: req.body.groupId || undefined,
|
|
143
|
+
groupName: req.body.groupName || '',
|
|
144
|
+
companyName: req.body.companyName || '',
|
|
145
|
+
companyAddress: req.body.companyAddress || '',
|
|
146
|
+
GSTNumber: req.body.GSTNumber || '',
|
|
147
|
+
PlaceOfSupply: req.body.PlaceOfSupply || '',
|
|
148
|
+
products: Array.isArray( req.body.products ) ? req.body.products : [],
|
|
149
|
+
tax: Array.isArray( req.body.tax ) ? req.body.tax : [],
|
|
150
|
+
amount: Math.round( Number( req.body.amount ) || 0 ),
|
|
151
|
+
totalAmount: Math.round( Number( req.body.totalAmount ) || 0 ),
|
|
152
|
+
stores: Number( req.body.stores ) || 0,
|
|
153
|
+
currency: req.body.currency || 'inr',
|
|
154
|
+
billingDate: baseDate.toDate(),
|
|
155
|
+
dueDate: req.body.dueDate ? new Date( req.body.dueDate ) : baseDate.add( 30, 'days' ).toDate(),
|
|
156
|
+
monthOfbilling: baseDate.format( 'MM' ),
|
|
157
|
+
paymentMethod: 'Online',
|
|
158
|
+
status: 'pendingCsm',
|
|
159
|
+
paymentStatus: 'unpaid',
|
|
160
|
+
};
|
|
161
|
+
const created = await invoiceService.create( data );
|
|
162
|
+
const logObj = {
|
|
163
|
+
userName: req.user?.userName,
|
|
164
|
+
email: req.user?.email,
|
|
165
|
+
clientId: data.clientId,
|
|
166
|
+
logSubType: 'invoiceCreated',
|
|
167
|
+
logType: 'invoice',
|
|
168
|
+
date: new Date(),
|
|
169
|
+
changes: [ `${data.invoice} invoice created manually by ${req.user?.email}` ],
|
|
170
|
+
eventType: 'create',
|
|
171
|
+
timestamp: new Date(),
|
|
172
|
+
showTo: [ 'tango' ],
|
|
173
|
+
};
|
|
174
|
+
try {
|
|
175
|
+
insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, logObj );
|
|
176
|
+
} catch ( logErr ) {
|
|
177
|
+
logger.error( { error: logErr, function: 'createInvoice.customLog' } );
|
|
178
|
+
}
|
|
179
|
+
return res.sendSuccess( { data: created } );
|
|
180
|
+
}
|
|
181
|
+
|
|
116
182
|
for ( let group of invoiceGroupList ) {
|
|
117
183
|
let Finacialyear = getCurrentFinancialYear();
|
|
118
184
|
// Scope the highest-index lookup to invoices created in the current FY
|
|
@@ -142,7 +208,14 @@ export async function createInvoice( req, res ) {
|
|
|
142
208
|
let amount = products.reduce( ( sum, product ) => sum + product.amount, 0 );
|
|
143
209
|
let taxList = [];
|
|
144
210
|
let totalAmount = 0;
|
|
145
|
-
|
|
211
|
+
// International billing groups: skip tax calculation entirely. The
|
|
212
|
+
// tax array stays empty so the PDF won't render a tax line and
|
|
213
|
+
// totalAmount equals subtotal. Default 'domestic' preserves the
|
|
214
|
+
// existing GST/IGST/CGST/SGST behavior for legacy records that have
|
|
215
|
+
// no taxCalculationType set yet.
|
|
216
|
+
if ( group.taxCalculationType === 'international' ) {
|
|
217
|
+
totalAmount = Math.round( amount );
|
|
218
|
+
} else if ( group.gst && group.gst.slice( 0, 2 ) == '33' ) {
|
|
146
219
|
let taxAmount = ( amount * 18 ) / 100;
|
|
147
220
|
totalAmount = Math.round( amount + taxAmount );
|
|
148
221
|
taxList.push(
|
|
@@ -303,7 +376,7 @@ export async function invoiceDownload( req, res ) {
|
|
|
303
376
|
let [ firstWord, secondWord ] = item.productName.replace( /([a-z])([A-Z])/g, '$1 $2' ).split( ' ' );
|
|
304
377
|
firstWord = firstWord.charAt( 0 ).toUpperCase() + firstWord.slice( 1 );
|
|
305
378
|
item.productName = firstWord + ' ' + secondWord;
|
|
306
|
-
item.price =
|
|
379
|
+
item.price = item.price .toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
|
|
307
380
|
item.amount = item.amount.toLocaleString( 'en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 } );
|
|
308
381
|
item.currency = invoiceCurrency;
|
|
309
382
|
} );
|
|
@@ -939,6 +1012,11 @@ function inWords( num ) {
|
|
|
939
1012
|
async function standardPrice( group, getClient, baseDate ) {
|
|
940
1013
|
console.log( '🚀 ~ standardPrice ~ baseDate:', baseDate.format( 'MMM YYYY' ) );
|
|
941
1014
|
const currentMonthDays = dayjs().daysInMonth();
|
|
1015
|
+
// Pricing method: 'flat' => bill every store for the full month
|
|
1016
|
+
// regardless of working days. 'prorate' => bill for actual working days.
|
|
1017
|
+
// Computed once so the aggregation pipelines can inline a $literal.
|
|
1018
|
+
const isFlatPricing = group.proRata === 'flat';
|
|
1019
|
+
console.log( '🚀 ~ standardPrice ~ isFlatPricing:', isFlatPricing );
|
|
942
1020
|
let billingTypeMap = {};
|
|
943
1021
|
if ( getClient?.planDetails?.product ) {
|
|
944
1022
|
getClient.planDetails.product.forEach( ( p ) => {
|
|
@@ -986,6 +1064,13 @@ async function standardPrice( group, getClient, baseDate ) {
|
|
|
986
1064
|
storeStatus: '$stores.status',
|
|
987
1065
|
zoneCount: { $ifNull: [ '$stores.zoneCount', 0 ] },
|
|
988
1066
|
cameraCount: { $ifNull: [ '$stores.cameraCount', 0 ] },
|
|
1067
|
+
// Pull per-store camera splits off the daily-pricing store doc so the
|
|
1068
|
+
// downstream perCamera branch (see below) can multiply price by the
|
|
1069
|
+
// actual camera count. Without these the second $group would $sum
|
|
1070
|
+
// missing fields and totalTraffic/ZoneCameraCount would always be 0
|
|
1071
|
+
// — silently degrading perCamera billing to perStore.
|
|
1072
|
+
trafficCameraCount: { $ifNull: [ '$stores.trafficCameraCount', 0 ] },
|
|
1073
|
+
zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
|
|
989
1074
|
},
|
|
990
1075
|
},
|
|
991
1076
|
{
|
|
@@ -997,23 +1082,31 @@ async function standardPrice( group, getClient, baseDate ) {
|
|
|
997
1082
|
$project: {
|
|
998
1083
|
productName: 1,
|
|
999
1084
|
storeId: 1,
|
|
1000
|
-
|
|
1085
|
+
// isFlatPricing is the group-level pricing flag, baked into every
|
|
1086
|
+
// doc so the next $project's $cond can read it.
|
|
1087
|
+
isFlatPricing: { $literal: isFlatPricing },
|
|
1001
1088
|
workingDays: 1,
|
|
1002
1089
|
storeStatus: 1,
|
|
1003
1090
|
zoneCount: 1,
|
|
1004
1091
|
cameraCount: 1,
|
|
1092
|
+
trafficCameraCount: 1,
|
|
1093
|
+
zoneCameraCount: 1,
|
|
1005
1094
|
},
|
|
1006
1095
|
},
|
|
1007
1096
|
{
|
|
1008
1097
|
$project: {
|
|
1009
1098
|
productName: 1,
|
|
1010
1099
|
storeId: 1,
|
|
1100
|
+
// Flat pricing => every store billed for the full month.
|
|
1101
|
+
// Prorate => keep the actual workingDays.
|
|
1011
1102
|
workingDays: {
|
|
1012
|
-
$cond: { if:
|
|
1103
|
+
$cond: { if: '$isFlatPricing', then: currentMonthDays, else: '$workingDays' },
|
|
1013
1104
|
},
|
|
1014
1105
|
storeStatus: 1,
|
|
1015
1106
|
zoneCount: 1,
|
|
1016
1107
|
cameraCount: 1,
|
|
1108
|
+
trafficCameraCount: 1,
|
|
1109
|
+
zoneCameraCount: 1,
|
|
1017
1110
|
},
|
|
1018
1111
|
},
|
|
1019
1112
|
{
|
|
@@ -1025,6 +1118,8 @@ async function standardPrice( group, getClient, baseDate ) {
|
|
|
1025
1118
|
workingdays: { $first: '$workingDays' },
|
|
1026
1119
|
zoneCount: { $first: '$zoneCount' },
|
|
1027
1120
|
cameraCount: { $first: '$cameraCount' },
|
|
1121
|
+
trafficCameraCount: { $first: '$trafficCameraCount' },
|
|
1122
|
+
zoneCameraCount: { $first: '$zoneCameraCount' },
|
|
1028
1123
|
},
|
|
1029
1124
|
},
|
|
1030
1125
|
{
|
|
@@ -1228,16 +1323,24 @@ async function standardPrice( group, getClient, baseDate ) {
|
|
|
1228
1323
|
storeStatus: '$stores.status',
|
|
1229
1324
|
zoneCount: { $ifNull: [ '$stores.zoneCount', 0 ] },
|
|
1230
1325
|
cameraCount: { $ifNull: [ '$stores.cameraCount', 0 ] },
|
|
1326
|
+
// perCamera (eachStore branch) reads these on the per-store record
|
|
1327
|
+
// below; projecting them through is required for the camera-count
|
|
1328
|
+
// multiplier to fire.
|
|
1329
|
+
trafficCameraCount: { $ifNull: [ '$stores.trafficCameraCount', 0 ] },
|
|
1330
|
+
zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
|
|
1231
1331
|
},
|
|
1232
1332
|
},
|
|
1233
1333
|
{ $match: { workingDays: { $gt: 0 }, productName: { $in: eachStoreProductNames } } },
|
|
1234
1334
|
{
|
|
1235
1335
|
$project: {
|
|
1236
1336
|
productName: 1, storeId: 1, storeName: 1,
|
|
1337
|
+
// Flat pricing => full month per store. Prorate => actual working
|
|
1338
|
+
// days. Same rule as the overall-store branch above.
|
|
1237
1339
|
workingDays: {
|
|
1238
|
-
$cond: { if: { $
|
|
1340
|
+
$cond: { if: { $literal: isFlatPricing }, then: currentMonthDays, else: '$workingDays' },
|
|
1239
1341
|
},
|
|
1240
1342
|
zoneCount: 1, cameraCount: 1,
|
|
1343
|
+
trafficCameraCount: 1, zoneCameraCount: 1,
|
|
1241
1344
|
},
|
|
1242
1345
|
},
|
|
1243
1346
|
{
|
|
@@ -1265,6 +1368,11 @@ async function standardPrice( group, getClient, baseDate ) {
|
|
|
1265
1368
|
{
|
|
1266
1369
|
$project: {
|
|
1267
1370
|
productName: 1, storeId: 1, storeName: 1, workingDays: 1, zoneCount: 1, cameraCount: 1,
|
|
1371
|
+
// Final whitelist project — must carry trafficCameraCount and
|
|
1372
|
+
// zoneCameraCount through to the JS consumer. Without these the
|
|
1373
|
+
// perCamera branch in the for-loop below sees undefined and falls
|
|
1374
|
+
// back to storeCount=1.
|
|
1375
|
+
trafficCameraCount: 1, zoneCameraCount: 1,
|
|
1268
1376
|
price: '$matchedStandard.negotiatePrice',
|
|
1269
1377
|
period: { $cond: { if: { $lt: [ '$workingDays', currentMonthDays ] }, then: 'prorate', else: 'fullmonth' } },
|
|
1270
1378
|
},
|
|
@@ -1273,6 +1381,7 @@ async function standardPrice( group, getClient, baseDate ) {
|
|
|
1273
1381
|
|
|
1274
1382
|
for ( let store of perStoreData ) {
|
|
1275
1383
|
let productBillingType = billingTypeMap[store.productName] || 'perStore';
|
|
1384
|
+
|
|
1276
1385
|
let storeCount = 1;
|
|
1277
1386
|
if ( store.productName === 'tangoZone' ) {
|
|
1278
1387
|
if ( productBillingType === 'perZone' && store.zoneCount > 0 ) {
|
|
@@ -1340,6 +1449,9 @@ async function standardPrice( group, getClient, baseDate ) {
|
|
|
1340
1449
|
|
|
1341
1450
|
async function stepPrice( group, getClient ) {
|
|
1342
1451
|
const currentMonthDays = dayjs().daysInMonth();
|
|
1452
|
+
// 'flat' => every store billed for full month.
|
|
1453
|
+
// 'prorate' => actual working days. See standardPrice for the same flag.
|
|
1454
|
+
const isFlatPricing = group.proRata === 'flat';
|
|
1343
1455
|
let billingTypeMap = {};
|
|
1344
1456
|
if ( getClient?.planDetails?.product ) {
|
|
1345
1457
|
getClient.planDetails.product.forEach( ( p ) => {
|
|
@@ -1387,6 +1499,11 @@ async function stepPrice( group, getClient ) {
|
|
|
1387
1499
|
storeStatus: '$stores.status',
|
|
1388
1500
|
zoneCount: { $ifNull: [ '$stores.zoneCount', 0 ] },
|
|
1389
1501
|
cameraCount: { $ifNull: [ '$stores.cameraCount', 0 ] },
|
|
1502
|
+
// Pull per-store camera splits; same fix as standardPrice — without
|
|
1503
|
+
// these the second $group sums missing fields and the perCamera
|
|
1504
|
+
// branch in the downstream map silently falls back to perStore.
|
|
1505
|
+
trafficCameraCount: { $ifNull: [ '$stores.trafficCameraCount', 0 ] },
|
|
1506
|
+
zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
|
|
1390
1507
|
},
|
|
1391
1508
|
},
|
|
1392
1509
|
{
|
|
@@ -1398,23 +1515,30 @@ async function stepPrice( group, getClient ) {
|
|
|
1398
1515
|
$project: {
|
|
1399
1516
|
productName: 1,
|
|
1400
1517
|
storeId: 1,
|
|
1401
|
-
|
|
1518
|
+
// Group-level flag baked into every doc; consumed by the next
|
|
1519
|
+
// $project's $cond.
|
|
1520
|
+
isFlatPricing: { $literal: isFlatPricing },
|
|
1402
1521
|
workingDays: 1,
|
|
1403
1522
|
storeStatus: 1,
|
|
1404
1523
|
zoneCount: 1,
|
|
1405
1524
|
cameraCount: 1,
|
|
1525
|
+
trafficCameraCount: 1,
|
|
1526
|
+
zoneCameraCount: 1,
|
|
1406
1527
|
},
|
|
1407
1528
|
},
|
|
1408
1529
|
{
|
|
1409
1530
|
$project: {
|
|
1410
1531
|
productName: 1,
|
|
1411
1532
|
storeId: 1,
|
|
1533
|
+
// Flat => full month per store. Prorate => actual working days.
|
|
1412
1534
|
workingDays: {
|
|
1413
|
-
$cond: { if:
|
|
1535
|
+
$cond: { if: '$isFlatPricing', then: currentMonthDays, else: '$workingDays' },
|
|
1414
1536
|
},
|
|
1415
1537
|
storeStatus: 1,
|
|
1416
1538
|
zoneCount: 1,
|
|
1417
1539
|
cameraCount: 1,
|
|
1540
|
+
trafficCameraCount: 1,
|
|
1541
|
+
zoneCameraCount: 1,
|
|
1418
1542
|
},
|
|
1419
1543
|
}, {
|
|
1420
1544
|
$group: {
|
|
@@ -1425,6 +1549,8 @@ async function stepPrice( group, getClient ) {
|
|
|
1425
1549
|
workingdays: { $first: '$workingDays' },
|
|
1426
1550
|
zoneCount: { $first: '$zoneCount' },
|
|
1427
1551
|
cameraCount: { $first: '$cameraCount' },
|
|
1552
|
+
trafficCameraCount: { $first: '$trafficCameraCount' },
|
|
1553
|
+
zoneCameraCount: { $first: '$zoneCameraCount' },
|
|
1428
1554
|
},
|
|
1429
1555
|
},
|
|
1430
1556
|
{
|
|
@@ -1493,16 +1619,24 @@ async function stepPrice( group, getClient ) {
|
|
|
1493
1619
|
storeStatus: '$stores.status',
|
|
1494
1620
|
zoneCount: { $ifNull: [ '$stores.zoneCount', 0 ] },
|
|
1495
1621
|
cameraCount: { $ifNull: [ '$stores.cameraCount', 0 ] },
|
|
1622
|
+
// perCamera (eachStore branch) reads these on the per-store record
|
|
1623
|
+
// below; projecting them through is required for the camera-count
|
|
1624
|
+
// multiplier to fire.
|
|
1625
|
+
trafficCameraCount: { $ifNull: [ '$stores.trafficCameraCount', 0 ] },
|
|
1626
|
+
zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
|
|
1496
1627
|
},
|
|
1497
1628
|
},
|
|
1498
1629
|
{ $match: { workingDays: { $gt: 0 }, productName: { $in: eachStoreProductNames } } },
|
|
1499
1630
|
{
|
|
1500
1631
|
$project: {
|
|
1501
1632
|
productName: 1, storeId: 1, storeName: 1,
|
|
1633
|
+
// Flat pricing => full month per store. Prorate => actual working
|
|
1634
|
+
// days. Same rule as the overall-store branch above.
|
|
1502
1635
|
workingDays: {
|
|
1503
|
-
$cond: { if: { $
|
|
1636
|
+
$cond: { if: { $literal: isFlatPricing }, then: currentMonthDays, else: '$workingDays' },
|
|
1504
1637
|
},
|
|
1505
1638
|
zoneCount: 1, cameraCount: 1,
|
|
1639
|
+
trafficCameraCount: 1, zoneCameraCount: 1,
|
|
1506
1640
|
},
|
|
1507
1641
|
},
|
|
1508
1642
|
{
|
|
@@ -1704,35 +1838,64 @@ export async function clientInvoiceList( req, res ) {
|
|
|
1704
1838
|
clientId: { $in: findClients },
|
|
1705
1839
|
},
|
|
1706
1840
|
} ];
|
|
1841
|
+
|
|
1842
|
+
// If the user picked an explicit Month or Year, ignore the Duration
|
|
1843
|
+
// filter — otherwise the two ranges conflict (e.g. "current month" +
|
|
1844
|
+
// April would always return zero results). Mirrors brand-invoices.
|
|
1845
|
+
const hasMonthYear = ( req.body.monthFilter && String( req.body.monthFilter ) !== '' ) ||
|
|
1846
|
+
( req.body.yearFilter && String( req.body.yearFilter ) !== '' );
|
|
1847
|
+
|
|
1707
1848
|
let filterStartDate = '';
|
|
1708
1849
|
let filterEndDate = '';
|
|
1709
1850
|
|
|
1710
|
-
if (
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1851
|
+
if ( !hasMonthYear ) {
|
|
1852
|
+
if ( req.body?.filter && req.body.filter == 'current' ) {
|
|
1853
|
+
filterStartDate = new Date( dayjs().startOf( 'month' ).format( 'YYYY-MM-DD' ) );
|
|
1854
|
+
filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
|
|
1855
|
+
}
|
|
1856
|
+
if ( req.body?.filter && req.body.filter == 'prev' ) {
|
|
1857
|
+
filterStartDate = new Date( dayjs().subtract( 1, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
|
|
1858
|
+
filterEndDate = new Date( dayjs().subtract( 1, 'month' ).endOf( 'month' ).format( 'YYYY-MM-DD' ) );
|
|
1859
|
+
}
|
|
1860
|
+
if ( req.body?.filter && req.body.filter == 'last' ) {
|
|
1861
|
+
filterStartDate = new Date( dayjs().subtract( 12, 'month' ).startOf( 'month' ).format( 'YYYY-MM-DD' ) );
|
|
1862
|
+
filterEndDate = new Date( dayjs().endOf( 'month' ).format( 'YYYY-MM-DD' ) );
|
|
1863
|
+
}
|
|
1723
1864
|
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
{
|
|
1727
|
-
$
|
|
1728
|
-
$
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
],
|
|
1732
|
-
},
|
|
1865
|
+
if ( req.body?.filter ) {
|
|
1866
|
+
query.push( {
|
|
1867
|
+
$match: {
|
|
1868
|
+
$and: [
|
|
1869
|
+
{ billingDate: { $gte: filterStartDate } },
|
|
1870
|
+
{ billingDate: { $lte: filterEndDate } },
|
|
1871
|
+
],
|
|
1733
1872
|
},
|
|
1873
|
+
} );
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1734
1876
|
|
|
1735
|
-
|
|
1877
|
+
// Month / Year filters (independent of durationFilter). Both 1-based:
|
|
1878
|
+
// monthFilter '1'..'12', yearFilter four-digit string like '2026'.
|
|
1879
|
+
// billingDate may be stored as Date OR string in some legacy rows, so
|
|
1880
|
+
// coerce inside the pipeline before $year/$month.
|
|
1881
|
+
const monthNum = req.body.monthFilter ? parseInt( req.body.monthFilter, 10 ) : null;
|
|
1882
|
+
const yearNum = req.body.yearFilter ? parseInt( req.body.yearFilter, 10 ) : null;
|
|
1883
|
+
if ( ( monthNum && monthNum >= 1 && monthNum <= 12 ) || yearNum ) {
|
|
1884
|
+
const billingDateExpr = {
|
|
1885
|
+
$cond: [
|
|
1886
|
+
{ $eq: [ { $type: '$billingDate' }, 'date' ] },
|
|
1887
|
+
'$billingDate',
|
|
1888
|
+
{ $toDate: '$billingDate' },
|
|
1889
|
+
],
|
|
1890
|
+
};
|
|
1891
|
+
const expr = { $and: [] };
|
|
1892
|
+
if ( yearNum ) {
|
|
1893
|
+
expr.$and.push( { $eq: [ { $year: billingDateExpr }, yearNum ] } );
|
|
1894
|
+
}
|
|
1895
|
+
if ( monthNum && monthNum >= 1 && monthNum <= 12 ) {
|
|
1896
|
+
expr.$and.push( { $eq: [ { $month: billingDateExpr }, monthNum ] } );
|
|
1897
|
+
}
|
|
1898
|
+
query.push( { $match: { $expr: expr } } );
|
|
1736
1899
|
}
|
|
1737
1900
|
|
|
1738
1901
|
|
|
@@ -1769,6 +1932,7 @@ export async function clientInvoiceList( req, res ) {
|
|
|
1769
1932
|
clientName: '$clientDetails.clientName',
|
|
1770
1933
|
logo: '$clientDetails.logo',
|
|
1771
1934
|
currencyType: '$currency',
|
|
1935
|
+
currency: 1,
|
|
1772
1936
|
invoice: 1,
|
|
1773
1937
|
stores: 1,
|
|
1774
1938
|
amount: 1,
|
|
@@ -1776,6 +1940,7 @@ export async function clientInvoiceList( req, res ) {
|
|
|
1776
1940
|
groupName: 1,
|
|
1777
1941
|
status: 1,
|
|
1778
1942
|
paymentStatus: 1,
|
|
1943
|
+
paidAmount: { $ifNull: [ '$paidAmount', 0 ] },
|
|
1779
1944
|
clientId: 1,
|
|
1780
1945
|
billingDate: 1,
|
|
1781
1946
|
},
|
|
@@ -2081,6 +2246,105 @@ export async function PaymentStatusChange( req, res ) {
|
|
|
2081
2246
|
}
|
|
2082
2247
|
}
|
|
2083
2248
|
|
|
2249
|
+
// Record a (full or partial) payment against an invoice. Appends to
|
|
2250
|
+
// paymentHistory, increments paidAmount, and recomputes paymentStatus
|
|
2251
|
+
// (unpaid → partial → paid). Body: { invoiceId, amount, date, method?,
|
|
2252
|
+
// reference?, notes? }. invoiceId is the human-readable invoice number
|
|
2253
|
+
// (e.g. INV-26-27-00077), matching the legacy PaymentStatusChange contract.
|
|
2254
|
+
export async function recordPayment( req, res ) {
|
|
2255
|
+
try {
|
|
2256
|
+
const { invoiceId, amount, date, method, reference, notes } = req.body;
|
|
2257
|
+
if ( !invoiceId ) {
|
|
2258
|
+
return res.sendError( 'invoiceId is required', 400 );
|
|
2259
|
+
}
|
|
2260
|
+
const amountNum = Number( amount );
|
|
2261
|
+
if ( !Number.isFinite( amountNum ) || amountNum <= 0 ) {
|
|
2262
|
+
return res.sendError( 'amount must be a positive number', 400 );
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
const invoice = await invoiceService.findOne( { invoice: invoiceId } );
|
|
2266
|
+
if ( !invoice ) {
|
|
2267
|
+
return res.sendError( 'Invoice not found', 404 );
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
const previousPaid = Number( invoice.paidAmount ) || 0;
|
|
2271
|
+
const newPaid = Math.round( ( previousPaid + amountNum ) * 100 ) / 100;
|
|
2272
|
+
const totalAmount = Number( invoice.totalAmount ) || 0;
|
|
2273
|
+
|
|
2274
|
+
// Reject overpayment — finance teams want this caught early. They can
|
|
2275
|
+
// record an exact final payment that brings the running total to the
|
|
2276
|
+
// invoice total; anything beyond that is a data-entry error.
|
|
2277
|
+
if ( totalAmount > 0 && newPaid > totalAmount + 0.01 ) {
|
|
2278
|
+
return res.sendError(
|
|
2279
|
+
`Payment exceeds outstanding balance. Outstanding: ${( totalAmount - previousPaid ).toFixed( 2 )}`,
|
|
2280
|
+
400,
|
|
2281
|
+
);
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
let derivedStatus = 'unpaid';
|
|
2285
|
+
if ( newPaid >= totalAmount - 0.01 && totalAmount > 0 ) {
|
|
2286
|
+
derivedStatus = 'paid';
|
|
2287
|
+
} else if ( newPaid > 0 ) {
|
|
2288
|
+
derivedStatus = 'partial';
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
const historyEntry = {
|
|
2292
|
+
amount: amountNum,
|
|
2293
|
+
date: date ? new Date( date ) : new Date(),
|
|
2294
|
+
method: method || undefined,
|
|
2295
|
+
reference: reference || undefined,
|
|
2296
|
+
notes: notes || undefined,
|
|
2297
|
+
recordedBy: req.user?.email || req.user?.userName || undefined,
|
|
2298
|
+
recordedAt: new Date(),
|
|
2299
|
+
};
|
|
2300
|
+
|
|
2301
|
+
const update = {
|
|
2302
|
+
$set: {
|
|
2303
|
+
paidAmount: newPaid,
|
|
2304
|
+
paymentStatus: derivedStatus,
|
|
2305
|
+
...( derivedStatus === 'paid' ? { paidDate: new Date() } : {} ),
|
|
2306
|
+
},
|
|
2307
|
+
$push: { paymentHistory: historyEntry },
|
|
2308
|
+
};
|
|
2309
|
+
|
|
2310
|
+
// Use invoiceUpdateOne — it passes the raw update object straight to
|
|
2311
|
+
// Mongoose. invoiceService.updateOne (without "invoice" prefix) wraps
|
|
2312
|
+
// its second arg in $set, which would turn our { $set, $push } into
|
|
2313
|
+
// { $set: { $set, $push } } and Mongoose silently drops both as
|
|
2314
|
+
// unknown fields under strict mode. (That's why an earlier version
|
|
2315
|
+
// of this controller responded 200 but didn't actually persist.)
|
|
2316
|
+
const result = await invoiceService.invoiceUpdateOne( { invoice: invoiceId }, update );
|
|
2317
|
+
logger.info?.( { function: 'recordPayment', invoiceId, matched: result?.matchedCount, modified: result?.modifiedCount } );
|
|
2318
|
+
|
|
2319
|
+
try {
|
|
2320
|
+
const logObj = {
|
|
2321
|
+
userName: req.user?.userName,
|
|
2322
|
+
email: req.user?.email,
|
|
2323
|
+
clientId: invoice.clientId,
|
|
2324
|
+
logSubType: 'paymentRecorded',
|
|
2325
|
+
logType: 'invoice',
|
|
2326
|
+
date: new Date(),
|
|
2327
|
+
changes: [ `Payment of ${amountNum} recorded for ${invoiceId}. Status: ${derivedStatus} (${newPaid}/${totalAmount}).` ],
|
|
2328
|
+
eventType: 'update',
|
|
2329
|
+
timestamp: new Date(),
|
|
2330
|
+
showTo: [ 'tango' ],
|
|
2331
|
+
};
|
|
2332
|
+
insertOpenSearchData( JSON.parse( process.env.OPENSEARCH ).activityLog, logObj );
|
|
2333
|
+
} catch ( logErr ) {
|
|
2334
|
+
logger.error( { error: logErr, function: 'recordPayment.log' } );
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
return res.sendSuccess( {
|
|
2338
|
+
paidAmount: newPaid,
|
|
2339
|
+
pendingAmount: Math.max( 0, totalAmount - newPaid ),
|
|
2340
|
+
paymentStatus: derivedStatus,
|
|
2341
|
+
} );
|
|
2342
|
+
} catch ( error ) {
|
|
2343
|
+
logger.error( { error: error, function: 'recordPayment' } );
|
|
2344
|
+
return res.sendError( error, 500 );
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2084
2348
|
export async function checkPaymentStatus( req, res ) {
|
|
2085
2349
|
try {
|
|
2086
2350
|
let findInvoice = await invoiceService.find( { status: 'approved', paymentStatus: { $ne: 'paid' } } );
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
/* eslint-disable new-cap */
|
|
3
|
-
import { logger, download, sendEmailWithSES, insertOpenSearchData, getOpenSearchData, updateOpenSearchData, getOpenSearchById
|
|
3
|
+
import { logger, download, sendEmailWithSES, insertOpenSearchData, getOpenSearchData, updateOpenSearchData, getOpenSearchById } from 'tango-app-api-middleware';
|
|
4
4
|
import * as paymentService from '../services/clientPayment.services.js';
|
|
5
5
|
import * as basePriceService from '../services/basePrice.service.js';
|
|
6
6
|
import * as storeService from '../services/store.service.js';
|
|
@@ -3332,7 +3332,7 @@ export const dailyPricingInsert = async ( req, res ) => {
|
|
|
3332
3332
|
];
|
|
3333
3333
|
let dailyData = await dailyPriceService.aggregate( query );
|
|
3334
3334
|
let cameraDetails = await cameraService.find( { storeId: getStore[storeIndex].storeId, clientId: requestClient[clientIndex], isActivated: true, isUp: true }, { streamName: 1, productModule: 1 } );
|
|
3335
|
-
console.log( '🚀 ~ dailyPricingInsert ~ cameraDetails:', cameraDetails );
|
|
3335
|
+
// console.log( '🚀 ~ dailyPricingInsert ~ cameraDetails:', cameraDetails );
|
|
3336
3336
|
|
|
3337
3337
|
let trafficCameraCount = cameraDetails.filter( ( cam ) =>
|
|
3338
3338
|
( cam.productModule || [] ).some( ( mod ) =>
|
|
@@ -3351,14 +3351,16 @@ export const dailyPricingInsert = async ( req, res ) => {
|
|
|
3351
3351
|
mod.productName === 'tangoZone' && mod.checked === true,
|
|
3352
3352
|
),
|
|
3353
3353
|
).map( ( cam ) => cam.streamName );
|
|
3354
|
-
let
|
|
3354
|
+
let allcameraname = cameraDetails?.map( ( cam ) => cam?.streamName );
|
|
3355
|
+
console.log( '🚀 ~ dailyPricingInsert ~ zoneCameraStreamNames:', zoneCameraStreamNames );
|
|
3356
|
+
let taggingDetails = await taggingService.find( { storeId: getStore[storeIndex].storeId, clientId: requestClient[clientIndex], productName: 'tangoZone', coordinates: { $ne: [] }, streamName: { $in: allcameraname } }, { tagName: 1 } );
|
|
3355
3357
|
let zoneCount = taggingDetails.length;
|
|
3356
3358
|
let zoneName = taggingDetails.map( ( item ) => item.tagName );
|
|
3357
3359
|
let firstDate = dayjs( getStore[storeIndex]?.edge?.firstFileDate ).format( 'YYYY-MM-DD' );
|
|
3358
3360
|
let workingdays;
|
|
3359
3361
|
let workingdaystrax;
|
|
3360
3362
|
const givenDate = dayjs( requestData.date );
|
|
3361
|
-
console.log( '🚀 ~ dailyPricingInsert ~ cameraCount:', trafficCameraCount, zoneCameraCount, zoneCount );
|
|
3363
|
+
console.log( '🚀 ~ dailyPricingInsert ~ cameraCount:', getStore[storeIndex].storeId, trafficCameraCount, zoneCameraCount, zoneCount );
|
|
3362
3364
|
const isFirstDayOfMonth = givenDate.isSame( dayjs().startOf( 'month' ), 'day' );
|
|
3363
3365
|
if ( getStore[storeIndex]?.edge.firstFile ) {
|
|
3364
3366
|
if ( firstDate < requestData.date && getStore[storeIndex]?.status == 'active' &&
|
|
@@ -3471,13 +3473,24 @@ export const dailyPricingInsert = async ( req, res ) => {
|
|
|
3471
3473
|
}
|
|
3472
3474
|
// console.log( clientIndex, requestClient.length-1 );
|
|
3473
3475
|
if ( clientIndex == requestClient.length - 1 ) {
|
|
3474
|
-
let teamsAlertUrls = process.env.teamsAlertURL ? JSON.parse( process.env.teamsAlertURL ) : '';
|
|
3476
|
+
// let teamsAlertUrls = process.env.teamsAlertURL ? JSON.parse( process.env.teamsAlertURL ) : '';
|
|
3475
3477
|
let totalcount = await dailyPriceService.find( { dateString: dayjs( requestData.date, 'YYYY-MM-DD' ).format( 'YYYY-MM-DD' ) } );
|
|
3476
3478
|
let teamsMsg = `${totalcount.length} clients data is inserted on ${dayjs( requestData.date, 'YYYY-MM-DD' ).format( 'YYYY-MM-DD' )}`;
|
|
3477
3479
|
console.log( teamsMsg );
|
|
3478
|
-
if ( teamsAlertUrls.invoiceAlert ) {
|
|
3479
|
-
|
|
3480
|
+
// if ( teamsAlertUrls.invoiceAlert ) {
|
|
3481
|
+
// sendTeamsNotification( teamsAlertUrls.invoiceAlert, teamsMsg );
|
|
3482
|
+
// }
|
|
3483
|
+
|
|
3484
|
+
const SES = JSON.parse( process.env.SES );
|
|
3485
|
+
let fromEmail = SES.accountsEmail;
|
|
3486
|
+
console.log( process.env.invoiceAlert );
|
|
3487
|
+
let invoiceEmails = JSON.parse( process.env.invoiceAlert );
|
|
3488
|
+
let mailSubject = `Daily Invoice Alert ${dayjs( requestData.date, 'YYYY-MM-DD' ).format( 'YYYY-MM-DD' )}`;
|
|
3489
|
+
if ( invoiceEmails ) {
|
|
3490
|
+
const result = await sendEmailWithSES( invoiceEmails?.email, mailSubject, teamsMsg, '', fromEmail, [] );
|
|
3491
|
+
console.log( '🚀 ~ dailyPricingInsert ~ result:', result );
|
|
3480
3492
|
}
|
|
3493
|
+
|
|
3481
3494
|
return res.sendSuccess( 'Price Details Inserted Successfully' );
|
|
3482
3495
|
}
|
|
3483
3496
|
}
|
|
@@ -314,6 +314,7 @@ export const createBillingGroupBody = joi.object(
|
|
|
314
314
|
proRata: joi.string().optional().allow( '' ),
|
|
315
315
|
paymentCategory: joi.string().optional().allow( '' ),
|
|
316
316
|
currency: joi.string().optional().allow( '' ),
|
|
317
|
+
taxCalculationType: joi.string().valid( 'domestic', 'international' ).optional(),
|
|
317
318
|
isInstallationOneTime: joi.boolean().optional(),
|
|
318
319
|
installationFee: joi.number().optional(),
|
|
319
320
|
paymentCycle: joi.string().optional().allow( '' ),
|
|
@@ -352,6 +353,7 @@ export const updateBillingGroupBody = joi.object(
|
|
|
352
353
|
proRata: joi.string().optional().allow( '' ),
|
|
353
354
|
paymentCategory: joi.string().optional().allow( '' ),
|
|
354
355
|
currency: joi.string().optional().allow( '' ),
|
|
356
|
+
taxCalculationType: joi.string().valid( 'domestic', 'international' ).optional(),
|
|
355
357
|
isInstallationOneTime: joi.boolean().optional(),
|
|
356
358
|
installationFee: joi.number().optional(),
|
|
357
359
|
paymentCycle: joi.string().optional().allow( '' ),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
export const billingRouter = express.Router();
|
|
3
3
|
import { accessVerification, isAllowedSessionHandler, validate } from 'tango-app-api-middleware';
|
|
4
|
-
import { createBillingGroup, deleteBillingGroup, getAllBillingGroups, getBillingGroups, getClientProducts, getInvoices, getLeadProducts, onetimePayment, subscribedStoreList, updateBillingGroup } from '../controllers/billing.controllers.js';
|
|
4
|
+
import { createBillingGroup, deleteBillingGroup, getAllBillingGroups, getBillingGroups, getClientProducts, getInvoices, getLeadProducts, onetimePayment, subscribedStoreList, updateBillingGroup, gstinLookup } from '../controllers/billing.controllers.js';
|
|
5
5
|
import { billingGroupSchema, clientProductsValid, createBillingGroupsSchema, deleteBillingGroupsSchema, getBillingGroupsSchema, getInvoiceSchema, leadProductsValid, onetimeFeeValid, subscribedStoreListSchema, updateBillingGroupsSchema } from '../dtos/validation.dtos.js';
|
|
6
6
|
|
|
7
7
|
|
|
@@ -27,3 +27,4 @@ billingRouter.get( '/getLeadProducts/:email', isAllowedSessionHandler, validate(
|
|
|
27
27
|
billingRouter.get( '/getClientProducts/:id', isAllowedSessionHandler, validate( clientProductsValid ), getClientProducts );
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
billingRouter.get( '/gst-lookup/:gstin', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'Global', name: 'Billing', permissions: [ 'isAdd' ] } ] } ), gstinLookup );
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
|
-
import { createInvoice, invoiceDownload, invoiceDownloadBulk, clientInvoiceList, creditTransactionlist, pendingInvoices, applyDiscount, migrateInvoice, PaymentStatusChange, checkPaymentStatus, getInvoice, invoiceAnnexure, updateInvoice, getClientBasePricing, deleteInvoice, approveInvoiceCsm, approveInvoiceFinance, approveInvoiceApproval } from '../controllers/invoice.controller.js';
|
|
2
|
+
import { createInvoice, invoiceDownload, invoiceDownloadBulk, clientInvoiceList, creditTransactionlist, pendingInvoices, applyDiscount, migrateInvoice, PaymentStatusChange, checkPaymentStatus, getInvoice, invoiceAnnexure, updateInvoice, getClientBasePricing, deleteInvoice, approveInvoiceCsm, approveInvoiceFinance, approveInvoiceApproval, recordPayment } from '../controllers/invoice.controller.js';
|
|
3
3
|
import { isAllowedSessionHandler, accessVerification, validate } from 'tango-app-api-middleware';
|
|
4
4
|
import { getInvoiceHeads, updateInvoiceHeads } from '../controllers/applicationDefault.controllers.js';
|
|
5
5
|
import { updateInvoiceHeadsSchema } from '../dtos/validation.dtos.js';
|
|
@@ -29,6 +29,7 @@ invoiceRouter.post( '/pendingInvoices', isAllowedSessionHandler, pendingInvoices
|
|
|
29
29
|
invoiceRouter.post( '/applyDiscount', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), applyDiscount );
|
|
30
30
|
invoiceRouter.post( '/migrateInvoice', migrateInvoice );
|
|
31
31
|
invoiceRouter.post( '/PaymentStatusChange', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), PaymentStatusChange );
|
|
32
|
+
invoiceRouter.post( '/recordPayment', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), recordPayment );
|
|
32
33
|
invoiceRouter.post( '/checkPaymentStatus', checkPaymentStatus );
|
|
33
34
|
invoiceRouter.get( '/getInvoice/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), getInvoice );
|
|
34
35
|
invoiceRouter.get( '/invoiceAnnexure/:invoiceId', isAllowedSessionHandler, superadminBypass( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), invoiceAnnexure );
|
|
@@ -9,6 +9,11 @@ export const findOneAccount = ( query = {}, record = {} ) => {
|
|
|
9
9
|
export const updateOneAccount = async ( query ={}, record={} ) => {
|
|
10
10
|
return await model.paymentAccountModel.updateOne( query, { $set: record } );
|
|
11
11
|
};
|
|
12
|
+
|
|
13
|
+
export const createAccount = async ( record ) => {
|
|
14
|
+
return await model.paymentAccountModel.create( record );
|
|
15
|
+
};
|
|
16
|
+
|
|
12
17
|
export const aggregate = ( query = [] ) => {
|
|
13
18
|
return model.paymentAccountModel.aggregate( query );
|
|
14
19
|
};
|